452ec12bbd
P2 Task 11: 六幕串行场景套件(cn+ov 双入口进程内起真服务,真实双 MySQL/MinIO/HMAC/WS)——①端到端复制+互动+统计回传(A25 观众路径只打 ov、A27 快照替换)②隐藏传播(high 优先+WS photo.hidden+恢复发布复原) ③发送器停机积压→排空追平→对账稳态④墓碑不复活(旧 published 重放拒绝 +bootstrap 重跑仍隐藏,A26)⑤紧急停用(ov 404/WS error、cn 无感、enable 复原含新照片)⑥50 并发点赞/30 并发浏览幂等小样(终值==Σdelta、恰+15)。 services/sync devDeps 增 ws/@types/ws/ioredis(WS 场景客户端 + 限速 滑窗键清场,同 api spec 约定;版本与 api 对齐)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
736 lines
27 KiB
TypeScript
736 lines
27 KiB
TypeScript
// services/sync/test/cross-region.e2e-spec.ts —— P2 Task 11 跨地域全链路集成
|
||
// 套件。复用 sync.e2e-spec.ts 的进程内双入口 harness(cn=AppCnModule、
|
||
// ov=AppOvModule 真实 HTTP+WS 入口 + 真实双 MySQL + MinIO + HMAC 全链路字节
|
||
// 一致),被测对象覆盖 P2 全部新增组件:OutboxSender、SyncReconciler、
|
||
// StatReturnSender、ov 公开读/点赞/浏览端点、realtime 网关、紧急停用通道。
|
||
//
|
||
// 与 sync/reconcile/stat-return 三套 spec 的分工:那些是**组件级**逐属性验证
|
||
// (幂等锚、版本单调、聚合折叠、digest 语义……);本套件是**场景级**业务闭环
|
||
// ——六幕串行剧本推进同一相册,每幕都是一个运维/产品故事的全链路走查:
|
||
// ① 端到端:cn 建册(zh+en)+分类 → staff API 上传 3 张真实 JPEG → worker
|
||
// 发布 → 发送器 → **ov 公开面**可见(A25 本地直读:观众路径全程只打 ov
|
||
// 入口——本 spec 所有 /public/** 调用一律指向 ovBase,cn 入口仅承载
|
||
// staff/内部动作)→ 两会话点赞 + 两访客浏览 → 统计回传 runReturnOnce →
|
||
// cn 副本 likeCount=Σdelta、viewCountReplica=2、statsSyncedAt 落位(A27);
|
||
// ② 隐藏传播:cn 隐藏 → high 优先投递 → ov 列表即时缩面 + 已订阅 WS 客户端
|
||
// 收到 photo.hidden 帧 → cn 恢复发布(hidden→published)→ ov 列表复原;
|
||
// ③ 中断积压恢复:发送器「停机」(本套件不装常驻循环,投递一律手工
|
||
// drainSender——停机即不调用)→ 连发 5 张 → ov 迟滞不见(积压在
|
||
// outbox)→ 排空 → 5 张齐现 → 对账 runOnce 稳态(无 mismatch/无孤儿);
|
||
// ④ 墓碑不复活(A26):photo3 隐藏后,伪造其**旧 published 事件**(更低
|
||
// entityVersion、全新 eventId)直推 ov content-batches → 版本比较拒绝;
|
||
// 再跑全量 bootstrap(当前态快照重放)→ 仍隐藏;
|
||
// ⑤ 紧急停用:ov 本地 disable → 公开读 404 + WS subscribe error → cn 侧
|
||
// 上传/发布/同步**无感**照常(副本照收)→ enable → 公开面复原(含停用
|
||
// 期间新发布的照片);
|
||
// ⑥ 并发幂等小样:50 并发点赞(25 会话×2 批)终值==Σdelta 分毫不差;
|
||
// 30 并发浏览(15 访客×2 requestId)恰 +15——窗口去重 + 唯一约束在真实
|
||
// 并发事务下的语义验证(P5 才做容量结论)。
|
||
// 限速滑窗键(Redis ov,TTL 24h/30min)跨 spec 残留:beforeAll SCAN 清场
|
||
//(同 api likes-realtime/visits spec 约定),保证 ⑥ 的并发量不被前序 spec
|
||
// 的同 IP 预算挤占。
|
||
import { randomUUID } from 'node:crypto';
|
||
import { NestFactory } from '@nestjs/core';
|
||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||
import Redis from 'ioredis';
|
||
import request from 'supertest';
|
||
import sharp from 'sharp';
|
||
import WebSocket from 'ws';
|
||
import { PrismaClient } from '@photo/db-cn';
|
||
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||
import {
|
||
type ClientMessage,
|
||
ErrorCodes,
|
||
type ServerMessage,
|
||
REALTIME_WS_PATH,
|
||
signSyncBody,
|
||
} from '@photo/contracts';
|
||
import {
|
||
createStorageFromEnv,
|
||
type MinioStorageAdapter,
|
||
} from '@photo/storage';
|
||
import { AppCnModule } from '../../api/src/app-cn.module';
|
||
import { AppOvModule } from '../../api/src/app-ov.module';
|
||
import { seedAdmin } from '../../api/src/scripts/seed-admin';
|
||
import { configureApp } from '../../api/src/shared/configure-app';
|
||
import { ContractWsAdapter } from '../../api/src/realtime/contract-ws.adapter';
|
||
import { PublishService } from '../../worker/src/publish.service';
|
||
import { resetDb } from '../../api/test/reset';
|
||
import { resetOvDb } from '../../api/test/reset-ov';
|
||
import { OutboxSender } from '../src/sender';
|
||
import { SyncReconciler } from '../src/reconciler';
|
||
import { StatReturnSender } from '../src/stat-return';
|
||
|
||
jest.setTimeout(300_000);
|
||
|
||
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
||
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
||
|
||
const CONTENT_BATCHES_PATH = '/v1/internal/sync/content-batches';
|
||
const BOOTSTRAP_PATH = '/v1/internal/sync/bootstrap';
|
||
const EMERGENCY_DISABLE_PATH = '/v1/internal/emergency/disable';
|
||
const EMERGENCY_ENABLE_PATH = '/v1/internal/emergency/enable';
|
||
|
||
/** subscribe 为异步校验(DB 查询):发送后的就绪等待(同 likes-realtime spec)。 */
|
||
const SUBSCRIBE_SETTLE_MS = 300;
|
||
|
||
/** 测试内生成真实 JPEG(同 sync.e2e-spec;无二进制入库)。 */
|
||
async function makeJpeg(
|
||
width: number,
|
||
height: number,
|
||
rgb: { r: number; g: number; b: number },
|
||
): Promise<Buffer> {
|
||
return sharp({ create: { width, height, channels: 3, background: rgb } })
|
||
.jpeg({ quality: 85 })
|
||
.toBuffer();
|
||
}
|
||
|
||
/** HMAC 签名 POST(内部端点:emergency/bootstrap/content-batches)。 */
|
||
async function signedPost(
|
||
base: string,
|
||
path: string,
|
||
body: unknown,
|
||
): Promise<Response> {
|
||
const raw = JSON.stringify(body);
|
||
return fetch(`${base}${path}`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'content-type': 'application/json',
|
||
'x-sync-timestamp': String(Math.floor(Date.now() / 1000)),
|
||
'x-sync-signature': signSyncBody(SYNC_KEY, raw),
|
||
},
|
||
body: raw,
|
||
});
|
||
}
|
||
|
||
/** 契约帧 WS 测试客户端(同 api likes-realtime spec:收帧队列 + 条件等待)。 */
|
||
class WsClient {
|
||
private readonly ws: WebSocket;
|
||
private readonly frames: ServerMessage[] = [];
|
||
private readonly waiters: Array<{
|
||
predicate: (frame: ServerMessage) => boolean;
|
||
resolve: (frame: ServerMessage) => void;
|
||
reject: (err: Error) => void;
|
||
timer: NodeJS.Timeout;
|
||
}> = [];
|
||
|
||
private constructor(ws: WebSocket) {
|
||
this.ws = ws;
|
||
this.ws.on('message', (data) => {
|
||
let frame: ServerMessage;
|
||
try {
|
||
frame = JSON.parse(String(data)) as ServerMessage;
|
||
} catch {
|
||
return;
|
||
}
|
||
this.frames.push(frame);
|
||
const index = this.waiters.findIndex((w) => w.predicate(frame));
|
||
if (index >= 0) {
|
||
const [waiter] = this.waiters.splice(index, 1);
|
||
clearTimeout(waiter.timer);
|
||
waiter.resolve(frame);
|
||
}
|
||
});
|
||
}
|
||
|
||
static async open(url: string): Promise<WsClient> {
|
||
const ws = new WebSocket(url);
|
||
await new Promise<void>((resolve, reject) => {
|
||
ws.once('open', () => resolve());
|
||
ws.once('error', (err: Error) => reject(err));
|
||
});
|
||
return new WsClient(ws);
|
||
}
|
||
|
||
send(frame: ClientMessage): void {
|
||
this.ws.send(JSON.stringify(frame));
|
||
}
|
||
|
||
/** 等待首条满足谓词的帧(含历史帧;超时拒绝)。 */
|
||
waitFor(
|
||
predicate: (frame: ServerMessage) => boolean,
|
||
timeoutMs: number,
|
||
label: string,
|
||
): Promise<ServerMessage> {
|
||
const existing = this.frames.find(predicate);
|
||
if (existing !== undefined) {
|
||
return Promise.resolve(existing);
|
||
}
|
||
return new Promise((resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
const index = this.waiters.findIndex((w) => w.reject === reject);
|
||
if (index >= 0) {
|
||
this.waiters.splice(index, 1);
|
||
}
|
||
reject(
|
||
new Error(
|
||
`waitFor(${label}) 超时 ${timeoutMs}ms;已收帧: ${JSON.stringify(this.frames)}`,
|
||
),
|
||
);
|
||
}, timeoutMs);
|
||
this.waiters.push({ predicate, resolve, reject, timer });
|
||
});
|
||
}
|
||
|
||
close(): void {
|
||
this.ws.close();
|
||
}
|
||
}
|
||
|
||
/** ov 公开列表条目(断言用子集)。 */
|
||
interface PublicPhotoItem {
|
||
photoId: string;
|
||
likeCount: number;
|
||
}
|
||
|
||
describe('cross-region cn↔ov full chain (e2e)', () => {
|
||
let cnApp: NestExpressApplication;
|
||
let ovApp: NestExpressApplication;
|
||
let cnBase: string;
|
||
let ovBase: string;
|
||
let prismaCn: PrismaClient;
|
||
let prismaOv: OvPrismaClient;
|
||
let storage: MinioStorageAdapter;
|
||
let sender: OutboxSender;
|
||
let reconciler: SyncReconciler;
|
||
let statReturn: StatReturnSender;
|
||
let adminToken: string;
|
||
|
||
let albumId: string;
|
||
let publicToken: string;
|
||
let categoryId: string;
|
||
/** 前三张(场景①的 staff 上传面)。 */
|
||
let photo1: string;
|
||
let photo2: string;
|
||
let photo3: string;
|
||
/** 场景③的 5 张积压照片(场景⑥并发点赞选其中第一张作靶)。 */
|
||
let backlogPhotoIds: string[] = [];
|
||
|
||
/** 排空发送器:循环 runOnce 直至无未投递事件(「常驻发送器」的测试替身)。 */
|
||
async function drainSender(): Promise<void> {
|
||
for (let round = 0; round < 20; round++) {
|
||
const stats = await sender.runOnce();
|
||
if (stats.picked === 0) {
|
||
return;
|
||
}
|
||
}
|
||
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
|
||
}
|
||
|
||
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
|
||
async function uploadAndPublish(
|
||
clientTaskId: string,
|
||
jpeg: Buffer,
|
||
): Promise<string> {
|
||
const init = await request(cnBase)
|
||
.post('/v1/uploads')
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({
|
||
albumId,
|
||
clientTaskId,
|
||
filename: 'cross-region.jpg',
|
||
bytes: jpeg.length,
|
||
contentType: 'image/jpeg',
|
||
categoryId,
|
||
});
|
||
expect(init.status).toBe(201);
|
||
const { uploadId, photoId, credential } = init.body as {
|
||
uploadId: string;
|
||
photoId: string;
|
||
credential: { url: string; method: string; headers: Record<string, string> };
|
||
};
|
||
const put = await fetch(credential.url, {
|
||
method: credential.method,
|
||
headers: credential.headers,
|
||
body: new Uint8Array(jpeg),
|
||
});
|
||
expect(put.ok).toBe(true);
|
||
const done = await request(cnBase)
|
||
.post(`/v1/uploads/${uploadId}/complete`)
|
||
.set('Authorization', `Bearer ${adminToken}`);
|
||
expect(done.status).toBe(200);
|
||
expect(await new PublishService(prismaCn, storage).processPhoto(photoId)).toBe(
|
||
'published',
|
||
);
|
||
return photoId;
|
||
}
|
||
|
||
// —— 观众面 helper:全部只打 ov 入口(A25 本地直读语义的落地口径)——
|
||
|
||
async function ovAlbumStatus(): Promise<number> {
|
||
const res = await request(ovBase).get(
|
||
`/v1/public/albums/${publicToken}`,
|
||
);
|
||
return res.status;
|
||
}
|
||
|
||
async function ovPhotoIds(): Promise<string[]> {
|
||
const res = await request(ovBase).get(
|
||
`/v1/public/albums/${publicToken}/photos`,
|
||
);
|
||
expect(res.status).toBe(200);
|
||
return ((res.body as { items: PublicPhotoItem[] }).items).map(
|
||
(item) => item.photoId,
|
||
);
|
||
}
|
||
|
||
function wsUrl(): string {
|
||
return ovBase.replace(/^http/, 'ws') + REALTIME_WS_PATH;
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
expect(SYNC_KEY).not.toBe('');
|
||
await resetDb();
|
||
await resetOvDb();
|
||
// 限速滑窗键清场(同 api likes-realtime/visits spec 约定):⑥ 的 50 点赞
|
||
// +30 浏览并发量需要完整的 IP/会话预算。
|
||
const redis = new Redis(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381');
|
||
for (const pattern of ['like:window:*', 'visit:window:*']) {
|
||
let cursor = '0';
|
||
do {
|
||
const [next, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 1000);
|
||
cursor = next;
|
||
if (keys.length > 0) {
|
||
await redis.del(...keys);
|
||
}
|
||
} while (cursor !== '0');
|
||
}
|
||
await redis.quit();
|
||
|
||
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
||
rawBody: true,
|
||
});
|
||
configureApp(cnApp);
|
||
await cnApp.listen(0);
|
||
cnBase = await cnApp.getUrl();
|
||
|
||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||
rawBody: true,
|
||
});
|
||
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
||
configureApp(ovApp);
|
||
await ovApp.listen(0);
|
||
ovBase = await ovApp.getUrl();
|
||
|
||
prismaCn = new PrismaClient();
|
||
prismaOv = new OvPrismaClient();
|
||
storage = createStorageFromEnv();
|
||
sender = new OutboxSender({
|
||
prisma: prismaCn,
|
||
ovApiUrl: ovBase,
|
||
internalKey: SYNC_KEY,
|
||
});
|
||
reconciler = new SyncReconciler({
|
||
prismaOv,
|
||
cnApiUrl: cnBase,
|
||
ovApiUrl: ovBase,
|
||
internalKey: SYNC_KEY,
|
||
});
|
||
statReturn = new StatReturnSender({
|
||
prismaOv,
|
||
cnApiUrl: cnBase,
|
||
internalKey: SYNC_KEY,
|
||
});
|
||
|
||
await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||
const login = await request(cnBase).post('/v1/auth/password').send({
|
||
account: ADMIN_ACCOUNT,
|
||
password: ADMIN_PASSWORD,
|
||
});
|
||
expect(login.status).toBe(200);
|
||
adminToken = login.body.sessionToken as string;
|
||
});
|
||
|
||
afterAll(async () => {
|
||
await cnApp.close();
|
||
await ovApp.close();
|
||
await prismaCn.$disconnect();
|
||
await prismaOv.$disconnect();
|
||
});
|
||
|
||
it('① 端到端:上传→发布→同步→ov 可见→点赞/浏览→回传→cn 副本一致(A25+A27)', async () => {
|
||
const album = await request(cnBase)
|
||
.post('/v1/albums')
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({
|
||
translations: [
|
||
{ locale: 'zh-CN', title: '跨境晚会', description: '全链路验收' },
|
||
{ locale: 'en', title: 'Cross-Region Gala' },
|
||
],
|
||
defaultLocale: 'zh-CN',
|
||
startAt: '2026-10-01T10:00:00.000Z',
|
||
});
|
||
expect(album.status).toBe(201);
|
||
albumId = album.body.id as string;
|
||
publicToken = album.body.publicToken as string;
|
||
|
||
const category = await request(cnBase)
|
||
.post(`/v1/albums/${albumId}/categories`)
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({
|
||
sortOrder: 0,
|
||
translations: [
|
||
{ locale: 'zh-CN', name: '主舞台' },
|
||
{ locale: 'en', name: 'Main Stage' },
|
||
],
|
||
});
|
||
expect(category.status).toBe(201);
|
||
categoryId = category.body.id as string;
|
||
|
||
photo1 = await uploadAndPublish(
|
||
'cross-1',
|
||
await makeJpeg(900, 600, { r: 190, g: 90, b: 90 }),
|
||
);
|
||
photo2 = await uploadAndPublish(
|
||
'cross-2',
|
||
await makeJpeg(880, 600, { r: 90, g: 190, b: 90 }),
|
||
);
|
||
photo3 = await uploadAndPublish(
|
||
'cross-3',
|
||
await makeJpeg(860, 600, { r: 90, g: 90, b: 190 }),
|
||
);
|
||
await drainSender();
|
||
|
||
// 观众面只打 ov(A25):相册公开信息 + 列表 3 张。
|
||
expect(await ovAlbumStatus()).toBe(200);
|
||
expect(await ovPhotoIds()).toHaveLength(3);
|
||
|
||
// 两会话点赞 photo1(N=7):delta 3 + 4。
|
||
const likeA = await request(ovBase)
|
||
.post(`/v1/public/albums/${publicToken}/photos/${photo1}/likes`)
|
||
.send({ sessionId: 'cross-session-a', batchId: randomUUID(), delta: 3 });
|
||
expect(likeA.status).toBe(200);
|
||
expect(likeA.body).toMatchObject({ total: 3, batchAccepted: true });
|
||
const likeB = await request(ovBase)
|
||
.post(`/v1/public/albums/${publicToken}/photos/${photo1}/likes`)
|
||
.send({ sessionId: 'cross-session-b', batchId: randomUUID(), delta: 4 });
|
||
expect(likeB.status).toBe(200);
|
||
expect(likeB.body).toMatchObject({ total: 7, batchAccepted: true });
|
||
|
||
// 两访客浏览 → viewCount=2(各自 counted)。
|
||
for (const visitorId of ['cross-visitor-1', 'cross-visitor-2']) {
|
||
const res = await request(ovBase)
|
||
.post(`/v1/public/albums/${publicToken}/visits`)
|
||
.send({ visitorId, requestId: `cross-req-${visitorId}` });
|
||
expect(res.status).toBe(200);
|
||
expect(res.body).toMatchObject({ counted: true });
|
||
}
|
||
|
||
// 统计回传一轮:2 likes 行 + 2 views 行(每个 counted 访客各一行)→
|
||
// 折叠 2 快照(likes 按 photo 折叠、views 按 album 折叠)全受理(A27)。
|
||
const ret = await statReturn.runReturnOnce();
|
||
expect(ret).toEqual({ picked: 4, snapshots: 2, accepted: 2 });
|
||
|
||
// cn 副本一致:likeCount=Σdelta、viewCountReplica=2、statsSyncedAt 落位。
|
||
const cnPhoto = await prismaCn.photos.findUniqueOrThrow({
|
||
where: { id: photo1 },
|
||
select: { likeCount: true },
|
||
});
|
||
expect(cnPhoto.likeCount).toBe(7);
|
||
const cnAlbum = await prismaCn.albums.findUniqueOrThrow({
|
||
where: { id: albumId },
|
||
select: { viewCountReplica: true, statsSyncedAt: true },
|
||
});
|
||
expect(cnAlbum.viewCountReplica).toBe(2);
|
||
expect(cnAlbum.statsSyncedAt).not.toBeNull();
|
||
|
||
// ov 列表 likeCount 即本地副本值(观众路径无跨境读,A25)。
|
||
const items = await request(ovBase)
|
||
.get(`/v1/public/albums/${publicToken}/photos`);
|
||
const mine = (
|
||
(items.body as { items: PublicPhotoItem[] }).items
|
||
).find((item) => item.photoId === photo1);
|
||
expect(mine?.likeCount).toBe(7);
|
||
});
|
||
|
||
it('② 隐藏传播:cn 隐藏 → ov 列表缩面 + WS photo.hidden → 恢复发布 → 复原', async () => {
|
||
const client = await WsClient.open(wsUrl());
|
||
client.send({ type: 'subscribe', albumToken: publicToken });
|
||
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
|
||
|
||
const hide = await request(cnBase)
|
||
.patch(`/v1/photos/${photo2}`)
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({ visibility: 'hidden' });
|
||
expect(hide.status).toBe(200);
|
||
await drainSender();
|
||
|
||
// ov 公开面即时缩面(high 优先事件已投递)。
|
||
const visible = await ovPhotoIds();
|
||
expect(visible).toHaveLength(2);
|
||
expect(visible).not.toContain(photo2);
|
||
|
||
// 已订阅客户端收到 photo.hidden 帧(realtime 网关经 OV_PHOTO_APPLIED 转发)。
|
||
const frame = await client.waitFor(
|
||
(f) => f.type === 'photo.hidden' && f.photoId === photo2,
|
||
10_000,
|
||
'photo.hidden',
|
||
);
|
||
expect(frame).toMatchObject({
|
||
type: 'photo.hidden',
|
||
photoId: photo2,
|
||
albumId,
|
||
entityVersion: 3,
|
||
});
|
||
|
||
// cn 恢复发布(hidden→published,owner/admin 可操作)→ ov 复原。
|
||
const restore = await request(cnBase)
|
||
.patch(`/v1/photos/${photo2}`)
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({ visibility: 'published' });
|
||
expect(restore.status).toBe(200);
|
||
expect(restore.body).toMatchObject({ visibility: 'published' });
|
||
await drainSender();
|
||
expect(await ovPhotoIds()).toHaveLength(3);
|
||
client.close();
|
||
});
|
||
|
||
it('③ 中断积压恢复:发送器停机 → 5 张积压 → 排空追平 → 对账稳态', async () => {
|
||
// 「停机」:本套件发送器无常驻循环(不调 drainSender 即停机)——连发 5 张。
|
||
const backlog: string[] = [];
|
||
for (let i = 0; i < 5; i += 1) {
|
||
backlog.push(
|
||
await uploadAndPublish(
|
||
`cross-backlog-${i}`,
|
||
await makeJpeg(800 + i * 10, 600, {
|
||
r: 40 + i * 20,
|
||
g: 120,
|
||
b: 160,
|
||
}),
|
||
),
|
||
);
|
||
}
|
||
backlogPhotoIds = backlog;
|
||
|
||
// 积压可见性:ov 迟滞不见(无任何投递),outbox 5 条未投递。
|
||
expect(await ovPhotoIds()).toHaveLength(3);
|
||
expect(
|
||
await prismaCn.outbox_events.count({ where: { deliveredAt: null } }),
|
||
).toBeGreaterThanOrEqual(5);
|
||
|
||
// 恢复:排空发送器 → 5 张齐现。
|
||
await drainSender();
|
||
const visible = await ovPhotoIds();
|
||
expect(visible).toHaveLength(8);
|
||
for (const photoId of backlog) {
|
||
expect(visible).toContain(photoId);
|
||
}
|
||
|
||
// 对账稳态:digest 一致(无 bootstrap 需求、无 ov 孤儿相册)。
|
||
const steady = await reconciler.runOnce();
|
||
expect(steady.mismatchedAlbums).toEqual([]);
|
||
expect(steady.ovOrphanAlbums).toEqual([]);
|
||
});
|
||
|
||
it('④ 墓碑不复活(A26):旧 published 重放被版本比较拒绝;bootstrap 仍隐藏', async () => {
|
||
// photo3 隐藏(v2 → v3,high)→ 投递 → ov 副本隐藏。
|
||
const hide = await request(cnBase)
|
||
.patch(`/v1/photos/${photo3}`)
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({ visibility: 'hidden' });
|
||
expect(hide.status).toBe(200);
|
||
await drainSender();
|
||
expect(
|
||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||
where: { photoId: photo3 },
|
||
select: { visibility: true, entityVersion: true },
|
||
}),
|
||
).toMatchObject({ visibility: 'hidden', entityVersion: 3 });
|
||
|
||
// 伪造重放:photo3 的旧 published 事件(v2 载荷、全新 eventId)直推 ov。
|
||
const publishedRow = await prismaCn.outbox_events.findFirstOrThrow({
|
||
where: { type: 'photo.published', photoId: photo3 },
|
||
});
|
||
expect(publishedRow.entityVersion).toBe(2);
|
||
const replayId = `cross-replay-${randomUUID()}`;
|
||
const replay = await signedPost(ovBase, CONTENT_BATCHES_PATH, {
|
||
batch: [
|
||
{
|
||
eventId: replayId,
|
||
schemaVersion: 1,
|
||
entityType: 'photo',
|
||
entityId: photo3,
|
||
albumId,
|
||
entityVersion: publishedRow.entityVersion,
|
||
tombstone: false,
|
||
priority: 'normal',
|
||
payload: publishedRow.payload as Record<string, unknown>,
|
||
},
|
||
],
|
||
});
|
||
expect(replay.status).toBe(200);
|
||
expect(await replay.json()).toEqual({
|
||
acceptedEventIds: [replayId],
|
||
applied: 0,
|
||
});
|
||
expect(
|
||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||
where: { photoId: photo3 },
|
||
select: { visibility: true },
|
||
}),
|
||
).toMatchObject({ visibility: 'hidden' });
|
||
|
||
// 全量 bootstrap 重跑(当前态快照)→ 排空 → photo3 仍隐藏。
|
||
const boot = await signedPost(cnBase, BOOTSTRAP_PATH, { albumId });
|
||
expect(boot.status).toBe(200);
|
||
// 当前态:album v1 + category v1 + photo1 v2 + photo2 v4(隐藏后恢复)+
|
||
// photo3 v3(hidden)+ 5 张积压各 v2 = 10 事件;watermark = 最大版本 4。
|
||
expect(await boot.json()).toEqual({ eventCount: 10, watermark: 4 });
|
||
await drainSender();
|
||
expect(
|
||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||
where: { photoId: photo3 },
|
||
select: { visibility: true, entityVersion: true },
|
||
}),
|
||
).toMatchObject({ visibility: 'hidden', entityVersion: 3 });
|
||
});
|
||
|
||
it('⑤ 紧急停用:ov 屏蔽(404 + WS error)→ cn 无感 → enable 复原含新照片', async () => {
|
||
const client = await WsClient.open(wsUrl());
|
||
|
||
const disable = await signedPost(ovBase, EMERGENCY_DISABLE_PATH, {
|
||
albumId,
|
||
reason: 'cross-region e2e: 紧急停用演练',
|
||
});
|
||
expect(disable.status).toBe(200);
|
||
expect(await disable.json()).toEqual({ disabled: true, albumId });
|
||
|
||
// 观众面全屏蔽:公开读 404(统一 ALBUM_NOT_PUBLIC 防枚举语义)。
|
||
expect(await ovAlbumStatus()).toBe(404);
|
||
const list = await request(ovBase).get(
|
||
`/v1/public/albums/${publicToken}/photos`,
|
||
);
|
||
expect(list.status).toBe(404);
|
||
expect((list.body as { code: string }).code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||
|
||
// WS subscribe 同一可见性口径 → error 帧(连接保持)。
|
||
client.send({ type: 'subscribe', albumToken: publicToken });
|
||
const error = await client.waitFor(
|
||
(f) => f.type === 'error',
|
||
10_000,
|
||
'subscribe error',
|
||
);
|
||
expect(error).toMatchObject({
|
||
type: 'error',
|
||
code: ErrorCodes.ALBUM_NOT_PUBLIC,
|
||
});
|
||
|
||
// cn 无感:上传/发布/同步照常(副本照收,仅公开面被 ov 本地位拦下)。
|
||
const photo9 = await uploadAndPublish(
|
||
'cross-emergency',
|
||
await makeJpeg(840, 600, { r: 200, g: 150, b: 50 }),
|
||
);
|
||
await drainSender();
|
||
expect(
|
||
await prismaOv.photos_ov.findUniqueOrThrow({
|
||
where: { photoId: photo9 },
|
||
select: { visibility: true },
|
||
}),
|
||
).toMatchObject({ visibility: 'published' });
|
||
// 停用期间公开面仍屏蔽。
|
||
expect(await ovAlbumStatus()).toBe(404);
|
||
|
||
// enable:唯一恢复通道 → 公开面复原,含停用期间新发布的照片(photo3 仍
|
||
// 处于场景④的隐藏态缺席——8 = 2 原始 + 5 积压 + photo9)。
|
||
const enable = await signedPost(ovBase, EMERGENCY_ENABLE_PATH, { albumId });
|
||
expect(enable.status).toBe(200);
|
||
expect(await enable.json()).toEqual({ disabled: false, albumId });
|
||
expect(await ovAlbumStatus()).toBe(200);
|
||
const visible = await ovPhotoIds();
|
||
expect(visible).toHaveLength(8);
|
||
expect(visible).toContain(photo9);
|
||
client.close();
|
||
});
|
||
|
||
it('⑥ 并发幂等小样:50 并发点赞终值==Σdelta;30 并发浏览恰 +15', async () => {
|
||
// 50 并发点赞(25 会话 × 2 批,delta 1..3 确定性轮转)打在积压面第一张
|
||
//(③ 落档的 backlog[0]:初始 likeTotal=0,Σdelta 即终值)。
|
||
const targetPhotoId = backlogPhotoIds[0];
|
||
const likePlan: Array<{ sessionId: string; batchId: string; delta: number }> =
|
||
[];
|
||
let expectedTotal = 0;
|
||
for (let i = 0; i < 50; i += 1) {
|
||
const delta = 1 + (i % 3);
|
||
expectedTotal += delta;
|
||
likePlan.push({
|
||
sessionId: `cross-conc-sess-${Math.floor(i / 2)}`,
|
||
batchId: randomUUID(),
|
||
delta,
|
||
});
|
||
}
|
||
|
||
const likeResponses = await Promise.all(
|
||
likePlan.map((batch) =>
|
||
fetch(
|
||
`${ovBase}/v1/public/albums/${publicToken}/photos/${targetPhotoId}/likes`,
|
||
{
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(batch),
|
||
},
|
||
),
|
||
),
|
||
);
|
||
for (const res of likeResponses) {
|
||
expect(res.status).toBe(200);
|
||
expect(((await res.json()) as { batchAccepted: boolean }).batchAccepted)
|
||
.toBe(true);
|
||
}
|
||
|
||
// ov 权威终值 == Σdelta 分毫不差(行锁串行化 + 幂等批次,无丢失/双计)。
|
||
const ovRow = await prismaOv.photos_ov.findUniqueOrThrow({
|
||
where: { photoId: targetPhotoId },
|
||
select: { likeTotal: true },
|
||
});
|
||
expect(ovRow.likeTotal).toBe(expectedTotal);
|
||
|
||
// 回传后 cn 副本 == 同一权威值(快照替换,非增量累加)。
|
||
await statReturn.runReturnOnce();
|
||
const cnRow = await prismaCn.photos.findUniqueOrThrow({
|
||
where: { id: targetPhotoId },
|
||
select: { likeCount: true },
|
||
});
|
||
expect(cnRow.likeCount).toBe(expectedTotal);
|
||
|
||
// 30 并发浏览(15 访客 × 2 requestId):每访客恰计 1 → viewCount 2+15=17。
|
||
const visitPlan: Array<{ visitorId: string; requestId: string }> = [];
|
||
for (let i = 0; i < 15; i += 1) {
|
||
visitPlan.push(
|
||
{ visitorId: `cross-conc-visitor-${i}`, requestId: randomUUID() },
|
||
{ visitorId: `cross-conc-visitor-${i}`, requestId: randomUUID() },
|
||
);
|
||
}
|
||
const visitResponses = await Promise.all(
|
||
visitPlan.map((report) =>
|
||
fetch(`${ovBase}/v1/public/albums/${publicToken}/visits`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(report),
|
||
}),
|
||
),
|
||
);
|
||
const counted = await Promise.all(
|
||
visitResponses.map(async (res) => {
|
||
expect(res.status).toBe(200);
|
||
return ((await res.json()) as { counted: boolean }).counted;
|
||
}),
|
||
);
|
||
// 恰 15 个 counted=true(每访客一个;并发同访客竞态由 session 唯一约束仲裁)。
|
||
expect(counted.filter(Boolean)).toHaveLength(15);
|
||
|
||
const stats = await prismaOv.album_stats.findUniqueOrThrow({
|
||
where: { albumId },
|
||
select: { viewCount: true },
|
||
});
|
||
expect(stats.viewCount).toBe(17);
|
||
|
||
await statReturn.runReturnOnce();
|
||
const cnAlbum = await prismaCn.albums.findUniqueOrThrow({
|
||
where: { id: albumId },
|
||
select: { viewCountReplica: true },
|
||
});
|
||
expect(cnAlbum.viewCountReplica).toBe(17);
|
||
});
|
||
});
|