Files
photo-live/services/api/test/visits.e2e-spec.ts
T
浅念 1f2a0c658f feat(ov): windowed dedup visit counting with retention cleanup
Task 8 (P2): POST /public/albums/{token}/visits with visit_sessions
window dedup (VISIT_WINDOW_MINUTES), requestId idempotent replay
(echoes stored outcome; expired beyond VISIT_REQUEST_TTL_MINUTES
rejected), album_stats viewCount/statVersion authority + outbox_stat_events
views snapshots, WS stats.updated broadcast, visitorId+IP fixed-window
rate limit (VISIT_WINDOW_REQUESTS), and ov CleanupModule scheduler
(setInterval, CLEANUP_INTERVAL_MINUTES) deleting like_batches /
visit_requests / visit_sessions past their windows — totals never
touched. Contract prose aligned to replay semantics; constants
VISIT_WINDOW_REQUESTS / CLEANUP_INTERVAL_MINUTES added.

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

802 lines
29 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/visits.e2e-spec.ts —— P2 Task 8 浏览统计 + 去重数据清理
// e2e。与 likes-realtime.e2e-spec 同模式:进程内起 cn + ov 两个真实入口,
// 业务动作全走 cn API(建相册/上传 complete/直驱发布事务/开关),OutboxSender
// (生产实现)指向 ov 入口真实 URL——浏览计数作用在与生产链路同源的数据上。
//
// 覆盖(A14 仿真级 + Task 8 全部工作项):
// ① A14 计数主干:首访 counted=true viewCount 1 → GET album 读到真实值
// (Task 6 接线验证);窗口内刷新(新 requestId)counted=false 不加计;
// 不同 visitorId counted=true 2;visit_requests/visit_sessions/
// album_stats/outbox_stat_events(kind views)行级断言;
// ② requestId 幂等重试:重放回显原结局(counted=true 行仍 true / false 行
// 仍 false),viewCount 当前权威,不加计;
// ③ 新窗口再计:OvPrisma 手工回退 visit_sessions.lastCountedAt 越窗 →
// 新 requestId counted=true 3;
// ④ 并发首访:Promise.all 同 visitorId 两个 requestId → 恰一个
// counted=true(唯一约束 + 冲突后重读),总计数只 +1;
// ⑤ 过期 requestId 重放:回退 visit_requests.createdAt 越 TTL →
// counted=false(无论原结局),不加计;
// ⑥ 404/400:幽灵 token / publicEnabled=false 同步后的真实相册 →
// ALBUM_NOT_PUBLIC;visitorId 7 字符 / requestId 129 字符 / 缺字段 →
// VALIDATION_FAILED;
// ⑦ 基础限速:VISIT_WINDOW_REQUESTS 压小(惰性 env)→ 窗口超额 429
// RATE_LIMITED;幂等快速通道不消耗限速预算(重放已确认 requestId 仍 200);
// ⑧ 上报排除规则:GET album / 列表 / 单图 / 点赞 / WS 帧(subscribe+ping)
// 一律不触碰 visit_* / album_stats(行数与 viewCount 快照不变);
// ⑨ WS stats.updated:计入 → 相册房间收 {albumId, viewCount,
// countVersion=statVersion};窗口内重复(counted=false)不广播;
// ⑩ runCleanupOnce:fabricate 超窗旧行(like_batches 25h / visit_requests
// 36min / visit_sessions 31min)→ 全删;窗口内新行全留;累计总数
// (album_stats / photos_ov.likeTotal)分毫不动;
// ⑪ 过期批次重放(Task 7 已登记偏差的文档化行为):清理删除 like_batches
// 旧行后,重放同 batchId 以「新批次」身份重新加计(batchAccepted=true,
// within session cap)——无永久幂等账本的取舍边界,见 likes.service.ts
// 头注;此处固化该已登记行为(确定性:回退行 + 直调清理,无时间等待)。
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 { CleanupService } from '../src/cleanup/cleanup.service';
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';
/** subscribe 为异步校验(DB 查询):发送后的就绪等待(本地查询 ~ms 级)。 */
const SUBSCRIBE_SETTLE_MS = 300;
/** 「无新增广播」断言的观察窗(远大于任何提交路径时延)。 */
const QUIET_MS = 1000;
/** 测试内生成真实 JPEG(同 likes-realtime.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,断言交给调用方;body 宽松以覆盖 400 用例)。 */
async function visit(
ovBase: string,
token: string,
body: { visitorId: string; requestId?: string },
): Promise<request.Response> {
return request(ovBase)
.post(`/v1/public/albums/${token}/visits`)
.send(body);
}
/** 契约帧 WS 测试客户端:收帧队列 + 条件等待(同 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 });
});
}
/** 按类型统计(「不广播」断言用)。 */
countOf(type: ServerMessage['type']): number {
return this.frames.filter((f) => f.type === type).length;
}
close(): void {
this.ws.close();
}
}
describe('visits + cleanup (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;
/** 排空发送器:循环 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: 'visits.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();
// 限速固定窗键跨 spec 运行残留(TTL=去重窗口 30min):清场保证本 spec
// 从零计数(同 likes spec 的 like:window:* 清场约定)。
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',
'visit: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(浏览主目标 + 1 张照片供排除规则/过期批次用例)、
// 相册 B(publicEnabled=false → ov 同步后不可见,404 用例)——
const albumA = await createAlbum('浏览统计 A');
tokenA = albumA.publicToken;
albumAId = albumA.albumId;
const albumB = await createAlbum('浏览统计 B(未公开)');
tokenB = albumB.publicToken;
photoA1 = await uploadAndPublish(
albumAId,
'visits-a1',
await makeJpeg(900, 600, { r: 200, g: 100, b: 80 }),
);
const off = await request(cnBase)
.patch(`/v1/albums/${albumB.albumId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ publicEnabled: false });
expect(off.status).toBe(200);
await drainSender();
});
afterAll(async () => {
delete process.env.VISIT_WINDOW_REQUESTS;
await ovApp.close();
await cnApp.close();
await prismaCn.$disconnect();
await prismaOv.$disconnect();
});
it('① A14 计数主干:首访计入、窗口内去重、跨访客计入、GET album 真实 viewCount', async () => {
// 首访:counted=true,viewCount 1。
const first = await visit(ovBase, tokenA, {
visitorId: 'visitor-a14-1',
requestId: 'req-a14-first',
});
expect(first.status).toBe(200);
expect(first.body).toEqual({ viewCount: 1, counted: true });
// Task 6 接线:GET 公开相册读到 album_stats 真实值(非占位)。
const album1 = await request(ovBase)
.get(`/v1/public/albums/${tokenA}`)
.query({ locale: 'zh-CN' });
expect(album1.status).toBe(200);
expect(album1.body.viewCount).toBe(1);
// 窗口内刷新(同 visitor 新 requestId):counted=false,不加计。
const refresh = await visit(ovBase, tokenA, {
visitorId: 'visitor-a14-1',
requestId: 'req-a14-refresh',
});
expect(refresh.status).toBe(200);
expect(refresh.body).toEqual({ viewCount: 1, counted: false });
// 不同 visitorId:新访次计入。
const second = await visit(ovBase, tokenA, {
visitorId: 'visitor-a14-2',
requestId: 'req-a14-second',
});
expect(second.status).toBe(200);
expect(second.body).toEqual({ viewCount: 2, counted: true });
const album2 = await request(ovBase)
.get(`/v1/public/albums/${tokenA}`)
.query({ locale: 'zh-CN' });
expect(album2.body.viewCount).toBe(2);
// 行级断言:幂等行(counted 回映各自结局)、去重锚点、权威位、回传队列。
const requests = await prismaOv.visit_requests.findMany({
where: { albumId: albumAId },
orderBy: { createdAt: 'asc' },
});
expect(requests.map((r) => [r.requestId, r.counted])).toEqual([
['req-a14-first', true],
['req-a14-refresh', false],
['req-a14-second', true],
]);
expect(await prismaOv.visit_sessions.count()).toBe(2);
const stats = await prismaOv.album_stats.findUniqueOrThrow({
where: { albumId: albumAId },
});
expect([stats.viewCount, stats.statVersion]).toEqual([2, 2]);
const outbox = await prismaOv.outbox_stat_events.findMany({
where: { kind: 'views', albumId: albumAId },
orderBy: { statVersion: 'asc' },
});
expect(outbox.map((r) => [r.total, r.statVersion])).toEqual([
[1, 1],
[2, 2],
]);
});
it('② requestId 幂等重试:回显原结局、viewCount 权威、不加计', async () => {
// 重放已计入的 requestId:counted 仍 true(确认丢失重试的闭环语义)。
const retryCounted = await visit(ovBase, tokenA, {
visitorId: 'visitor-a14-1',
requestId: 'req-a14-first',
});
expect(retryCounted.status).toBe(200);
expect(retryCounted.body).toEqual({ viewCount: 2, counted: true });
// 重放未计入(窗口内重复)的 requestId:counted 仍 false。
const retrySkipped = await visit(ovBase, tokenA, {
visitorId: 'visitor-a14-1',
requestId: 'req-a14-refresh',
});
expect(retrySkipped.body).toEqual({ viewCount: 2, counted: false });
// 不加计:幂等行不增、权威位不动。
expect(await prismaOv.visit_requests.count()).toBe(3);
const stats = await prismaOv.album_stats.findUniqueOrThrow({
where: { albumId: albumAId },
});
expect([stats.viewCount, stats.statVersion]).toEqual([2, 2]);
});
it('③ 新窗口再计:回退 lastCountedAt 越窗 → 同 visitor 再计入', async () => {
// 手工回退 visitor1 的去重锚点至窗口(30min)外。
await prismaOv.visit_sessions.updateMany({
where: { albumId: albumAId, visitorId: 'visitor-a14-1' },
data: { lastCountedAt: new Date(Date.now() - 32 * 60_000) },
});
const again = await visit(ovBase, tokenA, {
visitorId: 'visitor-a14-1',
requestId: 'req-a14-newwindow',
});
expect(again.status).toBe(200);
expect(again.body).toEqual({ viewCount: 3, counted: true });
// 锚点被重新置为当前时刻(窗口重新起算)。
const session = await prismaOv.visit_sessions.findUniqueOrThrow({
where: {
albumId_visitorId: { albumId: albumAId, visitorId: 'visitor-a14-1' },
},
});
expect(
Date.now() - session.lastCountedAt!.getTime(),
).toBeLessThan(60_000);
});
it('④ 并发首访:同 visitorId 两 requestId → 恰一个 counted=true', async () => {
const [r1, r2] = await Promise.all([
visit(ovBase, tokenA, {
visitorId: 'visitor-conc-1',
requestId: 'req-conc-1',
}),
visit(ovBase, tokenA, {
visitorId: 'visitor-conc-1',
requestId: 'req-conc-2',
}),
]);
expect(r1.status).toBe(200);
expect(r2.status).toBe(200);
const outcomes = [r1.body, r2.body].sort(
(a: { counted: boolean }, b: { counted: boolean }) =>
Number(b.counted) - Number(a.counted),
);
// 恰一个计入(权威 viewCount=4),另一个窗口内重复(权威值同为 4)。
expect(outcomes).toEqual([
{ viewCount: 4, counted: true },
{ viewCount: 4, counted: false },
]);
// 去重锚点唯一:并发只落一行 session;权威位只 +1。
expect(
await prismaOv.visit_sessions.count({
where: { visitorId: 'visitor-conc-1' },
}),
).toBe(1);
const stats = await prismaOv.album_stats.findUniqueOrThrow({
where: { albumId: albumAId },
});
expect([stats.viewCount, stats.statVersion]).toEqual([4, 4]);
});
it('⑤ 过期 requestId 重放:越 TTL → counted=false(无论原结局),不加计', async () => {
// 回退首个已计入 requestId 的行龄至 TTL(35min)外。
await prismaOv.visit_requests.update({
where: { requestId: 'req-a14-first' },
data: { createdAt: new Date(Date.now() - 36 * 60_000) },
});
const replay = await visit(ovBase, tokenA, {
visitorId: 'visitor-a14-1',
requestId: 'req-a14-first',
});
expect(replay.status).toBe(200);
expect(replay.body).toEqual({ viewCount: 4, counted: false });
const stats = await prismaOv.album_stats.findUniqueOrThrow({
where: { albumId: albumAId },
});
expect(stats.viewCount).toBe(4);
});
it('⑥ 404/400:幽灵 token / 未公开相册 / 参数越界', async () => {
const ghost = await visit(ovBase, 'g' + '0'.repeat(23), {
visitorId: 'visitor-a14-1',
requestId: 'req-404-ghost',
});
expect(ghost.status).toBe(404);
expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
// 真实相册 publicEnabled=false(cn 已同步):同码 404。
const invisible = await visit(ovBase, tokenB, {
visitorId: 'visitor-a14-1',
requestId: 'req-404-invisible',
});
expect(invisible.status).toBe(404);
expect(invisible.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
for (const bad of [
{ visitorId: 'v'.repeat(7), requestId: 'req-bad-1' }, // visitorId < 8
{ visitorId: 'visitor-a14-1', requestId: 'r'.repeat(129) }, // requestId > 128
{ visitorId: 'visitor-a14-1' }, // 缺 requestId
]) {
const res = await visit(ovBase, tokenA, bad);
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
});
it('⑦ 基础限速:visitorId+IP 固定窗超额 429;幂等快速通道不耗预算', async () => {
process.env.VISIT_WINDOW_REQUESTS = '3';
try {
const visitor = 'visitor-rate-1';
// 窗口预算 3:n1 计入(viewCount 5)、n2/n3 窗口内重复(仍消耗预算)。
const n1 = await visit(ovBase, tokenA, {
visitorId: visitor,
requestId: 'req-rate-1',
});
expect(n1.body).toEqual({ viewCount: 5, counted: true });
const n2 = await visit(ovBase, tokenA, {
visitorId: visitor,
requestId: 'req-rate-2',
});
expect(n2.body).toEqual({ viewCount: 5, counted: false });
const n3 = await visit(ovBase, tokenA, {
visitorId: visitor,
requestId: 'req-rate-3',
});
expect(n3.body).toEqual({ viewCount: 5, counted: false });
// 第 4 次:超额 → 429 RATE_LIMITED。
const n4 = await visit(ovBase, tokenA, {
visitorId: visitor,
requestId: 'req-rate-4',
});
expect(n4.status).toBe(429);
expect(n4.body.code).toBe(ErrorCodes.RATE_LIMITED);
// 幂等快速通道:重放已确认 requestId 不进限速(客户端重试是正常路径)。
const retry = await visit(ovBase, tokenA, {
visitorId: visitor,
requestId: 'req-rate-1',
});
expect(retry.status).toBe(200);
expect(retry.body).toEqual({ viewCount: 5, counted: true });
// 被拒请求未入账:visit_requests 无 req-rate-4 行。
expect(
await prismaOv.visit_requests.count({
where: { requestId: 'req-rate-4' },
}),
).toBe(0);
} finally {
delete process.env.VISIT_WINDOW_REQUESTS;
}
});
it('⑧ 上报排除规则:读接口/点赞/WS 帧一律不触碰浏览数据', async () => {
const before = {
requests: await prismaOv.visit_requests.count(),
sessions: await prismaOv.visit_sessions.count(),
statsRows: await prismaOv.album_stats.count(),
viewCount: (
await prismaOv.album_stats.findUniqueOrThrow({
where: { albumId: albumAId },
})
).viewCount,
};
// 公开读三端点。
expect(
(
await request(ovBase)
.get(`/v1/public/albums/${tokenA}`)
.query({ locale: 'zh-CN' })
).status,
).toBe(200);
expect(
(
await request(ovBase).get(`/v1/public/albums/${tokenA}/photos`)
).status,
).toBe(200);
expect(
(
await request(ovBase)
.get(`/v1/public/albums/${tokenA}/photos/${photoA1}`)
).status,
).toBe(200);
// 点赞(含照片计数写入——但与浏览表无关)。
const like = await request(ovBase)
.post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`)
.send({ sessionId: 'excl-session', batchId: 'excl-1', delta: 1 });
expect(like.status).toBe(200);
// WS 帧:subscribe(相册校验读)+ ping/pong。
const client = await WsClient.open(wsUrl());
try {
client.send({ type: 'subscribe', albumToken: tokenA });
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
client.send({ type: 'ping', ts: Date.now() });
await client.waitFor((f) => f.type === 'pong', 3000, 'pong');
} finally {
client.close();
}
// 浏览域零变更:行数与权威 viewCount 均不变。
expect(await prismaOv.visit_requests.count()).toBe(before.requests);
expect(await prismaOv.visit_sessions.count()).toBe(before.sessions);
expect(await prismaOv.album_stats.count()).toBe(before.statsRows);
expect(
(
await prismaOv.album_stats.findUniqueOrThrow({
where: { albumId: albumAId },
})
).viewCount,
).toBe(before.viewCount);
});
it('⑨ WS stats.updated:计入即广播(countVersion=statVersion);重复不广播', async () => {
const client = await WsClient.open(wsUrl());
try {
client.send({ type: 'subscribe', albumToken: tokenA });
await new Promise((resolve) => setTimeout(resolve, SUBSCRIBE_SETTLE_MS));
// 新访客计入 → 房间收 stats.updated(viewCount 6 / countVersion 6)。
const counted = await visit(ovBase, tokenA, {
visitorId: 'visitor-ws-1',
requestId: 'req-ws-1',
});
expect(counted.body).toEqual({ viewCount: 6, counted: true });
const frame = await client.waitFor(
(f) => f.type === 'stats.updated',
5000,
'stats.updated',
);
if (frame.type !== 'stats.updated') {
throw new Error('unreachable');
}
expect(frame.albumId).toBe(albumAId);
expect(frame.viewCount).toBe(6);
expect(frame.countVersion).toBe(6);
// 窗口内重复(counted=false):不广播。
const skip = await visit(ovBase, tokenA, {
visitorId: 'visitor-ws-1',
requestId: 'req-ws-2',
});
expect(skip.body).toEqual({ viewCount: 6, counted: false });
await new Promise((resolve) => setTimeout(resolve, QUIET_MS));
expect(client.countOf('stats.updated')).toBe(1);
} finally {
client.close();
}
});
it('⑩ runCleanupOnce:超窗行全删、窗口内行全留、累计总数不动', async () => {
// fabricate 三类超窗旧行(直接落库,无时间等待)。
await prismaOv.like_batches.create({
data: {
photoId: photoA1,
sessionId: 'cleanup-old-session',
batchId: 'cleanup-old-batch',
delta: 5,
createdAt: new Date(Date.now() - 25 * 3_600_000),
},
});
await prismaOv.visit_requests.create({
data: {
requestId: 'cleanup-old-req',
albumId: albumAId,
visitorId: 'cleanup-old-visitor',
createdAt: new Date(Date.now() - 36 * 60_000),
},
});
await prismaOv.visit_sessions.create({
data: {
albumId: albumAId,
visitorId: 'cleanup-old-visitor',
lastCountedAt: new Date(Date.now() - 31 * 60_000),
},
});
// 清理前基线(新鲜行数 + 两个权威总数)。
const requestsBefore = await prismaOv.visit_requests.count();
const sessionsBefore = await prismaOv.visit_sessions.count();
const batchesBefore = await prismaOv.like_batches.count();
const likeTotalBefore = (
await prismaOv.photos_ov.findUniqueOrThrow({ where: { photoId: photoA1 } })
).likeTotal;
const stats = await ovApp.get(CleanupService).runCleanupOnce();
// 删除量:like_batches 1 行旧批;visit_requests 2 行(fabricate 旧行 +
// ⑤ 回退的 req-a14-first,行龄 36min > TTL 35min);visit_sessions 1 行。
expect(stats.likeBatches).toBe(1);
expect(stats.visitRequests).toBe(2);
expect(stats.visitSessions).toBe(1);
// 旧行确实消失。
expect(
await prismaOv.like_batches.count({
where: { batchId: 'cleanup-old-batch' },
}),
).toBe(0);
expect(
await prismaOv.visit_requests.count({
where: { requestId: 'cleanup-old-req' },
}),
).toBe(0);
expect(
await prismaOv.visit_requests.count({
where: { requestId: 'req-a14-first' },
}),
).toBe(0);
expect(
await prismaOv.visit_sessions.count({
where: { visitorId: 'cleanup-old-visitor' },
}),
).toBe(0);
// 窗口内新行全留(各减去被删的旧行数)。
expect(await prismaOv.visit_requests.count()).toBe(requestsBefore - 2);
expect(await prismaOv.visit_sessions.count()).toBe(sessionsBefore - 1);
expect(await prismaOv.like_batches.count()).toBe(batchesBefore - 1);
// 累计总数分毫不动:album_stats(viewCount/statVersion)与 photos_ov。
const albumStats = await prismaOv.album_stats.findUniqueOrThrow({
where: { albumId: albumAId },
});
expect([albumStats.viewCount, albumStats.statVersion]).toEqual([6, 6]);
expect(
(
await prismaOv.photos_ov.findUniqueOrThrow({
where: { photoId: photoA1 },
})
).likeTotal,
).toBe(likeTotalBefore);
});
it('⑪ 过期批次重放(已登记偏差):清理后重放旧 batchId 重新加计', async () => {
// 提交一批 → 回退行龄越窗 → 清理删除 → 重放同 batchId。
const first = await request(ovBase)
.post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`)
.send({ sessionId: 'dev-old-session', batchId: 'dev-old-batch', delta: 2 });
expect(first.status).toBe(200);
const totalBefore = first.body.total as number;
await prismaOv.like_batches.updateMany({
where: { batchId: 'dev-old-batch' },
data: { createdAt: new Date(Date.now() - 25 * 3_600_000) },
});
const cleanup = await ovApp.get(CleanupService).runCleanupOnce();
expect(cleanup.likeBatches).toBe(1);
// 已登记偏差(likes.service.ts 头注):行被清后与「从未提交」不可区分,
// 重放以新批次身份重新加计(batchAccepted=true)——文档化行为固化;
// session 滑窗上限仍钳制其总量(delta 累计远低于默认 500 上限)。
const replay = await request(ovBase)
.post(`/v1/public/albums/${tokenA}/photos/${photoA1}/likes`)
.send({ sessionId: 'dev-old-session', batchId: 'dev-old-batch', delta: 2 });
expect(replay.status).toBe(200);
expect(replay.body.batchAccepted).toBe(true);
expect(replay.body.total).toBe(totalBefore + 2);
expect(
(
await prismaOv.photos_ov.findUniqueOrThrow({
where: { photoId: photoA1 },
})
).likeTotal,
).toBe(totalBefore + 2);
});
});