// services/api/test/downloads.e2e-spec.ts —— P4a Task 4 公开照片下载 e2e // (T12 收口)。与 likes-realtime.e2e-spec 同模式:进程内起 cn + ov 两个真实 // 入口,相册/开关/上传全走 cn API(PATCH albums 开关 → album.updated 同步 → // ov 副本生效),OutboxSender(生产实现)指向 ov 入口真实 URL。覆盖(A18): // ① 开关矩阵:on/on → browse/original 双 200(variant 回显、expiresAt 未来、 // URL 可取回真实字节、original 字节 == 上传原图、browse 字节 == browse // 变体对象);on/off → browse 200 / original 403 DOWNLOAD_DISABLED; // off/off 与 off/on(级联——downloadEnabled 关则 original 也 403,无旁路) // → 双 variant 均 403; // ② 水印相册:browse 下载 URL = 模式键 variants/{photoId}/{v}/watermark.jpg // (与公开列表同一 ovBrowseKey 推导),字节 == 水印变体对象; // ③ 可见性:隐藏照片(cn 隐藏 + 同步)→ 404 PHOTO_NOT_PUBLIC;幽灵 token → // 404 ALBUM_NOT_PUBLIC;幽灵照片 → 404 PHOTO_NOT_PUBLIC;variant 缺失/ // 非法 → 400 VALIDATION_FAILED; // ④ 基础限速:DOWNLOAD_WINDOW_REQUESTS 压小(惰性 env)+ 清窗键 → 窗口超额 // 429 RATE_LIMITED; // ⑤ 排除规则:全程下载调用后 visit_requests / album_stats 零行(下载不计数)。 import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; import { Readable } from 'node:stream'; import request from 'supertest'; import sharp from 'sharp'; import Redis from 'ioredis'; import { PrismaClient } from '@photo/db-cn'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; import { ErrorCodes } 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 { ContractWsAdapter } from '../src/realtime/contract-ws.adapter'; import { configureApp } from '../src/shared/configure-app'; import { PublishService } from '../../worker/src/publish.service'; import { WatermarkRegenService } from '../../worker/src/watermark.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'; /** 测试内生成真实 JPEG(无二进制入库)。 */ 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(); } interface SeededAlbum { albumId: string; token: string; } describe('downloads (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; /** 四开关组合相册 + 水印相册(各含已发布照片;on/on 另含隐藏目标)。 */ let onOn: SeededAlbum; let onOff: SeededAlbum; let offOff: SeededAlbum; let offOn: SeededAlbum; let watermarked: SeededAlbum; let onOnPhoto: string; let onOnHidden: string; let onOffPhoto: string; let offOffPhoto: string; let offOnPhoto: string; let wmPhoto: string; /** 上传原图字节(original 下载对照基准)。 */ let onOnOriginal: Buffer; /** 排空发送器:循环 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 仍有未投递事件'); } /** Redis ov 清限速窗键(跨 spec 残留 / 测试间重置计数)。 */ async function clearDownloadWindowKeys(): Promise { const redis = new Redis( process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381', ); try { let cursor = '0'; do { const [next, keys] = await redis.scan( cursor, 'MATCH', 'download:window:*', 'COUNT', 1000, ); cursor = next; if (keys.length > 0) { await redis.del(...keys); } } while (cursor !== '0'); } finally { await redis.quit(); } } /** cn 建相册(缺省开关 true/false)→ 可选 PATCH 开关 → 返回 {albumId, token}。 */ async function createAlbum( title: string, switches?: { downloadEnabled?: boolean; originalDownloadEnabled?: boolean }, ): Promise { const album = await request(cnBase) .post('/v1/albums') .set('Authorization', `Bearer ${adminToken}`) .send({ translations: [{ locale: 'zh-CN', title }], defaultLocale: 'zh-CN', startAt: '2026-12-01T10:00:00.000Z', }); expect(album.status).toBe(201); const seeded = { albumId: album.body.id as string, token: album.body.publicToken as string, }; if (switches !== undefined) { // 开关经 cn PATCH(album.updated 事件 → ov 副本;比直改副本行诚实)。 const patch = await request(cnBase) .patch(`/v1/albums/${seeded.albumId}`) .set('Authorization', `Bearer ${adminToken}`) .send(switches); expect(patch.status).toBe(200); } return seeded; } /** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */ async function uploadAndPublish( albumId: string, clientTaskId: string, jpeg: Buffer, ): Promise { const init = await request(cnBase) .post('/v1/uploads') .set('Authorization', `Bearer ${adminToken}`) .send({ albumId, clientTaskId, filename: 'download.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 }; }; 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)。 */ function download( album: SeededAlbum, photoId: string, variant: string, ): Promise { return request(ovBase).get( `/v1/public/albums/${album.token}/photos/${photoId}/download?variant=${variant}`, ); } /** cn browse 变体对象字节(browse 下载对照基准)。 */ async function browseObjectOf(photoId: string): Promise { const row = await prismaCn.photo_variants.findUniqueOrThrow({ where: { photoId_variant: { photoId, variant: 'browse' } }, }); return readObject(row.objectKey); } /** 流式读取对象为单缓冲(与 worker readOriginal 同约定)。 */ async function readObject(key: string): Promise { const stream = (await storage.getStream(key)) as Readable; const chunks: Buffer[] = []; for await (const chunk of stream) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array)); } return Buffer.concat(chunks); } beforeAll(async () => { expect(SYNC_KEY).not.toBe(''); await resetDb(); await resetOvDb(); await clearDownloadWindowKeys(); cnApp = await NestFactory.create(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); cnBase = await cnApp.getUrl(); ovApp = await NestFactory.create(AppOvModule, { rawBody: true, }); // main-ov 同形:契约帧适配器(WS 与 HTTP 同端口;本 spec 不用 WS,仅为 // 与生产入口同形的装配)。 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; // —— 开关矩阵四相册(缺省 true/false;PATCH 显式设定目标组合)—— onOn = await createAlbum('下载开原图开', { originalDownloadEnabled: true, }); onOff = await createAlbum('下载开原图关'); offOff = await createAlbum('下载关原图关', { downloadEnabled: false, originalDownloadEnabled: false, }); offOn = await createAlbum('下载关原图开', { downloadEnabled: false, originalDownloadEnabled: true, }); onOnOriginal = await makeJpeg(960, 640, { r: 190, g: 90, b: 40 }); onOnPhoto = await uploadAndPublish(onOn.albumId, 'dl-onon', onOnOriginal); onOnHidden = await uploadAndPublish( onOn.albumId, 'dl-onon-hidden', await makeJpeg(800, 500, { r: 90, g: 190, b: 120 }), ); onOffPhoto = await uploadAndPublish( onOff.albumId, 'dl-onoff', await makeJpeg(900, 600, { r: 60, g: 120, b: 190 }), ); offOffPhoto = await uploadAndPublish( offOff.albumId, 'dl-offoff', await makeJpeg(700, 450, { r: 120, g: 60, b: 190 }), ); offOnPhoto = await uploadAndPublish( offOn.albumId, 'dl-offon', await makeJpeg(850, 550, { r: 60, g: 190, b: 190 }), ); // 隐藏目标:cn 隐藏 → photo.hidden(高优先级)→ ov 撤下。 const hide = await request(cnBase) .patch(`/v1/photos/${onOnHidden}`) .set('Authorization', `Bearer ${adminToken}`) .send({ visibility: 'hidden' }); expect(hide.status).toBe(200); // —— 水印相册:POST watermark-jobs(enabled)→ 直驱 done → ov 有效位 —— watermarked = await createAlbum('下载水印相册'); wmPhoto = await uploadAndPublish( watermarked.albumId, 'dl-wm', await makeJpeg(900, 600, { r: 150, g: 150, b: 60 }), ); const wmJob = await request(cnBase) .post(`/v1/albums/${watermarked.albumId}/watermark-jobs`) .set('Authorization', `Bearer ${adminToken}`) .send({ enabled: true, text: { content: '© Download e2e' } }); expect(wmJob.status).toBe(202); expect( await new WatermarkRegenService(prismaCn, storage).processJob( wmJob.body.jobId as string, ), ).toBe('done'); await drainSender(); }); afterAll(async () => { delete process.env.DOWNLOAD_WINDOW_REQUESTS; await ovApp.close(); await cnApp.close(); await prismaCn.$disconnect(); await prismaOv.$disconnect(); }); it('① on/on:双 variant 200、variant/expiresAt 正确、URL 真实字节(original == 上传原图)', async () => { const browse = await download(onOn, onOnPhoto, 'browse'); expect(browse.status).toBe(200); expect(browse.body.variant).toBe('browse'); expect(Date.parse(browse.body.expiresAt)).toBeGreaterThan(Date.now()); // browse 键 = 内联 browse 键(无水印相册)且字节 == browse 变体对象。 expect(browse.body.url).toContain( `/variants/${onOnPhoto}/1/browse.jpg`, ); const browseBytes = Buffer.from( await (await fetch(browse.body.url)).arrayBuffer(), ); expect(browseBytes.length).toBeGreaterThan(0); expect(browseBytes.equals(await browseObjectOf(onOnPhoto))).toBe(true); const original = await download(onOn, onOnPhoto, 'original'); expect(original.status).toBe(200); expect(original.body.variant).toBe('original'); expect(Date.parse(original.body.expiresAt)).toBeGreaterThan(Date.now()); expect(original.body.url).toContain(`/originals/${onOnPhoto}.jpg`); // A20 语义的下载面:原图零触碰——字节与上传时完全一致。 const originalBytes = Buffer.from( await (await fetch(original.body.url)).arrayBuffer(), ); expect(originalBytes.equals(onOnOriginal)).toBe(true); }); it('② on/off:browse 200;original 403 DOWNLOAD_DISABLED', async () => { const browse = await download(onOff, onOffPhoto, 'browse'); expect(browse.status).toBe(200); expect(browse.body.variant).toBe('browse'); expect((await fetch(browse.body.url)).ok).toBe(true); const original = await download(onOff, onOffPhoto, 'original'); expect(original.status).toBe(403); expect(original.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED); }); it('③ off/off 与 off/on(级联):双 variant 均 403 DOWNLOAD_DISABLED', async () => { for (const album of [offOff, offOn]) { const photoId = album === offOff ? offOffPhoto : offOnPhoto; const browse = await download(album, photoId, 'browse'); expect(browse.status).toBe(403); expect(browse.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED); // 级联:downloadEnabled 关 → original 无旁路(即使 originalDownloadEnabled 开)。 const original = await download(album, photoId, 'original'); expect(original.status).toBe(403); expect(original.body.code).toBe(ErrorCodes.DOWNLOAD_DISABLED); } // ov 副本开关确已同步到位(矩阵前提非巧合)。 const ovAlbum = await prismaOv.albums_ov.findUniqueOrThrow({ where: { albumId: offOn.albumId }, }); expect(ovAlbum.downloadEnabled).toBe(false); expect(ovAlbum.originalDownloadEnabled).toBe(true); }); it('④ 水印相册:browse 下载 = 模式键 watermark.jpg,字节 == 水印变体对象', async () => { const res = await download(watermarked, wmPhoto, 'browse'); expect(res.status).toBe(200); expect(res.body.url).toContain( `/variants/${wmPhoto}/1/watermark.jpg`, ); const bytes = Buffer.from( await (await fetch(res.body.url)).arrayBuffer(), ); const wmRow = await prismaCn.photo_variants.findUniqueOrThrow({ where: { photoId_variant: { photoId: wmPhoto, variant: 'watermark' } }, }); expect(bytes.equals(await readObject(wmRow.objectKey))).toBe(true); // 与公开列表 browse 同键(同一 ovBrowseKey 推导的间接证明)。 const list = await request(ovBase) .get(`/v1/public/albums/${watermarked.token}/photos?limit=10`); expect(list.body.items[0].browse.url).toContain( `/variants/${wmPhoto}/1/watermark.jpg`, ); }); it('⑤ 可见性与参数校验:隐藏 404 / 幽灵 token 404 / 幽灵照片 404 / variant 非法 400', async () => { const hidden = await download(onOn, onOnHidden, 'browse'); expect(hidden.status).toBe(404); expect(hidden.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); const ghostAlbum = await request(ovBase).get( `/v1/public/albums/${'g' + '0'.repeat(23)}/photos/${onOnPhoto}/download?variant=browse`, ); expect(ghostAlbum.status).toBe(404); expect(ghostAlbum.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC); const ghostPhoto = await download(onOn, 'p-ghost', 'browse'); expect(ghostPhoto.status).toBe(404); expect(ghostPhoto.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); for (const query of ['', '?variant=', '?variant=thumb', '?variant=1']) { const res = await request(ovBase).get( `/v1/public/albums/${onOn.token}/photos/${onOnPhoto}/download${query}`, ); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } }); it('⑥ 基础限速:同 IP 窗口超额 → 429 RATE_LIMITED', async () => { // 清窗键重置计数(①-⑤ 已消耗本机 IP 预算)+ 压小上限。 await clearDownloadWindowKeys(); process.env.DOWNLOAD_WINDOW_REQUESTS = '3'; try { for (let i = 0; i < 3; i++) { const ok = await download(onOn, onOnPhoto, 'browse'); expect(ok.status).toBe(200); } const limited = await download(onOn, onOnPhoto, 'browse'); expect(limited.status).toBe(429); expect(limited.body.code).toBe(ErrorCodes.RATE_LIMITED); // 拒绝不改写已签发凭证语义:前序 URL 仍可取回(限速只挡新签发)。 } finally { delete process.env.DOWNLOAD_WINDOW_REQUESTS; await clearDownloadWindowKeys(); } }); it('⑦ 排除规则:下载不计数——visit_requests / album_stats 全程零行', async () => { expect(await prismaOv.visit_requests.count()).toBe(0); expect(await prismaOv.album_stats.count()).toBe(0); }); });