3681999667
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>
727 lines
30 KiB
TypeScript
727 lines
30 KiB
TypeScript
// 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);
|
||
});
|
||
});
|