Files
photo-live/services/api/test/public-ov.e2e-spec.ts
T
浅念 8e674a902e feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
  outbox album.updated priority=high + audit album.delete);列表/详情/更新/
  成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
  (status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
  album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 10:13:08 +08:00

565 lines
23 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);
});
it('⑥ banner 回退 cover(asset.updated kind=cover 投影 coverAssetKey + ov 读侧回退)', async () => {
// cn 真实链路:建相册 → cover 资产 init/PUT/complete(真实 asset.updated 事件)。
const album = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${adminToken}`)
.send({
translations: [{ locale: 'zh-CN', title: '仅封面 ov 相册' }],
defaultLocale: 'zh-CN',
startAt: '2026-10-02T10:00:00.000Z',
});
expect(album.status).toBe(201);
const token = album.body.publicToken as string;
const coverJpeg = await makeJpeg(960, 320, { r: 90, g: 130, b: 180 });
const init = await request(cnBase)
.post(`/v1/albums/${album.body.id as string}/assets`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
kind: 'cover',
filename: 'cover.jpg',
bytes: coverJpeg.length,
contentType: 'image/jpeg',
});
expect(init.status).toBe(201);
const credential = init.body.credential as { url: string; method: string; headers: Record<string, string> };
await fetch(credential.url, {
method: credential.method,
headers: credential.headers,
body: new Uint8Array(coverJpeg),
});
const done = await request(cnBase)
.post(`/v1/albums/${album.body.id as string}/assets/${init.body.assetId as string}/complete`)
.set('Authorization', `Bearer ${adminToken}`);
expect(done.status).toBe(200);
// 真实发送管线:asset.updated(cover) → ov coverAssetKey 投影。
await drainSender();
// ov 读侧回退:bannerAssetKey 空 → coverAssetKey 签发(字节=cover 对象)。
const before = await prismaOv.albums_ov.findUnique({ where: { publicToken: token } });
expect(before?.bannerAssetKey).toBeNull();
expect(before?.coverAssetKey).toBeTruthy();
const ov = await getJson(ovBase, `/v1/public/albums/${token}`);
expect(ov.status).toBe(200);
const banner = ov.body.banner as { url: string };
expect(banner).not.toBeNull();
const fetched = await fetch(banner.url);
expect(fetched.status).toBe(200);
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
});
});