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>
513 lines
21 KiB
TypeScript
513 lines
21 KiB
TypeScript
// services/api/test/public-ov.e2e-spec.ts —— P2 Task 6 ov 公开读端点 e2e。
|
||
// ONE spec 串「cn 种 → 同步 → ov 读」全链路:进程内起两个真实入口(cn + ov,
|
||
// 同 sync.e2e-spec 模式),业务动作全走 cn API(建相册/分类/banner 资产/上传
|
||
// complete + 直驱 worker 发布事务),发送器为生产 OutboxSender(指向 ov 入口
|
||
// 真实 URL)——ov 读到的数据与生产链路逐字节同源。
|
||
//
|
||
// 覆盖(任务验收):
|
||
// ① 契约同形(本 spec 的核心证明):ov getPublicAlbum/listPublicPhotos/
|
||
// getPublicPhoto 与 cn 同端点响应 deep-equal(剥离现签 URL 后逐字段相等;
|
||
// album 另差 viewCount——cn 占位 null / ov 真实值);游标字符串相等
|
||
// (同一 shared/cursor 编码);分类过滤/单图 categoryId 同形;
|
||
// banner 签名 URL 可取回真实字节;locale 回退(en/zh)同形;
|
||
// ② viewCount 真实读:无 album_stats 行 → 0;有行 → 行值(计数端点 Task 8);
|
||
// ③ 参数校验同形:sort 非法 → 400(ov 新增参数);cursor 非法 → 400;
|
||
// category 幽灵 → 400(与 cn 同码同因);幽灵 token 三端点 → 404
|
||
// ALBUM_NOT_PUBLIC;
|
||
// ④ hot 排序快照:likeTotal 全 0 → photoId 倒序决胜;TTL 窗口内顺序冻结
|
||
// (DB 改 likeTotal 后响应顺序不变,计数现读);翻页对同一快照切片;
|
||
// TTL 过期后重算 → 顺序翻转;
|
||
// ⑤ 隐藏照片:cn 隐藏 → 高优先级同步 → ov 列表剔除 + 单图 404
|
||
// PHOTO_NOT_PUBLIC;
|
||
// ⑥ 相册下线:cn publicEnabled=false → ov 404(与 cn 同码);
|
||
// ⑦ 紧急停用(Task 5 通道 × 公开读):ov disable → ov 404 而 cn 仍 200
|
||
// (ov 本地独立位);enable → 恢复。
|
||
// 真实 MySQL×2 + MinIO(banner/变体真实 JPEG)+ HMAC 同步链路字节一致。
|
||
import { NestFactory } from '@nestjs/core';
|
||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||
import request from 'supertest';
|
||
import sharp from 'sharp';
|
||
import { PrismaClient } from '@photo/db-cn';
|
||
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||
import { ErrorCodes, signSyncBody } 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 { configureApp } from '../src/shared/configure-app';
|
||
// Task 7 起 AppOvModule 含 realtime 网关:进程内 ov 入口须与生产 main-ov
|
||
// 一致安装契约 WS 适配器(否则 Nest 落到默认 socket.io 驱动 → listen 即报错)。
|
||
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
||
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';
|
||
|
||
/** hot 快照 TTL(默认 30s 对 e2e 太长:缩短到 2s,「窗口内冻结」断言留足余量)。 */
|
||
const HOT_TTL_TEST_MS = 2000;
|
||
|
||
/** 测试内生成真实 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();
|
||
}
|
||
|
||
/** 签名 POST(emergency 内部端点)。 */
|
||
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,
|
||
});
|
||
}
|
||
|
||
/** SignedUrl 字段剥离(现签 URL 必然不同:两端各自签名;形状仍断言)。 */
|
||
interface ItemNoUrl {
|
||
photoId: string;
|
||
width: number;
|
||
height: number;
|
||
likeCount: number;
|
||
publishedAt: string;
|
||
}
|
||
|
||
function stripItem(item: {
|
||
photoId: string;
|
||
width: number;
|
||
height: number;
|
||
thumb: unknown;
|
||
browse: unknown;
|
||
likeCount: number;
|
||
publishedAt: string;
|
||
}): ItemNoUrl {
|
||
const { thumb: _thumb, browse: _browse, ...rest } = item;
|
||
void _thumb;
|
||
void _browse;
|
||
return rest;
|
||
}
|
||
|
||
describe('public-ov (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 albumId: string;
|
||
let publicToken: string;
|
||
let categoryId: string;
|
||
/** 按上传顺序(publishedAt 升序)的三张照片。 */
|
||
let photoIds: 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,
|
||
withCategory: boolean,
|
||
): Promise<string> {
|
||
const init = await request(cnBase)
|
||
.post('/v1/uploads')
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({
|
||
albumId,
|
||
clientTaskId,
|
||
filename: 'pub-ov.jpg',
|
||
bytes: jpeg.length,
|
||
contentType: 'image/jpeg',
|
||
...(withCategory ? { 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;
|
||
}
|
||
|
||
/** GET(匿名公开端点;supertest Response.body 已是宽松 JSON 类型)。 */
|
||
async function getJson(base: string, path: string): Promise<request.Response> {
|
||
return request(base).get(path).set('accept', 'application/json');
|
||
}
|
||
|
||
beforeAll(async () => {
|
||
expect(SYNC_KEY).not.toBe('');
|
||
await resetDb();
|
||
await resetOvDb();
|
||
// hot 快照 TTL 缩短(服务端逐请求惰性读 env,见 public-ov.service)。
|
||
process.env.HOT_SORT_TTL_MS = String(HOT_TTL_TEST_MS);
|
||
|
||
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,
|
||
});
|
||
|
||
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 () => {
|
||
delete process.env.HOT_SORT_TTL_MS;
|
||
await cnApp.close();
|
||
await ovApp.close();
|
||
await prismaCn.$disconnect();
|
||
await prismaOv.$disconnect();
|
||
});
|
||
|
||
it('① ov 三端点与 cn 契约同形(deep-equal 剥离现签 URL;游标逐字节相等)', async () => {
|
||
// —— cn 侧种全套:相册(zh+en)+ 分类 + banner(全局)+ 3 张发布照片 ——
|
||
const album = await request(cnBase)
|
||
.post('/v1/albums')
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({
|
||
translations: [
|
||
{ locale: 'zh-CN', title: '海外晚会', description: 'ov 公开读验收', venue: '主会场' },
|
||
{ locale: 'en', title: 'Overseas 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: 'Red Carpet' },
|
||
],
|
||
});
|
||
expect(category.status).toBe(201);
|
||
categoryId = category.body.id as string;
|
||
|
||
// banner:资产 API 全链路(init → PUT → complete → asset.updated 事件)。
|
||
const bannerJpeg = await makeJpeg(1200, 300, { r: 150, g: 100, b: 60 });
|
||
const bannerInit = await request(cnBase)
|
||
.post(`/v1/albums/${albumId}/assets`)
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({
|
||
kind: 'banner',
|
||
filename: 'banner.jpg',
|
||
bytes: bannerJpeg.length,
|
||
contentType: 'image/jpeg',
|
||
});
|
||
expect(bannerInit.status).toBe(201);
|
||
const bannerCredential = bannerInit.body.credential as {
|
||
url: string;
|
||
method: string;
|
||
headers: Record<string, string>;
|
||
};
|
||
expect(
|
||
await fetch(bannerCredential.url, {
|
||
method: bannerCredential.method,
|
||
headers: bannerCredential.headers,
|
||
body: new Uint8Array(bannerJpeg),
|
||
}).then((r) => r.ok),
|
||
).toBe(true);
|
||
const bannerDone = await request(cnBase)
|
||
.post(`/v1/albums/${albumId}/assets/${bannerInit.body.assetId as string}/complete`)
|
||
.set('Authorization', `Bearer ${adminToken}`);
|
||
expect(bannerDone.status).toBe(200);
|
||
|
||
photoIds = [];
|
||
const shapes = [
|
||
{ w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 }, withCategory: true },
|
||
{ w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 }, withCategory: false },
|
||
{ w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 }, withCategory: false },
|
||
] as const;
|
||
for (let i = 0; i < shapes.length; i++) {
|
||
const s = shapes[i]!;
|
||
photoIds.push(
|
||
await uploadAndPublish(
|
||
`pub-ov-${i}`,
|
||
await makeJpeg(s.w, s.h, s.rgb),
|
||
s.withCategory,
|
||
),
|
||
);
|
||
}
|
||
await drainSender();
|
||
|
||
// —— getPublicAlbum 同形(zh 缺省 + en 显式两个 locale)——
|
||
for (const localeQuery of ['', '?locale=en']) {
|
||
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}${localeQuery}`);
|
||
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}${localeQuery}`);
|
||
expect(ov.status).toBe(200);
|
||
// 剥离 banner(现签 URL 必不同)与 viewCount(cn 占位 null / ov 真实 0)
|
||
// 后逐字段相等——契约同形的直接证明。
|
||
const { banner: _cb, viewCount: _cv, ...cnRest } = cn.body;
|
||
const { banner: ovBanner, viewCount: ovViewCount, ...ovRest } = ov.body;
|
||
void _cb;
|
||
void _cv;
|
||
expect(ovRest).toEqual(cnRest);
|
||
expect(ovViewCount).toBe(0); // 无 album_stats 行 → 默认 0(真实值语义)
|
||
// banner:两端都有、形状合法、ov URL 取回真实字节(签名对副本键有效)。
|
||
expect(ovBanner).not.toBeNull();
|
||
expect(typeof ovBanner.url).toBe('string');
|
||
expect(Date.parse(ovBanner.expiresAt)).toBeGreaterThan(Date.now());
|
||
const fetched = await fetch(ovBanner.url as string);
|
||
expect(fetched.ok).toBe(true);
|
||
expect((await fetched.arrayBuffer()).byteLength).toBe(bannerJpeg.length);
|
||
}
|
||
|
||
// —— listPublicPhotos 同形:默认页 / limit=2(hasMore+游标值)/ 分类过滤 ——
|
||
const listCases = [
|
||
'',
|
||
'?limit=2',
|
||
`?limit=2&category=${categoryId}`,
|
||
'?sort=latest',
|
||
];
|
||
for (const query of listCases) {
|
||
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos${query}`);
|
||
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos${query}`);
|
||
expect(ov.status).toBe(200);
|
||
expect(ov.body.items.map(stripItem)).toEqual(cn.body.items.map(stripItem));
|
||
expect(ov.body.nextCursor).toBe(cn.body.nextCursor); // 同一 shared/cursor 编码 → 逐字节相等
|
||
expect(ov.body.hasMore).toBe(cn.body.hasMore);
|
||
// 现签 URL 形状仍校验(每条可取回真实字节)。
|
||
for (const item of ov.body.items) {
|
||
expect(Date.parse(item.thumb.expiresAt)).toBeGreaterThan(Date.now());
|
||
expect(await fetch(item.thumb.url).then((r) => r.ok)).toBe(true);
|
||
}
|
||
}
|
||
// limit=2 分页确实产生了 hasMore(上面游标相等性才有意义)。
|
||
const firstPage = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?limit=2`);
|
||
expect(firstPage.body.hasMore).toBe(true);
|
||
expect(firstPage.body.items).toHaveLength(2);
|
||
|
||
// —— getPublicPhoto 同形(含 categoryId;刷新入口语义=每次现签)——
|
||
for (const photoId of photoIds) {
|
||
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos/${photoId}`);
|
||
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos/${photoId}`);
|
||
expect(ov.status).toBe(200);
|
||
expect(stripItem(ov.body)).toEqual(stripItem(cn.body));
|
||
expect(ov.body.categoryId).toBe(cn.body.categoryId);
|
||
}
|
||
});
|
||
|
||
it('② viewCount 真实读:album_stats 行值(计数端点 Task 8 接线)', async () => {
|
||
const before = await getJson(ovBase, `/v1/public/albums/${publicToken}`);
|
||
expect(before.body.viewCount).toBe(0);
|
||
await prismaOv.album_stats.create({
|
||
data: { albumId, viewCount: 5, statVersion: 3 },
|
||
});
|
||
const after = await getJson(ovBase, `/v1/public/albums/${publicToken}`);
|
||
expect(after.body.viewCount).toBe(5);
|
||
});
|
||
|
||
it('③ 参数校验同形:sort/cursor/category 400;幽灵 token 三端点 404', async () => {
|
||
const badSort = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=bogus`);
|
||
expect(badSort.status).toBe(400);
|
||
expect(badSort.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||
|
||
const badCursor = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?cursor=zzz`);
|
||
expect(badCursor.status).toBe(400);
|
||
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||
|
||
// 幽灵分类:与 cn 同码同因(防探测,不区分不存在/跨册)。
|
||
for (const base of [cnBase, ovBase]) {
|
||
const ghostCategory = await getJson(base, `/v1/public/albums/${publicToken}/photos?category=cat-ghost`);
|
||
expect(ghostCategory.status).toBe(400);
|
||
expect(ghostCategory.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||
}
|
||
|
||
const ghostToken = 'g' + '0'.repeat(23);
|
||
for (const path of [
|
||
`/v1/public/albums/${ghostToken}`,
|
||
`/v1/public/albums/${ghostToken}/photos`,
|
||
`/v1/public/albums/${ghostToken}/photos/p-x`,
|
||
]) {
|
||
const res = await getJson(ovBase, path);
|
||
expect(res.status).toBe(404);
|
||
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||
}
|
||
});
|
||
|
||
it('④ hot 排序:photoId 决胜 → TTL 窗口冻结(计数现读)→ 过期翻转', async () => {
|
||
// likeTotal 全 0:hot = likeTotal DESC, photoId DESC(id 决胜)。
|
||
const idDesc = [...photoIds].sort((a, b) => (a < b ? 1 : -1));
|
||
const first = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot&limit=2`);
|
||
expect(first.status).toBe(200);
|
||
expect(first.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(idDesc.slice(0, 2));
|
||
expect(first.body.hasMore).toBe(true);
|
||
|
||
// 快照语义:DB 改 likeTotal 后,TTL 窗口内顺序冻结(仍 id 序)——但条目
|
||
// 计数现读(尾张已带新值)。
|
||
const tail = idDesc[2]!;
|
||
await prismaOv.photos_ov.update({
|
||
where: { photoId: tail },
|
||
data: { likeTotal: 10 },
|
||
});
|
||
const stale = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot`);
|
||
expect(stale.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(idDesc);
|
||
expect(stale.body.items.find((i: ItemNoUrl) => i.photoId === tail).likeCount).toBe(10);
|
||
|
||
// 翻页对同一快照切片:第 2 页从冻结清单 offset 2 取——尾张仍在末位。
|
||
const page2 = await getJson(
|
||
ovBase,
|
||
`/v1/public/albums/${publicToken}/photos?sort=hot&limit=2&cursor=${first.body.nextCursor as string}`,
|
||
);
|
||
expect(page2.status).toBe(200);
|
||
expect(page2.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual([tail]);
|
||
expect(page2.body.hasMore).toBe(false);
|
||
|
||
// TTL 过期 → 快照重算 → 顺序翻转(尾张 +10 居首)。
|
||
await new Promise((resolve) => setTimeout(resolve, HOT_TTL_TEST_MS + 200));
|
||
const flipped = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot`);
|
||
expect(flipped.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual([
|
||
tail,
|
||
...idDesc.slice(0, 2),
|
||
]);
|
||
});
|
||
|
||
it('⑤ 隐藏照片:cn 隐藏 → 同步 → ov 列表剔除 + 单图 404 PHOTO_NOT_PUBLIC', async () => {
|
||
const hidden = photoIds[1]!;
|
||
const hide = await request(cnBase)
|
||
.patch(`/v1/photos/${hidden}`)
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({ visibility: 'hidden' });
|
||
expect(hide.status).toBe(200);
|
||
await drainSender();
|
||
|
||
const cnList = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos`);
|
||
const ovList = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos`);
|
||
expect(ovList.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(
|
||
cnList.body.items.map((i: ItemNoUrl) => i.photoId),
|
||
);
|
||
expect(ovList.body.items.map((i: ItemNoUrl) => i.photoId)).not.toContain(hidden);
|
||
|
||
const single = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos/${hidden}`);
|
||
expect(single.status).toBe(404);
|
||
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||
});
|
||
|
||
it('⑥ 相册下线:cn publicEnabled=false → ov 与 cn 同码 404', async () => {
|
||
const offline = await request(cnBase)
|
||
.patch(`/v1/albums/${albumId}`)
|
||
.set('Authorization', `Bearer ${adminToken}`)
|
||
.send({ publicEnabled: false });
|
||
expect(offline.status).toBe(200);
|
||
await drainSender();
|
||
|
||
for (const base of [cnBase, ovBase]) {
|
||
const album = await getJson(base, `/v1/public/albums/${publicToken}`);
|
||
expect(album.status).toBe(404);
|
||
expect(album.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||
const list = await getJson(base, `/v1/public/albums/${publicToken}/photos`);
|
||
expect(list.status).toBe(404);
|
||
}
|
||
});
|
||
|
||
it('⑦ 紧急停用:ov disable → ov 404 而 cn 无感;enable 恢复', async () => {
|
||
// 独立相册(不影响前序场景):cn 建 + 同步 → ov 可读。
|
||
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);
|
||
const token = album.body.publicToken as string;
|
||
await drainSender();
|
||
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
||
|
||
// ov 本地停用(独立于同步版本链)。
|
||
const disable = await signedPost(ovBase, '/v1/internal/emergency/disable', {
|
||
albumToken: token,
|
||
reason: 'e2e: public-ov visibility',
|
||
});
|
||
expect(disable.status).toBe(200);
|
||
const ovRes = await getJson(ovBase, `/v1/public/albums/${token}`);
|
||
expect(ovRes.status).toBe(404);
|
||
expect(ovRes.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||
// cn 无感:仍 200(紧急停用是 ov 观众域动作,cn 权威数据不变)。
|
||
expect((await getJson(cnBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
||
|
||
// 显式 enable → 恢复。
|
||
const enable = await signedPost(ovBase, '/v1/internal/emergency/enable', {
|
||
albumToken: token,
|
||
});
|
||
expect(enable.status).toBe(200);
|
||
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
||
});
|
||
});
|