Files
photo-live/services/api/test/users-lookup.e2e-spec.ts
T
浅念 53c25697a6 feat(lookup): 用户前缀查找放宽至单字符——中文姓氏单字搜索(全链路)
契约 minLength 2→1 + 服务端 bounds 1..64 + admin 两处/App 一处前端门槛
同步放宽;防抖测试基线更新('s' 与合并后 'sta' 共两条)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 15:37:41 +08:00

399 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/api/test/users-lookup.e2e-spec.ts —— P1b Task 4 账号前缀查询 e2e
// (先写后实现);P4a Task 1 契约统一后同步修订。真实 app + MySQL。覆盖:
// ① 前缀命中:prefix 'al' → albert+alice(account 升序);条目为统一
// AdminUser 字段集(id/account/displayName + 治理字段 role/sourceStatus/
// localLocked/credentialStatus/locale/createdAt;无邮箱/手机/凭据字段);
// 响应为 AdminUserList(items + nextCursor/hasMore 游标元信息);
// ② 账号资格过滤:credentialStatus=pending / sourceStatus=disabled /
// localLocked=true 均排除(只留「激活」用户);
// ③ 大小写敏感(库级 utf8mb4_0900_bin 二进制校对,契约已注明):'AL' → 空、
// 'carol' → 空、'Carol' → 命中;
// ④ LIKE 通配符转义(ESCAPE 子句):前缀中的 _ / % 按字面匹配,不作单字符/
// 任意串通配(esc_ 仅命中字面下划线账号;al% 空);
// ⑤ 参数边界:accountPrefix 缺失 → 管理列表模式分流(staff 403 / admin
// 200,Task 6 双模式收口后语义)/1 字符/65 字符 → 400;2 与 64 字符合法;limit 缺省 20(P1b 时
// 为 10,P4a 统一分页 bounds)、1..100 界内、0/101/非整数 → 400;
// ⑥ 访问策略(P1b 决策,契约描述):未认证 401;任何已认证工作人员(含非
// admin 的 staff)200;admin 200。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { AppCnModule } from '../src/app-cn.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
jest.setTimeout(120_000);
describe('账号查询 GET /admin/users (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let staffToken: string; // 非 admin 工作人员(访问策略断言用)
let adminToken: string;
let aliceId: string;
let albertId: string;
let carolId: string;
let escUnderscoreId: string;
beforeAll(async () => {
await resetDb();
const moduleRef = await Test.createTestingModule({
imports: [AppCnModule],
}).compile();
app = moduleRef.createNestApplication();
app.setGlobalPrefix(API_PREFIX);
await app.init();
prisma = new PrismaClient();
const mkUser = (
account: string,
displayName: string,
extra: {
role?: string;
sourceStatus?: string;
credentialStatus?: string;
localLocked?: boolean;
} = {},
) =>
prisma.users.create({
data: {
account,
passwordHash: 'x',
displayName,
...extra,
},
select: { id: true },
});
aliceId = (await mkUser('alice', '爱丽')).id;
albertId = (await mkUser('albert', '艾伯')).id;
await mkUser('bob', '博伟');
carolId = (await mkUser('Carol', '卡萝')).id; // 大写 C——大小写敏感断言
// 资格排除组(前缀均 'al' 命中域,但不应出现在结果里)。
await mkUser('aldis', '停用者', { sourceStatus: 'disabled' });
await mkUser('alpend', '待激活', { credentialStatus: 'pending' });
await mkUser('allock', '锁定者', { localLocked: true });
// LIKE 通配符转义断言组:esc_ 前缀若未转义 _,会同时命中 escshape。
escUnderscoreId = (await mkUser('esc_ape', '下划线')).id;
await mkUser('escshape', '形状');
// 默认 limit=10 截断断言组:12 个 zz-look-* 账号(升序 01..12)。
for (let i = 1; i <= 12; i++) {
await mkUser(`zz-look-${String(i).padStart(2, '0')}`, `批量${i}`);
}
// 请求方:staff(非 admin)与 admin。
const staffId = (await mkUser('ul-staff', '陆石')).id;
const adminId = (await mkUser('ul-admin', '路安', { role: 'admin' })).id;
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prisma.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
staffToken = await mkToken(staffId);
adminToken = await mkToken(adminId);
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
function lookup(
token: string | null,
query?: Record<string, string | number>,
) {
const req = request(app
.getHttpServer())
.get(`/${API_PREFIX}/admin/users`);
if (token !== null) {
req.set('Authorization', `Bearer ${token}`);
}
return req.query(query ?? {});
}
it("前缀 'al' → albert+alice 升序;禁用/待激活/锁定排除;条目与响应形状(AdminUserList)", async () => {
const res = await lookup(staffToken, { accountPrefix: 'al' });
expect(res.status).toBe(200);
// 响应为统一 AdminUserList(P4a):items + 游标元信息(前缀模式即取即用
// → nextCursor=null / hasMore=false)。
expect(Object.keys(res.body).sort()).toEqual(['hasMore', 'items', 'nextCursor']);
expect(res.body.nextCursor).toBeNull();
expect(res.body.hasMore).toBe(false);
const items = res.body.items as Record<string, unknown>[];
// account 升序:albert < alice;恰好两条(排除组全不出现在结果)。
expect(items.map((item) => item.account)).toEqual(['albert', 'alice']);
expect(items.map((item) => item.id)).toEqual([albertId, aliceId]);
expect(items.map((item) => item.displayName)).toEqual(['艾伯', '爱丽']);
// 条目形状:AdminUser 字段集(治理字段随 P4a 统一暴露;仍无邮箱/手机/
// 密码哈希等凭据字段)。注意数组序 = Array#sort 的码点序(大小写敏感):
// 'localLocked'(大写 L,0x4C)排在 'locale'(e,0x65)之前。
for (const item of items) {
expect(Object.keys(item).sort()).toEqual([
'account',
'createdAt',
'credentialStatus',
'displayName',
'id',
'localLocked',
'locale',
'role',
'sourceStatus',
]);
expect(item.role).toBe('staff');
expect(item.sourceStatus).toBe('local');
expect(item.localLocked).toBe(false);
expect(item.credentialStatus).toBe('active');
}
});
it('大小写敏感(bin 校对):“AL”/“carol” → 空;“Carol” → 命中', async () => {
const upper = await lookup(staffToken, { accountPrefix: 'AL' });
expect(upper.status).toBe(200);
expect(upper.body.items).toEqual([]);
const lowerCarol = await lookup(staffToken, { accountPrefix: 'carol' });
expect(lowerCarol.status).toBe(200);
expect(lowerCarol.body.items).toEqual([]);
const exact = await lookup(staffToken, { accountPrefix: 'Carol' });
expect(exact.status).toBe(200);
// 条目为 AdminUser 全字段:以关键字段子集断言(字段全集见用例 ①)。
expect(exact.body.items).toEqual([
expect.objectContaining({
id: carolId,
account: 'Carol',
displayName: '卡萝',
}),
]);
});
it('LIKE 通配符按字面匹配(ESCAPE):“esc_” 仅命中字面下划线账号;“al%” 空', async () => {
// 未转义时 _ 作单字符通配会同时命中 escshape——转义后只命中 esc_ape。
const underscore = await lookup(staffToken, { accountPrefix: 'esc_' });
expect(underscore.status).toBe(200);
expect(underscore.body.items).toEqual([
expect.objectContaining({
id: escUnderscoreId,
account: 'esc_ape',
displayName: '下划线',
}),
]);
// 未转义时 al% 作任意串通配会命中 albert/alice 等——转义后按字面前缀匹配。
const percent = await lookup(staffToken, { accountPrefix: 'al%' });
expect(percent.status).toBe(200);
expect(percent.body.items).toEqual([]);
});
// displayName 前缀并入(2026-09-29 方案 A:运营按姓名搜人——账号=手机号下
// 姓名是运营唯一记得的标识;管理列表 query 模式此前已 OR displayName,
// 前缀模式对齐)。语义:account 前缀 OR displayName 前缀,资格过滤/
// account 升序/大小写敏感(bin 校对对两列同样生效)不变。
it('displayName 前缀命中(account 不匹配也可查);两列命中并集按 account 升序', async () => {
const byName = await lookup(staffToken, { accountPrefix: '爱丽' });
expect(byName.status).toBe(200);
expect(byName.body.items).toEqual([
expect.objectContaining({ id: aliceId, account: 'alice', displayName: '爱丽' }),
]);
// 前缀同时命中两列的多个用户:'批量' 命中 12 个 zz-look-* 的姓名,
// account 升序 + limit 截断(limit:5 → 01..05)。
const paged = await lookup(staffToken, { accountPrefix: '批量', limit: 5 });
expect(paged.status).toBe(200);
expect(paged.body.items.map((i: { account: string }) => i.account)).toEqual([
'zz-look-01', 'zz-look-02', 'zz-look-03', 'zz-look-04', 'zz-look-05',
]);
});
it('displayName 命中同样受激活资格过滤(停用/待激活/锁定经姓名也查不到)', async () => {
const disabled = await lookup(staffToken, { accountPrefix: '停用' });
expect(disabled.status).toBe(200);
expect(disabled.body.items).toEqual([]);
const pending = await lookup(staffToken, { accountPrefix: '待激活' });
expect(pending.status).toBe(200);
expect(pending.body.items).toEqual([]);
const locked = await lookup(staffToken, { accountPrefix: '锁定' });
expect(locked.status).toBe(200);
expect(locked.body.items).toEqual([]);
});
it('accountPrefix 边界:缺失 → 双模式分流(staff 403 / admin 200 管理列表);1/65 字符 → 400;64 → 200 空', async () => {
// Task 6 起缺失 accountPrefix 进入管理列表模式(仅 admin):staff 无前缀
// → 403 AUTH_FORBIDDEN;admin → 200 管理列表(全量用户,含非激活态——
// 深度断言由 admin-users.e2e-spec 守护,此处只断言模式分流与响应形状)。
const staffMissing = await lookup(staffToken, {});
expect(staffMissing.status).toBe(403);
expect(staffMissing.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const adminMissing = await lookup(adminToken, {});
expect(adminMissing.status).toBe(200);
expect(Object.keys(adminMissing.body).sort()).toEqual([
'hasMore',
'items',
'nextCursor',
'total',
]);
// 管理列表包含前缀模式排除的非激活用户(本 spec 的排除组)。
const adminAccounts = (
adminMissing.body.items as { account: string }[]
).map((item) => item.account);
expect(adminAccounts).toContain('aldis');
expect(adminAccounts).toContain('alpend');
expect(adminAccounts).toContain('allock');
// 显式空串是「提供了非法值」而非「未提供」→ 仍 400。
const empty = await lookup(staffToken, { accountPrefix: '' });
expect(empty.status).toBe(400);
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 单字符已合法(2026-09-29 自 2 放宽——中文姓氏单字搜索):'a' 命中
// alice/albert(displayName 前缀并入后 '爱' 亦单字可用)。
const single = await lookup(staffToken, { accountPrefix: 'a' });
expect(single.status).toBe(200);
expect(single.body.items.map((i: { account: string }) => i.account)).toEqual([
'albert',
'alice',
]);
const singleName = await lookup(staffToken, { accountPrefix: '爱' });
expect(singleName.status).toBe(200);
expect(singleName.body.items).toHaveLength(1);
const long = await lookup(staffToken, { accountPrefix: 'a'.repeat(65) });
expect(long.status).toBe(400);
expect(long.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
const max = await lookup(staffToken, { accountPrefix: 'a'.repeat(64) });
expect(max.status).toBe(200);
expect(max.body.items).toEqual([]);
});
it('limit:缺省 20(P4a 统一 bounds);5 合法;0/101/非整数 → 400;100 合法', async () => {
// zz-look-* 共 12 个激活账号:缺省 limit=20 > 存量 12 → 全量升序返回。
const def = await lookup(staffToken, { accountPrefix: 'zz-look-' });
expect(def.status).toBe(200);
expect(
(def.body.items as { account: string }[]).map((item) => item.account),
).toEqual(Array.from({ length: 12 }, (_, i) => `zz-look-${String(i + 1).padStart(2, '0')}`));
const five = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit: 5,
});
expect(five.status).toBe(200);
expect(five.body.items).toHaveLength(5);
const top = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit: 100,
});
expect(top.status).toBe(200);
expect(top.body.items).toHaveLength(12); // 上限 100 > 存量 12,全量返回
for (const limit of [0, 101, 'abc']) {
const res = await lookup(staffToken, {
accountPrefix: 'zz-look-',
limit,
});
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
});
it('访问策略:未认证 401;已认证 staff(非 admin)200;admin 200', async () => {
const anon = await lookup(null, { accountPrefix: 'al' });
expect(anon.status).toBe(401);
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
// P1b 决策(契约描述):邀请发起方不限于 admin/owner,任何已认证工作人员
// 可用——此处显式断言非 admin 的 staff 可 200。
const staff = await lookup(staffToken, { accountPrefix: 'al' });
expect(staff.status).toBe(200);
expect(staff.body.items).toHaveLength(2);
const admin = await lookup(adminToken, { accountPrefix: 'al' });
expect(admin.status).toBe(200);
expect(admin.body.items).toHaveLength(2);
});
it('page 模式:page/pageSize 偏移分页 + total;synced 档案快照合并;游标/前缀模式形状不受染', async () => {
// 档案种子:albert 挂映射带 profile 快照(G4);alice 无映射。
await prisma.external_user_mappings.create({
data: {
userId: albertId,
sourceSystem: 'uniservice',
externalUserId: 'ext-albert',
sourceVersion: 1,
profile: { sn: 'A001', email: 'albert@x.com', companyAbbr: '合塑' },
},
});
// 'al' 前缀域恒 5 账号:alice/albert/aldis/alpend/allock(顺序按 createdAt
// +ID 倒序,不依赖种子时序断言具体页位)。
const page1 = await lookup(adminToken, { query: 'al', page: 1, pageSize: 2 });
expect(page1.status).toBe(200);
expect(page1.body.total).toBe(5);
expect(page1.body.hasMore).toBe(false);
expect(page1.body.nextCursor).toBeNull();
expect(page1.body.items).toHaveLength(2);
const page2 = await lookup(adminToken, { query: 'al', page: 2, pageSize: 2 });
const page3 = await lookup(adminToken, { query: 'al', page: 3, pageSize: 2 });
expect(page2.body.items).toHaveLength(2);
expect(page3.body.items).toHaveLength(1);
// 走全量:三页并集 = 5 账号、无重复。
const all = [
...(page1.body.items as { account: string }[]),
...(page2.body.items as { account: string }[]),
...(page3.body.items as { account: string }[]),
];
expect(new Set(all.map((item) => item.account)).size).toBe(5);
// synced 合并:albert(有映射)带快照且字段原样;其余行 synced=null。
const albert = all
.concat() as unknown as { account: string; synced: unknown }[];
const albertRow = albert.find((item) => item.account === 'albert');
expect(albertRow?.synced).toEqual({
sn: 'A001',
email: 'albert@x.com',
companyAbbr: '合塑',
});
const aliceRow = albert.find((item) => item.account === 'alice');
expect(aliceRow?.synced).toBeNull();
// 越界页:空 items、total 仍返回。
const beyond = await lookup(adminToken, { query: 'al', page: 4, pageSize: 2 });
expect(beyond.status).toBe(200);
expect(beyond.body.items).toEqual([]);
expect(beyond.body.total).toBe(5);
// 游标模式(不带 page):total=null(键存在但空——兼容旧客户端语义)。
const cursorMode = await lookup(adminToken, { query: 'al', limit: 2 });
expect(cursorMode.status).toBe(200);
expect(cursorMode.body.total).toBeNull();
// 前缀模式条目干净:不合并 synced(邀请候选无需档案)。
const prefix = await lookup(staffToken, { accountPrefix: 'al' });
expect(prefix.status).toBe(200);
for (const item of prefix.body.items as object[]) {
expect('synced' in item).toBe(false);
}
// 参数校验:page=0 / pageSize=101 / 非整数 → 400。
expect((await lookup(adminToken, { page: 0 })).status).toBe(400);
expect((await lookup(adminToken, { page: 1, pageSize: 101 })).status).toBe(400);
expect((await lookup(adminToken, { page: 'x' })).status).toBe(400);
});
});