Files
photo-live/services/api/test/likes-realtime.e2e-spec.ts
T
浅念 85b2da3db5 feat(ov): idempotent batched likes and realtime broadcast
P2 Task 7(点赞批次端点 + ov 实时网关):
- interactions 模块(仅 ov 装配):POST /public/albums/{token}/photos/
  {photoId}/likes——相册不可见/照片未发布 404、delta 1..20 与
  sessionId/batchId 1..64 校验 400;单事务 like_batches 唯一插入
  (P2002=幂等重试,batchAccepted=true 不重加)→ photos_ov.likeTotal/
  countVersion 原子自增回读 → outbox_stat_events(kind=likes) 兜底;
  Redis ov(REDIS_URL_OV)INCRBY+TTL 会话/IP 双窗口限流(默认
  500/2000,超限 429 RATE_LIMITED),幂等重试先于限流不耗额度
- 过期批次重放边界(24h 清理后的 batchId 重插会再次计入):接受该
  边界——无永久账本可查(保留全量批次行违背清理策略),会话/IP 窗口
  上限即重放天然封顶(同一 sessionId 重放挤占自身额度),偏差已在
  likes.service 头注释登记,Task 8 统计侧按清理后语义处理
- realtime:ContractWsAdapter(契约 {type,...} 帧分发,ws 与 HTTP 同
  端口挂 /v1/realtime)+ RealtimeGateway 相册房间模型——subscribe 逐
  相册校验 isAlbumVisibleOv,失败回 error 帧且连接保持;应用层 ping→
  pong 回显 ts;传输层 HEARTBEAT_MS 周期 ping 踢死连接;点赞提交经
  EventEmitter2(单进程,多实例 Redis pub/sub 登记 P5)触发
  likes.burst 500ms 合并窗(delta 聚合、originBatchIds 供客户端过滤
  自身批次);sync-inbox 应用事务提交后发 OV_PHOTO_APPLIED →
  photo.published/hidden/deleted 按房间广播(stale/duplicate/暂存/
  缺席即撤下不转发)
- 契约:REALTIME_WS_PATH、ServerMessage 增 error 帧(穷尽锁 7 成员
  + 类型测试)、LIKE_SESSION_WINDOW_DELTA/LIKE_IP_WINDOW_DELTA/
  LIKE_BURST_MERGE_MS 常量与 .env.example
- e2e(likes-realtime.e2e-spec.ts,6 用例):A09 三批累加/幂等重试/
  outbox 行、小额度 429、ping/pong+幽灵 token error 帧连接保持、
  A10 双房间隔离(先证非空调订)、合并窗单 burst(delta 求和+双
  originBatchIds)、cn 隐藏→房间收 photo.hidden+隐藏后点赞 404;
  凡进程内起 AppOvModule 的 spec(sync×2/public-ov/emergency)补装
  ContractWsAdapter 与生产 main-ov 一致——api e2e 168(162+6)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-18 17:51:55 +08:00

