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);
|
|||
|
|
});
|
|||
|
|
});
|