2026-09-20 20:01:17 +08:00
|
|
|
|
// 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 关联(本地直建账号建映射,不动其
|
2026-09-22 16:22:38 +08:00
|
|
|
|
// 凭据/角色,会话保留)+ 2 新增(V1.1:credentialStatus=active +
|
|
|
|
|
|
// 初始密码=账号后 6 位,可立即登录);counts/水位/映射行;
|
2026-09-20 20:01:17 +08:00
|
|
|
|
// ② 改名幂等:版本更高 → 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 全量对账应用;缺席用户绝不停用;
|
2026-09-22 16:22:38 +08:00
|
|
|
|
// ⑪ V1.1 初始凭据闭环:同步即可用初始密码登录 → activate 幂等 →
|
|
|
|
|
|
// reset-password 轮换后初始密码失效(Task 6 端点联动);
|
2026-09-20 20:01:17 +08:00
|
|
|
|
// ⑫ 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);
|
|
|
|
|
|
|
2026-09-22 16:22:38 +08:00
|
|
|
|
it('① 首轮全量:1 account 关联 + 2 新增(V1.1 直接 active);counts/水位/映射/红线(不动凭据角色会话)', async () => {
|
2026-09-22 18:06:12 +08:00
|
|
|
|
// 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
|
2026-09-20 20:01:17 +08:00
|
|
|
|
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();
|
|
|
|
|
|
|
2026-09-22 16:22:38 +08:00
|
|
|
|
// 新增(V1.1 初始凭据):直接 active + 初始密码=账号后 6 位(非哨兵哈希)。
|
2026-09-20 20:01:17 +08:00
|
|
|
|
for (const account of ['sync-u2', 'sync-u3']) {
|
|
|
|
|
|
const user = await userByAccount(account);
|
|
|
|
|
|
expect(user).not.toBeNull();
|
2026-09-22 16:22:38 +08:00
|
|
|
|
expect(user?.credentialStatus).toBe('active');
|
2026-09-20 20:01:17 +08:00
|
|
|
|
expect(user?.role).toBe('staff');
|
|
|
|
|
|
expect(user?.sourceStatus).toBe('active');
|
2026-09-22 16:22:38 +08:00
|
|
|
|
expect(user?.passwordHash.startsWith('!:pending:')).toBe(false);
|
2026-09-20 20:01:17 +08:00
|
|
|
|
}
|
2026-09-22 16:22:38 +08:00
|
|
|
|
// V1.1:同步完成即可用初始密码登录。
|
|
|
|
|
|
const initialLogin = await login('sync-u2', 'sync-u2'.slice(-6));
|
|
|
|
|
|
expect(initialLogin.status).toBe(200);
|
2026-09-20 20:01:17 +08:00
|
|
|
|
|
|
|
|
|
|
// 关联:本地直建账号建映射 + 应用源 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);
|
|
|
|
|
|
}
|
2026-09-22 18:06:12 +08:00
|
|
|
|
// 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: '摄制' });
|
2026-09-20 20:01:17 +08:00
|
|
|
|
|
|
|
|
|
|
// 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 () => {
|
2026-09-22 18:06:12 +08:00
|
|
|
|
scenario([
|
|
|
|
|
|
U1(1),
|
|
|
|
|
|
{ ...U2(2, '王二改'), sourceProfile: { sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制二部' } },
|
|
|
|
|
|
U3(1),
|
|
|
|
|
|
]);
|
2026-09-20 20:01:17 +08:00
|
|
|
|
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);
|
2026-09-22 18:06:12 +08:00
|
|
|
|
// 快照随版本推进刷新(companyAbbr 变化生效)。
|
|
|
|
|
|
expect(mapping?.profile).toEqual({ sn: 'S-002', email: 'u2@x.com', companyAbbr: '摄制二部' });
|
2026-09-20 20:01:17 +08:00
|
|
|
|
|
|
|
|
|
|
// 同版本重跑:幂等跳过,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();
|
2026-09-22 16:22:38 +08:00
|
|
|
|
expect(u4?.credentialStatus).toBe('active');
|
2026-09-20 20:01:17 +08:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-09-22 16:22:38 +08:00
|
|
|
|
it('⑪ V1.1 初始凭据闭环:同步即可登录 → activate 幂等 → reset-password 轮换初始密码失效', async () => {
|
2026-09-20 20:01:17 +08:00
|
|
|
|
const u2 = await userByAccount('sync-u2');
|
2026-09-22 16:22:38 +08:00
|
|
|
|
expect(u2?.credentialStatus).toBe('active');
|
2026-09-20 20:01:17 +08:00
|
|
|
|
|
2026-09-22 16:22:38 +08:00
|
|
|
|
// 初始密码 = 账号后 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');
|
2026-09-20 20:01:17 +08:00
|
|
|
|
|
2026-09-22 16:22:38 +08:00
|
|
|
|
// activate 对 active 用户幂等(200,不重复落审计——admin-users 语义)。
|
2026-09-20 20:01:17 +08:00
|
|
|
|
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');
|
|
|
|
|
|
|
2026-09-22 16:22:38 +08:00
|
|
|
|
// 管理员 reset-password 轮换:新凭据可登录,初始密码同步失效。
|
2026-09-20 20:01:17 +08:00
|
|
|
|
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);
|
2026-09-22 16:22:38 +08:00
|
|
|
|
const rotated = reset.body.newPassword as string;
|
|
|
|
|
|
expect(rotated.length).toBeGreaterThanOrEqual(12);
|
|
|
|
|
|
const ok = await login('sync-u2', rotated);
|
2026-09-20 20:01:17 +08:00
|
|
|
|
expect(ok.status).toBe(200);
|
2026-09-22 16:22:38 +08:00
|
|
|
|
const old = await login('sync-u2', 'sync-u2'.slice(-6));
|
|
|
|
|
|
expect(old.status).toBe(401);
|
|
|
|
|
|
expect(old.body.code).toBe(ErrorCodes.AUTH_INVALID_CREDENTIALS);
|
2026-09-20 20:01:17 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
});
|
2026-09-22 18:06:12 +08:00
|
|
|
|
|
|
|
|
|
|
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('王二对账名');
|
|
|
|
|
|
});
|
2026-09-20 20:01:17 +08:00
|
|
|
|
});
|