654 lines
23 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/api/test/likes-realtime.e2e-spec.ts —— P2 Task 7 点赞与实时 e2e。
// 与 public-ov.e2e-spec 同模式:进程内起 cn + ov 两个真实入口,业务动作全走
// cn API(建相册/上传 complete/直驱发布事务),OutboxSender(生产实现)指向
// ov 入口真实 URL——点赞与 WS 广播作用在与生产链路同源的数据上。ov 入口按
// main-ov 同形装配 ContractWsAdapter(WS 与 HTTP 同端口 /v1/realtime)。
//
// 覆盖(任务验收 A09/A10/A21 部分):
// ① A09 点赞批次:连点 3 批累计 1+2+3(total/countVersion 单调)、同 batchId
// 重试幂等不加计(batchAccepted=true、like_batches 行数不增)、outbox_
// stat_events 逐批落权威快照;delta 0/21、sessionId/batchId 65 字符 →
// 400;幽灵 token → 404 ALBUM_NOT_PUBLIC;幽灵照片 → 404 PHOTO_NOT_PUBLIC;
// ② 基础限速:LIKE_SESSION_WINDOW_DELTA 压小(惰性 env)→ 窗口超额 429
// RATE_LIMITED(代码语义验证;窗口本身 24h,不做时间旅行);
// ③ WS 协议:ping→pong 回显 ts;幽灵 token subscribe → error 帧
// (ALBUM_NOT_PUBLIC)且连接保持(后续 ping 仍应答);
// ④ A10 房间隔离:两相册两客户端各自订阅(先以一次本册点赞证明双方订阅
// 均生效——非空转通过),跨册点赞只达本册房间,另一房间零消息;
// ⑤ A21 合并广播:合并窗内两批同照片 → 恰一条 likes.burst(delta 求和、
// originBatchIds 双元素、total 权威、burstId 存在),窗外无第二条;
// ⑥ photo.hidden 实时:cn 隐藏 → 高优先级同步 → ov 应用后相册房间收到
// photo.hidden(entityVersion 与副本行一致);隐藏后点赞 → 404。
//
// —— 登记偏差(过期批次重放,不可 e2e)——
// 24h 窗口外重放已清理 batchId 的「再次加计」边界在 likes.service.ts 头注
// 分析(无永久幂等账本下的理论边界,由 sessionId/IP 滑窗上限钳制);窗口
// 时间不可旅行,e2e 无法覆盖,随 Task 8 清理调度落地时以单元语义评审。
//
// —— 不在本 spec 的语义(对应实现位)——
// · 传输层心跳踢除(HEARTBEAT_MS=20s 周期 ping/terminate):真实 20s 等待
// 不值 e2e 时长,realtime.gateway.afterInit 直读契约常量(类型锁定见
// contracts __tests__/realtime.test.ts);P5 压测域验证。
// · stats.updated:Task 8 浏览计数接线后由该任务测试。
import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import request from 'supertest';
import sharp from 'sharp';
import WebSocket from 'ws';
import Redis from 'ioredis';
import { PrismaClient } from '@photo/db-cn';
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
import {
type ClientMessage,
type ServerMessage,
ErrorCodes,
REALTIME_WS_PATH,
} from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
} from '@photo/storage';
import { AppCnModule } from '../src/app-cn.module';
import { AppOvModule } from '../src/app-ov.module';
import { seedAdmin } from '../src/scripts/seed-admin';
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
import { configureApp } from '../src/shared/configure-app';
import { PublishService } from '../../worker/src/publish.service';
import { resetDb } from './reset';
import { resetOvDb } from './reset-ov';
import { OutboxSender } from '../../sync/src/sender';
jest.setTimeout(180_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';
/** 合并窗加大到 1s:两批 HTTP 提交的完成时间远小于窗(防慢机抖动)。 */
const BURST_MERGE_TEST_MS = 1000;
/** subscribe 为异步校验(DB 查询):发送后的就绪等待(本地查询 ~ms 级)。 */
const SUBSCRIBE_SETTLE_MS = 300;
/** 测试内生成真实 JPEG(同 public-ov.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();
}
/** 点赞 POST(返回 supertest Response,断言交给调用方)。 */
async function like(
ovBase: string,
token: string,
photoId: string,
body: { sessionId: string; batchId: string; delta: number },
): Promise<request.Response> {
return request(ovBase)
.post(`/v1/public/albums/${token}/photos/${photoId}/likes`)
.send(body);
}
/** 契约帧 WS 测试客户端:收帧队列 + 条件等待。 */
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 });
});
}
/** 已收帧快照(隔离断言用:跨房间不应新增)。 */
received(): readonly ServerMessage[] {
return this.frames;
}
/** 按类型统计(合并广播「恰一条」断言用)。 */
countOf(type: ServerMessage['type']): number {
return this.frames.filter((f) => f.type === type).length;
}
close(): void {
this.ws.close();
}
}
describe('likes + realtime (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 adminToken: string;
let tokenA: string;
let tokenB: string;
let albumAId: string;
let photoA1: string;
let photoA2: string;
let photoB1: 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 仍有未投递事件');
}
/** cn 建相册 → 返回 {albumId, publicToken}。 */
async function createAlbum(title: string): Promise<{
albumId: string;
publicToken: string;
}> {
const album = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${adminToken}`)
.send({
translations: [{ locale: 'zh-CN', title }],
defaultLocale: 'zh-CN',
startAt: '2026-10-01T10:00:00.000Z',
});
expect(album.status).toBe(201);
return {
albumId: album.body.id as string,
publicToken: album.body.publicToken as string,
};
}
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
async function uploadAndPublish(
albumId: string,
clientTaskId: string,
jpeg: Buffer,
): Promise<string> {
const init = await request(cnBase)
.post('/v1/uploads')
.set('Authorization', `Bearer ${adminToken}`)
.send({
albumId,
clientTaskId,
filename: 'likes-rt.jpg',
bytes: jpeg.length,
contentType: 'image/jpeg',
});
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;
}
/** ov 入口 WS 基址(同端口 HTTP+WS)。 */
function wsUrl(): string {
return ovBase.replace(/^http/, 'ws') + REALTIME_WS_PATH;
}
beforeAll(async () => {
expect(SYNC_KEY).not.toBe('');
await resetDb();
await resetOvDb();
// 合并窗与限速阈值均为惰性 env(同 HOT_SORT_TTL_MS 模式):测试内压/放宽。
process.env.LIKE_BURST_MERGE_MS = String(BURST_MERGE_TEST_MS);
// 限速滑窗键跨 spec 运行残留(TTL 24h):清场保证本 spec 从零计数。
const redis = new Redis(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381');
let cursor = '0';
do {
const [next, keys] = await redis.scan(
cursor,
'MATCH',
'like:window:*',
'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,
});
// main-ov 同形:契约帧适配器(WS 与 HTTP 同端口)。
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,
});
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;
// —— 种数据:相册 A(两张照片:A1 点赞主目标 / A2 隐藏目标)+ 相册 B ——
const albumA = await createAlbum('点赞实时 A');
tokenA = albumA.publicToken;
albumAId = albumA.albumId;
const albumB = await createAlbum('点赞实时 B');
tokenB = albumB.publicToken;
photoA1 = await uploadAndPublish(
albumAId,
'likes-rt-a1',
await makeJpeg(900, 600, { r: 180, g: 120, b: 60 }),
);
photoA2 = await uploadAndPublish(
albumAId,
'likes-rt-a2',
await makeJpeg(800, 500, { r: 60, g: 130, b: 180 }),
);
photoB1 = await uploadAndPublish(
albumB.albumId,
'likes-rt-b1',
await makeJpeg(700, 450, { r: 120, g: 180, b: 90 }),
);
await drainSender();
});
afterAll(async () => {
delete process.env.LIKE_BURST_MERGE_MS;
delete process.env.LIKE_SESSION_WINDOW_DELTA;
await ovApp.close();
await cnApp.close();
await prismaCn.$disconnect();
await prismaOv.$disconnect();
});
it('① A09 点赞批次:连点累计、幂等重试不加计、outbox 快照、参数/404 校验', async () => {
const session = 'e2e-session-a09';
// 连点 3 批:total 1 → 3 → 6,countVersion 逐批 +1。
const b1 = await like(ovBase, tokenA, photoA1, {
sessionId: session,
batchId: 'batch-1',
delta: 1,
});
expect(b1.status).toBe(200);
expect(b1.body).toEqual({ total: 1, countVersion: 1, batchAccepted: true });
const b2 = await like(ovBase, tokenA, photoA1, {
sessionId: session,
batchId: 'batch-2',
delta: 2,
});
expect(b2.body).toEqual({ total: 3, countVersion: 2, batchAccepted: true });
const b3 = await like(ovBase, tokenA, photoA1, {
sessionId: session,
batchId: 'batch-3',
delta: 3,
});
expect(b3.body).toEqual({ total: 6, countVersion: 3, batchAccepted: true });
// 幂等重试:同 batchId 再提交 → 权威 total 不变、batchAccepted 仍 true。
const retry = await like(ovBase, tokenA, photoA1, {
sessionId: session,
batchId: 'batch-2',
delta: 2,
});
expect(retry.status).toBe(200);
expect(retry.body).toEqual({ total: 6, countVersion: 3, batchAccepted: true });
// like_batches 不因重试增行(3 批 3 行)。
expect(await prismaOv.like_batches.count({ where: { photoId: photoA1 } })).toBe(
3,
);
// outbox_stat_events 逐批落权威快照(total 单调 1/3/6,statVersion 对齐)。
const outbox = await prismaOv.outbox_stat_events.findMany({
where: { photoId: photoA1 },
orderBy: { statVersion: 'asc' },
});
expect(outbox.map((r) => [r.total, r.statVersion, r.kind])).toEqual([
[1, 1, 'likes'],
[3, 2, 'likes'],
[6, 3, 'likes'],
]);
// 参数校验:delta 越界 / 字段长度 65 → 400 VALIDATION_FAILED。
for (const bad of [
{ sessionId: session, batchId: 'bad-0', delta: 0 },
{ sessionId: session, batchId: 'bad-21', delta: 21 },
{ sessionId: 'x'.repeat(65), batchId: 'bad-len', delta: 1 },
{ sessionId: session, batchId: 'y'.repeat(65), delta: 1 },
]) {
const res = await like(ovBase, tokenA, photoA1, bad);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
// 404:幽灵相册 token / 幽灵照片(合法相册下)。
const ghostToken = await like(ovBase, 'g' + '0'.repeat(23), photoA1, {
sessionId: session,
batchId: 'bad-token',
delta: 1,
});
expect(ghostToken.status).toBe(404);
expect(ghostToken.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
const ghostPhoto = await like(ovBase, tokenA, 'p-ghost', {
sessionId: session,
batchId: 'bad-photo',
delta: 1,
});
expect(ghostPhoto.status).toBe(404);
expect(ghostPhoto.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
});
it('② 基础限速:sessionId 滑窗超额 → 429 RATE_LIMITED', async () => {
process.env.LIKE_SESSION_WINDOW_DELTA = '5';
try {
const session = 'e2e-session-rate';
const ok = await like(ovBase, tokenA, photoA1, {
sessionId: session,
batchId: 'rl-1',
delta: 3,
});
expect(ok.status).toBe(200);
// 累计 3+3=6 > 5 → 拒绝(且本批不加计)。
const limited = await like(ovBase, tokenA, photoA1, {
sessionId: session,
batchId: 'rl-2',
delta: 3,
});
expect(limited.status).toBe(429);
expect(limited.body.code).toBe(ErrorCodes.RATE_LIMITED);
// 未入账:批次表无 rl-2 行,权威 total = ① 的 6 + 本测试已受理的 rl-1=3
// (若被拒批次加计则为 12)。
expect(
await prismaOv.like_batches.count({
where: { sessionId: session },
}),
).toBe(1);
const row = await prismaOv.photos_ov.findUniqueOrThrow({
where: { photoId: photoA1 },
select: { likeTotal: true },
});
expect(row.likeTotal).toBe(9);
} finally {
delete process.env.LIKE_SESSION_WINDOW_DELTA;
}
});
it('③ WS 协议:ping→pong 回显、幽灵 token error 帧、错误后连接保持', async () => {
const client = await WsClient.open(wsUrl());
try {
const ts = 1758187200123;
client.send({ type: 'ping', ts });
const pong = await client.waitFor(
(f) => f.type === 'pong',
3000,
'pong',
);
expect(pong).toEqual({ type: 'pong', ts });
// 幽灵 token:error 帧(ALBUM_NOT_PUBLIC)。
client.send({
type: 'subscribe',
albumToken: 'g' + '0'.repeat(23),
});
const error = await client.waitFor(
(f) => f.type === 'error',
3000,
'error',
);
expect(error).toEqual({
type: 'error',
code: ErrorCodes.ALBUM_NOT_PUBLIC,
});
// 连接保持:错误后仍可 ping→pong(可修正后重订其它相册)。
client.send({ type: 'ping', ts: 1758187200456 });
const pong2 = await client.waitFor(
(f) => f.type === 'pong' && f.ts === 1758187200456,
3000,
'pong2',
);
if (pong2.type !== 'pong') {
throw new Error('unreachable');
}
expect(pong2.ts).toBe(1758187200456);
} finally {
client.close();
}
});
it('④ A10 房间隔离:跨册点赞只达本册房间,另一房间零消息', async () => {
const clientA = await WsClient.open(wsUrl());
const clientB = await WsClient.open(wsUrl());
try {
clientA.send({ type: 'subscribe', albumToken: tokenA });
clientB.send({ type: 'subscribe', albumToken: tokenB });
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
// 非空转证明:先各以一次本册点赞证明两侧订阅均已生效。
await like(ovBase, tokenA, photoA1, {
sessionId: 'e2e-session-iso-a',
batchId: 'iso-a1',
delta: 1,
});
await clientA.waitFor((f) => f.type === 'likes.burst', 5000, 'A 首个 burst');
await like(ovBase, tokenB, photoB1, {
sessionId: 'e2e-session-iso-b',
batchId: 'iso-b1',
delta: 1,
});
await clientB.waitFor((f) => f.type === 'likes.burst', 5000, 'B 首个 burst');
// 隔离断言:再赞 B,A 房间在「B 收到 burst + 合并窗外一拍」后仍零新增。
const aBefore = clientA.received().length;
await like(ovBase, tokenB, photoB1, {
sessionId: 'e2e-session-iso-b',
batchId: 'iso-b2',
delta: 2,
});
await clientB.waitFor(
(f) => f.type === 'likes.burst' && f.originBatchIds.includes('iso-b2'),
5000,
'B 第二个 burst',
);
await new Promise((resolve) =>
setTimeout(resolve, BURST_MERGE_TEST_MS + 300),
);
expect(clientA.received().length).toBe(aBefore); // A 房间零新增
} finally {
clientA.close();
clientB.close();
}
});
it('⑤ A21 合并广播:合并窗内两批 → 恰一条 likes.burst(delta 求和 + 双 originBatchIds)', async () => {
const client = await WsClient.open(wsUrl());
try {
client.send({ type: 'subscribe', albumToken: tokenA });
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
const before = await prismaOv.photos_ov.findUniqueOrThrow({
where: { photoId: photoA1 },
select: { likeTotal: true, countVersion: true },
});
// 两批快速提交(远小于合并窗):同照片不同 batchId。
const first = await like(ovBase, tokenA, photoA1, {
sessionId: 'e2e-session-merge',
batchId: 'merge-1',
delta: 1,
});
const second = await like(ovBase, tokenA, photoA1, {
sessionId: 'e2e-session-merge',
batchId: 'merge-2',
delta: 2,
});
expect(first.body.total).toBe(before.likeTotal + 1);
expect(second.body.total).toBe(before.likeTotal + 3);
// 恰一条 burst:delta=1+2、originBatchIds 双元素、total 权威、版本对齐。
const burst = await client.waitFor(
(f) => f.type === 'likes.burst' && f.originBatchIds.includes('merge-2'),
5000,
'合并 burst',
);
if (burst.type !== 'likes.burst') {
throw new Error('unreachable');
}
expect(burst.photoId).toBe(photoA1);
expect(burst.albumId).toBe(albumAId);
expect(burst.delta).toBe(3);
expect([...burst.originBatchIds].sort()).toEqual(['merge-1', 'merge-2']);
expect(burst.total).toBe(before.likeTotal + 3);
expect(burst.countVersion).toBe(before.countVersion + 2);
expect(typeof burst.burstId).toBe('string');
// 窗外不再补发第二条(等待一个完整合并窗 + 余量)。
await new Promise((resolve) =>
setTimeout(resolve, BURST_MERGE_TEST_MS + 300),
);
expect(client.countOf('likes.burst')).toBe(1);
} finally {
client.close();
}
});
it('⑥ photo.hidden 实时:cn 隐藏 → 同步应用 → 房间收 photo.hidden;隐藏后点赞 404', async () => {
const client = await WsClient.open(wsUrl());
try {
client.send({ type: 'subscribe', albumToken: tokenA });
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
const hide = await request(cnBase)
.patch(`/v1/photos/${photoA2}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ visibility: 'hidden' });
expect(hide.status).toBe(200);
await drainSender();
const hidden = await client.waitFor(
(f) => f.type === 'photo.hidden' && f.photoId === photoA2,
5000,
'photo.hidden',
);
if (hidden.type !== 'photo.hidden') {
throw new Error('unreachable');
}
expect(hidden.albumId).toBe(albumAId);
const replica = await prismaOv.photos_ov.findUniqueOrThrow({
where: { photoId: photoA2 },
select: { entityVersion: true, visibility: true },
});
expect(hidden.entityVersion).toBe(replica.entityVersion);
expect(replica.visibility).toBe('hidden');
// 隐藏后点赞:404 PHOTO_NOT_PUBLIC(与公开读同口径)。
const denied = await like(ovBase, tokenA, photoA2, {
sessionId: 'e2e-session-hidden',
batchId: 'hidden-1',
delta: 1,
});
expect(denied.status).toBe(404);
expect(denied.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
} finally {
client.close();
}
});
});