Files
photo-live/services/sync/test/cross-region.e2e-spec.ts
T

736 lines
27 KiB
TypeScript
Raw Normal View History

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