Files
photo-live/services/api/test/user-sync.e2e-spec.ts
T

785 lines
32 KiB
TypeScript
Raw Normal View History

// 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<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;
};
// ① 的关联目标:本地直建账号(真实密码 + 既有会话——同步只许建映射和
// 应用 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<MockScenario> = {}): void {
mock.setScenario({ users, ...extra });
}
async function waitRunDone(runId: string): Promise<RunRow> {
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<string, string | number> = {},
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<string, string>,
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<void>((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('王二对账名');
});
});