// 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 { 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 { 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 { 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 { 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 }; }; 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 { 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(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); cnBase = await cnApp.getUrl(); ovApp = await NestFactory.create(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; }; 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 }; 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'); }); });