Files
photo-live/services/sync/test/cross-region.e2e-spec.ts
T
浅念 452ec12bbd test(sync): cross-region simulation integration suite
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>
2026-09-18 20:05:25 +08:00

736 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// services/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);
});
});