Files
photo-live/services/api/test/public-ov.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

513 lines
21 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/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);
});
});