85b2da3db5
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>
654 lines
23 KiB
TypeScript
654 lines
23 KiB
TypeScript
// 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();
|
||
}
|
||
});
|
||
});
|