// services/api/test/user-sync.e2e-spec.ts —— P4a Task 7 用户同步 e2e(先写后 // 实现)。真实 app + MySQL + cn Redis(互斥锁)。MockUserSource 场景经 // app.get(MockUserSource) 程序化注入(mock-user-source.ts 文件头约定)。 // 覆盖(T30 语义全量,红线逐条断言): // ① 首轮全量:3 用户 = 1 account 关联(本地直建账号建映射,不动其 // 凭据/角色,会话保留)+ 2 新增(V1.1:credentialStatus=active + // 初始密码=账号后 6 位,可立即登录);counts/水位/映射行; // ② 改名幂等:版本更高 → displayName 更新;同版本重跑 → 全跳过(counts 0); // ③ 上游停用与恢复:disabled → users.sourceStatus=disabled + 撤全部会话 // (令牌 401 + 正确密码登录 401)且 **localLocked 不被写入**;恢复 // active → 原密码可再登录; // ④ 红线「上游恢复不解除 localLocked」:手动停用(localLocked=true)+ // 上游停/恢复 → sourceStatus=active 而 localLocked 仍 true → 登录 401; // ⑤ 增量新增第 4 用户:mock 收到已提交水位游标(receivedCursors); // ⑥ 空响应:items=[]+nextCursor=null → 正常结束、counts 全 0、无人被动、 // 水位不进; // ⑦ 分页中断:page2 抛错 → 批次 failed、水位停在 page1、未拉到的行不出现、 // 无用户被动停用;修复后增量补齐; // ⑧ 部分无效记录:1 坏行 → partial + errors 明细(invalid_record)+ 其余 // 照常 + lastSuccessAt 不推进(partial 不是完全成功); // ⑨ 手动补拉与定时互斥:门闸制造运行中批次 → 第二次 POST 复用 runId、 // cron tick 跳过(无新批次);放行后完成; // ⑩ 增量 + 周期全量对账(RECONCILE_EVERY_N_RUNS=10):低版本行的新变更 // 对增量不可见 → 第 10 批 cursor=null 全量对账应用;缺席用户绝不停用; // ⑪ V1.1 初始凭据闭环:同步即可用初始密码登录 → activate 幂等 → // reset-password 轮换后初始密码失效(Task 6 端点联动); // ⑫ status/runs/errors API 形状 + runs 游标分页走全量 + 参数 400 + 非 // admin 403 / 匿名 401。 import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import IORedis from 'ioredis'; import { PrismaClient, USER_SYNC_STATE_SINGLETON_ID } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { AppCnModule } from '../src/app-cn.module'; import { hashPassword } from '../src/auth/password'; import { hashSessionToken } from '../src/auth/session.guard'; import { API_PREFIX } from '../src/shared/constants'; import { MockUserSource, type MockExternalRecord, type MockScenario, } from '../src/user-sync/mock-user-source'; import { USER_SYNC_LOCK_KEY } from '../src/user-sync/sync-lock'; import { UserSyncEngine } from '../src/user-sync/user-sync.service'; import { resetDb } from './reset'; jest.setTimeout(120_000); /** 批次行(断言用字段子集)。 */ interface RunRow { id: string; trigger: string; state: string; cursor: string | null; counts: { added: number; updated: number; disabled: number; failed: number }; error: string | null; startedAt: Date; finishedAt: Date | null; } describe('用户定期同步 /admin/user-sync (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let mock: MockUserSource; let engine: UserSyncEngine; let adminToken: string; let staffToken: string; let u1Id: string; let u1Token: string; let u1OriginalHash: string; beforeAll(async () => { await resetDb(); // Redis 互斥锁键不在 resetDb 清理范围(见 reset.ts 注释):spec 自清, // 防上一轮残留锁让本 spec 首批直接「复用幽灵批次」。 const redis = new IORedis(process.env.REDIS_URL_CN ?? 'redis://localhost:6379'); try { await redis.del(USER_SYNC_LOCK_KEY); } finally { await redis.quit().catch(() => undefined); } const moduleRef = await Test.createTestingModule({ imports: [AppCnModule], }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix(API_PREFIX); await app.init(); prisma = new PrismaClient(); mock = app.get(MockUserSource); engine = app.get(UserSyncEngine); const mkToken = async (userId: string): Promise => { 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; }; // ① 的关联目标:本地直建账号(真实密码 + 既有会话——同步只许建映射和 // 应用 displayName/sourceStatus,凭据/角色/会话必须原样)。 u1OriginalHash = await hashPassword('U1-Original-77'); u1Id = ( await prisma.users.create({ data: { account: 'sync-u1', passwordHash: u1OriginalHash, displayName: '本地直建', role: 'staff', }, select: { id: true }, }) ).id; u1Token = await mkToken(u1Id); const adminId = ( await prisma.users.create({ data: { account: 'sync-admin', passwordHash: await hashPassword('Admin-Sync-1'), displayName: '同步管理员', role: 'admin', }, select: { id: true }, }) ).id; adminToken = await mkToken(adminId); const staffId = ( await prisma.users.create({ data: { account: 'sync-staff', passwordHash: await hashPassword('Staff-Sync-1'), displayName: '同步员工', }, select: { id: true }, }) ).id; staffToken = await mkToken(staffId); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); // —— 工具 —— const ext = ( externalUserId: string, loginAccount: string, displayName: string, sourceVersion: number, sourceStatus: string = 'active', ): MockExternalRecord => ({ externalUserId, loginAccount, displayName, sourceStatus, sourceVersion, updatedAt: new Date(), }); function scenario(users: MockExternalRecord[], extra: Partial = {}): void { mock.setScenario({ users, ...extra }); } async function waitRunDone(runId: string): Promise { const deadline = Date.now() + 10_000; for (;;) { const row = await prisma.user_sync_runs.findUnique({ where: { id: runId } }); if (row !== null && row.state !== 'running') { return row as unknown as RunRow; } if (Date.now() > deadline) { throw new Error(`批次 ${runId} 未在时限内完成`); } await new Promise((resolve) => setTimeout(resolve, 25)); } } /** 手动补拉并等待完成(POST 202 → 轮询批次终态)。 */ async function triggerRun(): Promise<{ runId: string; run: RunRow }> { const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/admin/user-sync/runs`) .set('Authorization', `Bearer ${adminToken}`) .send(undefined); expect(res.status).toBe(202); expect(typeof res.body.runId).toBe('string'); const run = await waitRunDone(res.body.runId); return { runId: res.body.runId, run }; } function getStatus(token: string | null = adminToken) { const req = request(app.getHttpServer()) .get(`/${API_PREFIX}/admin/user-sync/status`); return token === null ? req : req.set('Authorization', `Bearer ${token}`); } function listRuns( query: Record = {}, token: string | null = adminToken, ) { const req = request(app.getHttpServer()) .get(`/${API_PREFIX}/admin/user-sync/runs`); return (token === null ? req : req.set('Authorization', `Bearer ${token}`)) .query(query); } function listErrors( query: Record, token: string | null = adminToken, ) { const req = request(app.getHttpServer()) .get(`/${API_PREFIX}/admin/user-sync/errors`); return (token === null ? req : req.set('Authorization', `Bearer ${token}`)) .query(query); } function postRun(token: string) { return request(app.getHttpServer()) .post(`/${API_PREFIX}/admin/user-sync/runs`) .set('Authorization', `Bearer ${token}`) .send(undefined); } function login(account: string, password: string) { return request(app.getHttpServer()) .post(`/${API_PREFIX}/auth/password`) .send({ account, password }); } /** 任意受保护 staff 端点探针(前缀查询):验证令牌是否仍被守卫放行。 */ function probeToken(token: string, accountPrefix = 'sync-u') { return request(app.getHttpServer()) .get(`/${API_PREFIX}/admin/users`) .set('Authorization', `Bearer ${token}`) .query({ accountPrefix }); } const userByAccount = (account: string) => prisma.users.findUnique({ where: { account } }); // —— 场景基线(版本账本见各用例注释)—— const U1 = (version: number, status = 'active') => ext('ext-1', 'sync-u1', '李一味', version, status); const U2 = (version: number, displayName = '王二') => ext('ext-2', 'sync-u2', displayName, version); const U3 = (version: number) => ext('ext-3', 'sync-u3', '赵三', version); it('① 首轮全量:1 account 关联 + 2 新增(V1.1 直接 active);counts/水位/映射/红线(不动凭据角色会话)', async () => { // U2 携带档案快照(G4 profile:随映射行落库,纯展示维度)。 scenario([ U1(1), { ...U2(1), sourceProfile: { sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制' } }, U3(1), ]); // pageSize 2 → 两页(1,2 | 3),全 v1 const { run } = await triggerRun(); expect(run.state).toBe('success'); expect(run.trigger).toBe('manual'); expect(run.counts).toEqual({ added: 2, updated: 1, disabled: 0, failed: 0 }); expect(run.cursor).toBe('1'); // 水位 = 已提交最大版本 expect(run.finishedAt).not.toBeNull(); // 新增(V1.1 初始凭据):直接 active + 初始密码=账号后 6 位(非哨兵哈希)。 for (const account of ['sync-u2', 'sync-u3']) { const user = await userByAccount(account); expect(user).not.toBeNull(); expect(user?.credentialStatus).toBe('active'); expect(user?.role).toBe('staff'); expect(user?.sourceStatus).toBe('active'); expect(user?.passwordHash.startsWith('!:pending:')).toBe(false); } // V1.1:同步完成即可用初始密码登录。 const initialLogin = await login('sync-u2', 'sync-u2'.slice(-6)); expect(initialLogin.status).toBe(200); // 关联:本地直建账号建映射 + 应用源 displayName/sourceStatus; // 凭据/角色/会话原样(红线 1:绝不写 password_hash/role)。 const u1 = await userByAccount('sync-u1'); expect(u1?.displayName).toBe('李一味'); expect(u1?.sourceStatus).toBe('active'); expect(u1?.passwordHash).toBe(u1OriginalHash); expect(u1?.role).toBe('staff'); expect(u1?.localLocked).toBe(false); const liveU1Sessions = await prisma.sessions.count({ where: { userId: u1Id, revokedAt: null }, }); expect(liveU1Sessions).toBe(1); const probed = await probeToken(u1Token); expect(probed.status).toBe(200); // 映射:稳定外部 ID 锚点 (sourceSystem, externalUserId) 唯一。 const mappings = await prisma.external_user_mappings.findMany(); expect(mappings).toHaveLength(3); expect( new Set(mappings.map((m) => `${m.sourceSystem}:${m.externalUserId}`)), ).toEqual(new Set(['mock:ext-1', 'mock:ext-2', 'mock:ext-3'])); for (const m of mappings) { expect(m.sourceVersion).toBe(1); } // profile 快照落库(G4):U2 的映射行携带源档案,字段原样。 const m2 = await prisma.external_user_mappings.findUnique({ where: { sourceSystem_externalUserId: { sourceSystem: 'mock', externalUserId: 'ext-2' }, }, }); expect(m2?.profile).toEqual({ sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制' }); // status 端点:首轮即 success + 水位 + 计数。 const status = await getStatus(); expect(status.status).toBe(200); expect(status.body.state).toBe('success'); expect(status.body.sourceVersion).toBe(1); expect(status.body.counts).toEqual({ added: 2, updated: 1, disabled: 0, failed: 0, }); expect(status.body.lastRunAt).not.toBeNull(); expect(status.body.lastSuccessAt).not.toBeNull(); }); it('② 改名幂等:版本更高 → displayName 更新;同版本重跑 → 全跳过', async () => { scenario([ U1(1), { ...U2(2, '王二改'), sourceProfile: { sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制二部' } }, U3(1), ]); const first = await triggerRun(); expect(first.run.state).toBe('success'); expect(first.run.counts).toEqual({ added: 0, updated: 1, disabled: 0, failed: 0 }); expect((await userByAccount('sync-u2'))?.displayName).toBe('王二改'); const mapping = await prisma.external_user_mappings.findUnique({ where: { sourceSystem_externalUserId: { sourceSystem: 'mock', externalUserId: 'ext-2' }, }, }); expect(mapping?.sourceVersion).toBe(2); // 快照随版本推进刷新(companyAbbr 变化生效)。 expect(mapping?.profile).toEqual({ sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制二部' }); // 同版本重跑:幂等跳过,counts 全 0,displayName 不动。 const again = await triggerRun(); expect(again.run.state).toBe('success'); expect(again.run.counts).toEqual({ added: 0, updated: 0, disabled: 0, failed: 0 }); expect((await userByAccount('sync-u2'))?.displayName).toBe('王二改'); expect(again.run.cursor).toBe('2'); }); it('③ 上游停用与恢复:disabled → sourceStatus + 撤全部会话 + 登录 401;恢复 → 原密码可登录', async () => { scenario([U1(3, 'disabled'), U2(2, '王二改'), U3(1)]); const disabled = await triggerRun(); expect(disabled.run.state).toBe('success'); expect(disabled.run.counts).toEqual({ added: 0, updated: 0, disabled: 1, failed: 0 }); const u1 = await userByAccount('sync-u1'); expect(u1?.sourceStatus).toBe('disabled'); expect(u1?.localLocked).toBe(false); // 红线:同步不写 localLocked const liveSessions = await prisma.sessions.count({ where: { userId: u1Id, revokedAt: null }, }); expect(liveSessions).toBe(0); const probed = await probeToken(u1Token); expect(probed.status).toBe(401); expect(probed.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); const lockedLogin = await login('sync-u1', 'U1-Original-77'); expect(lockedLogin.status).toBe(401); expect(lockedLogin.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); // 上游恢复(普通账号):sourceStatus=active,原密码可再登录。 scenario([U1(4), U2(2, '王二改'), U3(1)]); const recovered = await triggerRun(); expect(recovered.run.counts).toEqual({ added: 0, updated: 1, disabled: 0, failed: 0 }); expect((await userByAccount('sync-u1'))?.sourceStatus).toBe('active'); const relogin = await login('sync-u1', 'U1-Original-77'); expect(relogin.status).toBe(200); }); it('④ 红线:上游恢复不解除 localLocked(本地锁定独立维度)', async () => { // 管理员手动停用(Task 6 deactivate:localLocked=true + 撤会话)。 const deact = await request(app.getHttpServer()) .post(`/${API_PREFIX}/admin/users/${u1Id}/deactivate`) .set('Authorization', `Bearer ${adminToken}`) .send({}); expect(deact.status).toBe(200); // 上游同时停用:sourceStatus=disabled,localLocked 不被触碰(已为 true)。 scenario([U1(5, 'disabled'), U2(2, '王二改'), U3(1)]); await triggerRun(); const mid = await userByAccount('sync-u1'); expect(mid?.sourceStatus).toBe('disabled'); expect(mid?.localLocked).toBe(true); // 上游恢复:sourceStatus=active 但 localLocked **仍为 true**(3.1A 红线)。 scenario([U1(6), U2(2, '王二改'), U3(1)]); const recovered = await triggerRun(); expect(recovered.run.counts).toEqual({ added: 0, updated: 1, disabled: 0, failed: 0 }); const after = await userByAccount('sync-u1'); expect(after?.sourceStatus).toBe('active'); expect(after?.localLocked).toBe(true); const stillLocked = await login('sync-u1', 'U1-Original-77'); expect(stillLocked.status).toBe(401); expect(stillLocked.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); // 解锁唯一路径 = 管理员 PATCH localLocked=false(恢复 u1 供后续用例)。 const unlock = await request(app.getHttpServer()) .patch(`/${API_PREFIX}/admin/users/${u1Id}`) .set('Authorization', `Bearer ${adminToken}`) .send({ localLocked: false }); expect(unlock.status).toBe(200); expect((await userByAccount('sync-u1'))?.localLocked).toBe(false); }); it('⑤ 增量新增第 4 用户:mock 收到已提交水位游标,仅新行入账', async () => { scenario([ext('ext-4', 'sync-u4', '钱四', 7)]); // 水位 6 → v7 唯一可见 const { run } = await triggerRun(); expect(run.state).toBe('success'); expect(run.counts).toEqual({ added: 1, updated: 0, disabled: 0, failed: 0 }); expect(run.cursor).toBe('7'); // 增量形态证据:listPage 收到裸水位串 "6"(非 null 全量、非续翻页)。 expect(mock.receivedCursors).toEqual(['6']); const u4 = await userByAccount('sync-u4'); expect(u4).not.toBeNull(); expect(u4?.credentialStatus).toBe('active'); expect(u4?.displayName).toBe('钱四'); }); it('⑥ 空响应:items=[] + nextCursor=null → 正常结束不误伤,水位不进', async () => { const usersBefore = await prisma.users.count(); scenario([]); const { run } = await triggerRun(); expect(run.state).toBe('success'); expect(run.counts).toEqual({ added: 0, updated: 0, disabled: 0, failed: 0 }); expect(run.cursor).toBe('7'); // 水位不动 expect(run.error).toBeNull(); expect(await prisma.users.count()).toBe(usersBefore); const disabledCount = await prisma.users.count({ where: { sourceStatus: 'disabled' }, }); expect(disabledCount).toBe(0); // 空响应绝不停用任何人(红线 2) const status = await getStatus(); expect(status.body.state).toBe('success'); expect(status.body.sourceVersion).toBe(7); const errors = await listErrors({ runId: run.id }); expect(errors.status).toBe(200); expect(errors.body.items).toEqual([]); }); it('⑦ 分页中断:page2 抛错 → failed、水位停 page1、未拉到的行不出现;修复后增量补齐', async () => { scenario( [ ext('ext-5', 'sync-u5', '周五', 8), ext('ext-6', 'sync-u6', '吴六', 8), ext('ext-7', 'sync-u7', '郑七', 8), ], { pageSize: 2, failFromPage: 1 }, ); const { run } = await triggerRun(); expect(run.state).toBe('failed'); expect(run.error).not.toBeNull(); expect(run.error).toContain('mock upstream failure'); expect(run.counts).toEqual({ added: 2, updated: 0, disabled: 0, failed: 0 }); expect(run.cursor).toBe('8'); // 水位停在最后成功页(page1 提交值) // page1 已应用行保留(幂等重入前提);page2 的行不出现。 expect(await userByAccount('sync-u5')).not.toBeNull(); expect(await userByAccount('sync-u6')).not.toBeNull(); expect(await userByAccount('sync-u7')).toBeNull(); // 批次故障不牵连任何会话/停用(无用户被动)。 expect( await prisma.users.count({ where: { sourceStatus: 'disabled' } }), ).toBe(0); const status = await getStatus(); expect(status.body.state).toBe('failed'); expect(status.body.sourceVersion).toBe(8); // 修复(撤故障注入)+ 版本推进 → 增量从水位 8 续上,补齐 ext-7。 scenario([ext('ext-7', 'sync-u7', '郑七', 9)]); const healed = await triggerRun(); expect(healed.run.state).toBe('success'); expect(healed.run.counts).toEqual({ added: 1, updated: 0, disabled: 0, failed: 0 }); expect(await userByAccount('sync-u7')).not.toBeNull(); }); it('⑧ 部分无效记录:1 坏行 → partial + errors 明细,其余照常;lastSuccessAt 不推进', async () => { const before = await getStatus(); const lastSuccessAtBefore = before.body.lastSuccessAt; scenario([ ext('ext-8', 'sync-u8', '郑八', 10), { ...ext('ext-bad', 'sync-bad', '坏行', 10), displayName: '' }, // 坏行:displayName 缺失 ]); const { run } = await triggerRun(); expect(run.state).toBe('partial'); expect(run.counts).toEqual({ added: 1, updated: 0, disabled: 0, failed: 1 }); expect(run.cursor).toBe('10'); // 坏行版本不可采信,水位取有效行最大版本 expect(run.error).toBeNull(); // 坏行明细:invalid_record + 诊断详情;其余行照常应用。 expect(await userByAccount('sync-u8')).not.toBeNull(); const errors = await listErrors({ runId: run.id }); expect(errors.status).toBe(200); expect(errors.body.items).toHaveLength(1); expect(errors.body.items[0]).toEqual( expect.objectContaining({ runId: run.id, externalUserId: 'ext-bad', code: 'invalid_record', }), ); expect(errors.body.items[0].detail).toContain('displayName'); expect(typeof errors.body.items[0].createdAt).toBe('string'); // partial 不是完全成功:lastSuccessAt 不推进。 const after = await getStatus(); expect(after.body.state).toBe('partial'); expect(after.body.lastSuccessAt).toBe(lastSuccessAtBefore); }); it('⑨ 手动补拉与定时互斥:运行中批次复用 runId;cron tick 撞锁跳过', async () => { let releaseGate!: () => void; const gate = new Promise((resolve) => { releaseGate = resolve; }); scenario([ext('ext-9', 'sync-u9', '冯九', 11)], { gateFirstPage: gate }); const runsBefore = await prisma.user_sync_runs.count(); const first = await postRun(adminToken); expect(first.status).toBe(202); const runId = first.body.runId as string; // 批次卡在 page0(门闸):第二次手动补拉复用同一 runId(202 语义)。 const second = await postRun(adminToken); expect(second.status).toBe(202); expect(second.body.runId).toBe(runId); // 定时 tick 撞锁:静默跳过,不产生新批次。 await engine.tick(); expect(await prisma.user_sync_runs.count()).toBe(runsBefore + 1); const inflight = await prisma.user_sync_runs.findUnique({ where: { id: runId }, }); expect(inflight?.state).toBe('running'); const inflightStatus = await getStatus(); expect(inflightStatus.body.state).toBe('running'); // 放行 → 批次完成并落账。 releaseGate(); const done = await waitRunDone(runId); expect(done.state).toBe('success'); expect(done.counts).toEqual({ added: 1, updated: 0, disabled: 0, failed: 0 }); expect(await userByAccount('sync-u9')).not.toBeNull(); }); it('⑩ 增量 + 周期全量对账(每 10 批):低版本行新变更增量不可见,对账批应用;缺席用户绝不停用', async () => { // 对齐节奏:使「下下批」恰为第 10 批(对账批)。 const stateRow = await prisma.user_sync_state.findUniqueOrThrow({ where: { id: USER_SYNC_STATE_SINGLETON_ID }, }); const fillers = (10 - ((stateRow.runCount + 2) % 10)) % 10; scenario([]); for (let i = 0; i < fillers; i++) { await triggerRun(); } // 漂移构造:ext-2 改名到 v11 == 当前水位(低于/等于水位的行内变更 // 对增量不可见——增量只回 v > 水位)。 const usersBefore = await prisma.users.count(); scenario([U2(11, '王二对账名')]); // 增量批:收到裸水位 "11",过滤后空 → 全跳过,displayName 不变。 const incremental = await triggerRun(); expect(incremental.run.state).toBe('success'); expect(incremental.run.counts).toEqual({ added: 0, updated: 0, disabled: 0, failed: 0, }); expect(mock.receivedCursors).toEqual(['11']); expect((await userByAccount('sync-u2'))?.displayName).toBe('王二改'); // 对账批(第 10 批):cursor=null 全量快照 → 按映射行版本比较应用。 // (场景未重设 → receivedCursors 跨两批累计:['11', null] 恰为增量→全量 // 形态切换的完整证据。) const reconcile = await triggerRun(); expect(mock.receivedCursors).toEqual(['11', null]); expect(reconcile.run.state).toBe('success'); expect(reconcile.run.counts).toEqual({ added: 0, updated: 1, disabled: 0, failed: 0, }); expect((await userByAccount('sync-u2'))?.displayName).toBe('王二对账名'); expect(reconcile.run.cursor).toBe('11'); // 红线 2:快照里缺席的用户绝不被停用/删除。 expect(await prisma.users.count()).toBe(usersBefore); expect( await prisma.users.count({ where: { sourceStatus: 'disabled' } }), ).toBe(0); }); it('⑪ V1.1 初始凭据闭环:同步即可登录 → activate 幂等 → reset-password 轮换初始密码失效', async () => { const u2 = await userByAccount('sync-u2'); expect(u2?.credentialStatus).toBe('active'); // 初始密码 = 账号后 6 位:同步完成即可登录(V1.1 取代旧的哨兵待激活闭环)。 const initial = await login('sync-u2', 'sync-u2'.slice(-6)); expect(initial.status).toBe(200); expect(typeof initial.body.sessionToken).toBe('string'); // activate 对 active 用户幂等(200,不重复落审计——admin-users 语义)。 const activate = await request(app.getHttpServer()) .post(`/${API_PREFIX}/admin/users/${u2?.id}/activate`) .set('Authorization', `Bearer ${adminToken}`) .send({}); expect(activate.status).toBe(200); expect(activate.body.credentialStatus).toBe('active'); // 管理员 reset-password 轮换:新凭据可登录,初始密码同步失效。 const reset = await request(app.getHttpServer()) .post(`/${API_PREFIX}/admin/users/${u2?.id}/reset-password`) .set('Authorization', `Bearer ${adminToken}`) .send({}); expect(reset.status).toBe(200); const rotated = reset.body.newPassword as string; expect(rotated.length).toBeGreaterThanOrEqual(12); const ok = await login('sync-u2', rotated); expect(ok.status).toBe(200); const old = await login('sync-u2', 'sync-u2'.slice(-6)); expect(old.status).toBe(401); expect(old.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS); }); it('⑫ status/runs/errors API 形状 + runs 游标分页走全量 + 参数 400 + 权限矩阵', async () => { // 形状:status 全键集 + counts 全键集。 const status = await getStatus(); expect(status.status).toBe(200); expect(Object.keys(status.body).sort()).toEqual([ 'counts', 'lastRunAt', 'lastSuccessAt', 'sourceVersion', 'state', ]); expect(Object.keys(status.body.counts).sort()).toEqual([ 'added', 'disabled', 'failed', 'updated', ]); // 形状:run 条目全键集(cursor 投影为 int:水位数值串 → number)。 const firstPage = await listRuns({ limit: 3 }); expect(firstPage.status).toBe(200); expect(firstPage.body.items.length).toBeGreaterThan(0); expect(Object.keys(firstPage.body.items[0]).sort()).toEqual([ 'counts', 'cursor', 'error', 'finishedAt', 'id', 'startedAt', 'state', 'trigger', ]); expect(typeof firstPage.body.items[0].cursor).toBe('number'); expect(typeof firstPage.body.items[0].startedAt).toBe('string'); // 游标分页走全量:与 DB 总数一致、无重复、startedAt 倒序、元信息成对。 const total = await prisma.user_sync_runs.count(); const seen: string[] = []; let cursor: string | undefined; for (;;) { const page = await listRuns( cursor === undefined ? { limit: '3' } : { limit: '3', cursor }, ); expect(page.status).toBe(200); expect(page.body.hasMore).toBe(page.body.nextCursor !== null); seen.push(...(page.body.items as { id: string; startedAt: string }[]).map((i) => i.id)); const items = page.body.items as { startedAt: string }[]; for (let i = 1; i < items.length; i++) { expect(items[i - 1].startedAt >= items[i].startedAt).toBe(true); } cursor = page.body.nextCursor; if (cursor === null || cursor === undefined) break; } expect(seen).toHaveLength(total); expect(new Set(seen).size).toBe(total); // 参数校验:limit/cursor 非法 400;errors 缺 runId 400、未知 runId 空数组。 expect((await listRuns({ limit: 'abc' })).status).toBe(400); expect((await listRuns({ limit: '0' })).status).toBe(400); expect((await listRuns({ cursor: '%%not-base64%%' })).status).toBe(400); const noRunId = await listErrors({}); expect(noRunId.status).toBe(400); expect(noRunId.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const emptyRunId = await listErrors({ runId: '' }); expect(emptyRunId.status).toBe(400); const unknown = await listErrors({ runId: 'no-such-run' }); expect(unknown.status).toBe(200); expect(unknown.body).toEqual({ items: [] }); // 权限矩阵:staff 全端点 403(POST 不产生批次副作用);匿名 401。 expect((await getStatus(staffToken)).status).toBe(403); const staffStatus = await getStatus(staffToken); expect(staffStatus.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); expect((await listRuns({}, staffToken)).status).toBe(403); expect((await listErrors({ runId: 'x' }, staffToken)).status).toBe(403); const runsBeforePost = await prisma.user_sync_runs.count(); const staffPost = await postRun(staffToken); expect(staffPost.status).toBe(403); expect(staffPost.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); expect(await prisma.user_sync_runs.count()).toBe(runsBeforePost); expect((await getStatus(null)).status).toBe(401); expect((await listRuns({}, null)).status).toBe(401); expect((await listErrors({ runId: 'x' }, null)).status).toBe(401); const anonPost = await request(app.getHttpServer()) .post(`/${API_PREFIX}/admin/user-sync/runs`) .send(undefined); expect(anonPost.status).toBe(401); expect(anonPost.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); it('⑬ profile 展示补偿写:全量批同版本(版本门跳过)+ 快照缺失 → 回填且 counts 全 0', async () => { // 回填只发生在**行被送到引擎**的批次——增量批过滤 v > 水位(11)会滤掉 // 同版本行;生产 http 源每批全量拉取天然送达。此处仿 ⑩ 把下一批顶成第 // 10 批全量对账批(cursor=null → 全场景行不过滤)。 const stateRow = await prisma.user_sync_state.findUniqueOrThrow({ where: { id: USER_SYNC_STATE_SINGLETON_ID }, }); const fillers = (10 - ((stateRow.runCount + 1) % 10)) % 10; scenario([]); for (let i = 0; i < fillers; i++) { await triggerRun(); } // 构造 G4 存量形态:ext-2 映射版本已最新(11)但 profile 为 null // (JSON 列置 NULL 经原生 SQL——Prisma DbNull 需 @prisma/client 运行时 // 导入,api 包非其直接依赖)。 await prisma.$executeRaw`UPDATE external_user_mappings SET profile = NULL WHERE sourceSystem = 'mock' AND externalUserId = 'ext-2'`; // 全量批同版本行 + 快照:版本门跳过(不进 updated 桶)→ 仅回填 profile。 scenario([ { ...U2(11, '王二对账名'), sourceProfile: { sn: 'S-002R', companyAbbr: '摄制' } }, ]); const { run } = await triggerRun(); expect(run.state).toBe('success'); expect(run.counts).toEqual({ added: 0, updated: 0, disabled: 0, failed: 0 }); const backfilled = await prisma.external_user_mappings.findUnique({ where: { sourceSystem_externalUserId: { sourceSystem: 'mock', externalUserId: 'ext-2' }, }, }); expect(backfilled?.profile).toEqual({ sn: 'S-002R', companyAbbr: '摄制' }); // users 行未被牵连:版本未动、displayName 维持 ⑩ 对账值。 expect(backfilled?.sourceVersion).toBe(11); expect((await userByAccount('sync-u2'))?.displayName).toBe('王二对账名'); }); });