// 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 { 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 { 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 { const ws = new WebSocket(url); await new Promise((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 { 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 { 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 { 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 }; }; 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(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); cnBase = await cnApp.getUrl(); ovApp = await NestFactory.create(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(); } }); });