Files
photo-live/services/api/test/user-sync.e2e-spec.ts
T
浅念 3681999667 feat(api): scheduled user sync with mock source (T30 semantics)
P4a Task 7 —— cn 侧用户定期同步(mock 源先行,G4 真实接口到位后仅替换
UserSourcePort provider,引擎/契约/表不变)。

数据模型(db-cn 迁移 20260920114149,utf8mb4_0900_bin 逐表校对):
- user_sync_runs:批次账本(trigger cron|manual、state running|success|
  partial|failed、cursor 水位、counts JSON、起止时间与错误摘要);
- user_sync_errors:单批次坏行明细(runId 索引);
- external_user_mappings:本地↔外部 ID 映射(@@unique([sourceSystem,
  externalUserId]),sourceVersion 逐行高水位);
- user_sync_state:单行状态(lastRunAt/lastSuccessAt/cursor/runCount)。

同步引擎(UserSyncEngine):
- Redis SETNX 分布式互斥(TTL 10min,锁值=runId,Lua CAS 释放);
  定时 tick 与手动触发共用,运行中批次复用 runId(202);
- 游标增量 + 周期全量对账(RECONCILE_EVERY_N_RUNS 默认 10);
- 逐页单事务提交(run counts+cursor / state cursor),分页中断→failed
  且水位不前进、会话不动;
- 红线遵守:sync 不写 password_hash/role/album_members/localLocked;
  空/部分页不误伤停用;上游恢复不解除本地锁定;
- 新用户按 pending 建(随机不可用哨兵哈希,激活+重置后方可登录);
- 停用即撤销该用户全部会话;本地预建同名账号走关联而非重建。

Admin API(/admin/user-sync):GET status|runs|errors?runId=、POST runs
(202 {runId});runs 为 startedAt+ID 倒序游标分页。

auth.service:登录对哨兵/损坏哈希加固(等代价 dummy verify,
401 而非 500,时序平坦)。

测试:user-sync.e2e-spec 12 用例(首轮全量/改名幂等/停用撤会话/
恢复不解锁/新增/空页/分页中断/部分无效/手动互斥/增量+对账/API 形状
与权限/pending 登录门);api e2e 22 套件 239 用例全绿。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 20:01:17 +08:00

727 lines
30 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/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 新增待激活(credentialStatus=pending +
// 哨兵哈希 !:pending:);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 全量对账应用;缺席用户绝不停用;
// ⑪ §10 闭环(Task 6 联动):同步创建的 pending 用户登录 401(哨兵哈希
// 不 500)→ activate → 仍 401(哈希不可猜)→ reset-password → 可登录;
// ⑫ 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 新增待激活;counts/水位/映射/红线(不动凭据角色会话)', async () => {
scenario([U1(1), U2(1), 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();
// 新增:待激活 + 哨兵哈希 + staff 角色 + 源状态。
for (const account of ['sync-u2', 'sync-u3']) {
const user = await userByAccount(account);
expect(user).not.toBeNull();
expect(user?.credentialStatus).toBe('pending');
expect(user?.role).toBe('staff');
expect(user?.sourceStatus).toBe('active');
expect(user?.passwordHash.startsWith('!:pending:')).toBe(true);
}
// 关联:本地直建账号建映射 + 应用源 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);
}
// 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, '王二改'), 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);
// 同版本重跑:幂等跳过,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('pending');
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('⑪ §10 闭环:pending 登录 401(哨兵哈希不 500)→ activate → 仍 401 → reset-password → 可登录', async () => {
const u2 = await userByAccount('sync-u2');
expect(u2?.credentialStatus).toBe('pending');
// 待激活 + 哨兵哈希:任何口令 401(而非 argon2 解析 500),文案同密码错误。
const guess = await login('sync-u2', 'Guessing-123');
expect(guess.status).toBe(401);
expect(guess.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
// 激活(credentialStatus 维度):哈希仍为哨兵 → 依旧 401。
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');
const still = await login('sync-u2', 'Guessing-123');
expect(still.status).toBe(401);
expect(still.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
// 初始凭据唯一出口 = 管理员 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 initial = reset.body.newPassword as string;
expect(initial.length).toBeGreaterThanOrEqual(12);
const ok = await login('sync-u2', initial);
expect(ok.status).toBe(200);
expect(typeof ok.body.sessionToken).toBe('string');
});
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);
});
});