// services/api/test/e2e.full-chain.e2e-spec.ts —— Task 25 全链路 API E2E // (A01/A02/A03/A06 验收证据)。ONE spec 串完整业务流,且流程只走公开 API: // seed-admin → admin 密码登录 → 建相册(zh+en)+分类 → 邀请 staffB → // staffB 登录 → 同名文件两次上传(不同 clientTaskId,A03)→ 双 presign PUT // 实写 MinIO → complete(其一调两次,幂等)→ 真实 BullMQ Worker 消费 // (services/worker src 的 createPhotoProcessWorker,同 worker publish e2e // bootstrap)→ 双照片 ready+published、outbox 恰 2 条 photo.published(无第三 // 条)→ 公开 API(en 精确命中/回退、列表、limit=1 游标翻页无重复、签名 URL // 取回真实字节、单图)→ staffB 隐藏 photo1 → owner 移除 staffB(A02:被移除 // 成员立即失去上传/管理链)→ admin 软删除 photo2 → 空列表 → audit 全程可查。 // Prisma 仅用于种子(staffB 账号)与断言;不用于流程本身。真实 MySQL + Redis // (BullMQ)+ MinIO(staging/originals/variants 对象)。 import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import sharp from 'sharp'; import IORedis from 'ioredis'; import { PrismaClient } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { createStorageFromEnv, type MinioStorageAdapter, } from '@photo/storage'; import { AppCnModule } from '../src/app-cn.module'; import { hashPassword } from '../src/auth/password'; import { seedAdmin } from '../src/scripts/seed-admin'; import { API_PREFIX } from '../src/shared/constants'; import { createPhotoProcessWorker, } from '../../worker/src/process.processor'; import { PublishService, variantsObjectKey, } from '../../worker/src/publish.service'; import { resetDb } from './reset'; jest.setTimeout(180_000); const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me'; const STAFF_ACCOUNT = 'chain-staffb'; const STAFF_PASSWORD = 'staffb-secret-1'; /** 轮询等待:真实 Worker 异步消费的前置(同 worker publish e2e)。 */ async function waitFor( check: () => Promise, timeoutMs = 60_000, stepMs = 100, ): Promise { const deadline = Date.now() + timeoutMs; for (;;) { if (await check()) return; if (Date.now() > deadline) { throw new Error(`waitFor 超时(${timeoutMs}ms)`); } await new Promise((resolve) => setTimeout(resolve, stepMs)); } } /** 测试内生成真实 JPEG(900px,同任务要求;无二进制入库)。 */ 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(); } describe('full-chain (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let storage: MinioStorageAdapter; let adminToken: string; let staffBId: string; let staffBToken: string; let albumId: string; let publicToken: string; let categoryId: string; /** 同名两次上传的两张照片(uploadA=complete 调两次的那张)。 */ let uploadA: { uploadId: string; photoId: string; objectKey: string }; let uploadB: { uploadId: string; photoId: string; objectKey: string }; let jpegA: Buffer; let jpegB: Buffer; const login = (account: string, password: string) => request(app.getHttpServer()) .post(`/${API_PREFIX}/auth/password`) .send({ account, password }); beforeAll(async () => { expect(ADMIN_ACCOUNT).not.toBe(''); await resetDb(); const moduleRef = await Test.createTestingModule({ imports: [AppCnModule], }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix(API_PREFIX); await app.init(); prisma = new PrismaClient(); storage = createStorageFromEnv(); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); it('seed-admin → admin 真实密码登录(A01)', async () => { const seeded = await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD); expect(seeded.created).toBe(true); // 幂等:重跑跳过且不改写密码(seed.spec 已单测,此处仅顺带证据)。 expect(await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD)).toEqual({ created: false, }); const res = await login(ADMIN_ACCOUNT, ADMIN_PASSWORD); expect(res.status).toBe(200); expect(res.body.sessionToken).toMatch(/^[0-9a-f]{64}$/); adminToken = res.body.sessionToken as string; const admin = await prisma.users.findUniqueOrThrow({ where: { account: ADMIN_ACCOUNT }, }); expect(admin.role).toBe('admin'); // 登录审计(A01 证据之一)。 expect( await prisma.audit_logs.count({ where: { action: 'auth.login', actorId: admin.id }, }), ).toBe(1); }); it('admin 建相册(zh-CN+en)+ 分类 + 经成员 API 邀请 staffB', async () => { // staffB 账号:Prisma 直种(邀请端点按 userId 关联,账号查询接口属 P1b)。 staffBId = ( await prisma.users.create({ data: { account: STAFF_ACCOUNT, passwordHash: await hashPassword(STAFF_PASSWORD), displayName: 'staffB', role: 'staff', credentialStatus: 'active', }, select: { id: true }, }) ).id; const album = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${adminToken}`) .send({ translations: [ { locale: 'zh-CN', title: '全链路晚会', description: '端到端验收' }, { locale: 'en', title: 'Full Chain 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; expect(publicToken).toMatch(/^[0-9a-z]{20,32}$/); const category = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/categories`) .set('Authorization', `Bearer ${adminToken}`) .send({ sortOrder: 0, translations: [ { locale: 'zh-CN', name: '主舞台' }, { locale: 'en', name: 'Main Stage' }, ], }); expect(category.status).toBe(201); categoryId = category.body.id as string; const invite = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/members`) .set('Authorization', `Bearer ${adminToken}`) .send({ userId: staffBId, role: 'collaborator' }); expect(invite.status).toBe(201); expect(invite.body).toMatchObject({ userId: staffBId, role: 'collaborator', status: 'active', }); }); it('staffB 登录 → 同名文件两次上传 → 双 presign PUT 实写 → complete(其一调两次,A03)', async () => { const res = await login(STAFF_ACCOUNT, STAFF_PASSWORD); expect(res.status).toBe(200); staffBToken = res.body.sessionToken as string; jpegA = await makeJpeg(900, 600, { r: 180, g: 120, b: 60 }); jpegB = await makeJpeg(900, 600, { r: 60, g: 130, b: 180 }); const initAs = async (clientTaskId: string, bytes: number) => { const init = await request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads`) .set('Authorization', `Bearer ${staffBToken}`) .send({ albumId, clientTaskId, filename: 'show.jpg', // 同名文件(A03:同名不覆盖) bytes, contentType: 'image/jpeg', categoryId, }); expect(init.status).toBe(201); return init.body as { uploadId: string; photoId: string; objectKey: string; credential: { url: string; method: string; headers: Record }; }; }; const one = await initAs('chain-a', jpegA.length); const two = await initAs('chain-b', jpegB.length); // 同名不同 clientTaskId:两会话两照片,正式键互异(同名不覆盖)。 expect(one.uploadId).not.toBe(two.uploadId); expect(one.photoId).not.toBe(two.photoId); expect(one.objectKey).not.toBe(two.objectKey); uploadA = one; uploadB = two; expect(await prisma.photos.count({ where: { albumId } })).toBe(2); expect( await prisma.upload_sessions.count({ where: { userId: staffBId } }), ).toBe(2); // 两个 presign PUT:真实 JPEG 字节写入 MinIO staging。 for (const [init, jpeg] of [ [one, jpegA], [two, jpegB], ] as const) { const put = await fetch(init.credential.url, { method: init.credential.method, headers: init.credential.headers, body: new Uint8Array(jpeg), }); expect(put.ok).toBe(true); } const complete = (uploadId: string) => request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads/${uploadId}/complete`) .set('Authorization', `Bearer ${staffBToken}`); const done = await complete(one.uploadId); expect(done.status).toBe(200); expect(done.body).toEqual({ photoId: one.photoId, processingState: 'uploaded' }); // 幂等:同一会话 complete 调两次 → 同结果,不产生第二个 job/audit(A03)。 const again = await complete(one.uploadId); expect(again.status).toBe(200); expect(again.body).toEqual(done.body); const doneB = await complete(two.uploadId); expect(doneB.status).toBe(200); expect(doneB.body).toEqual({ photoId: two.photoId, processingState: 'uploaded' }); // DB:受控复制后 originals 已在,job 各恰一条 pending。 for (const { photoId } of [one, two]) { expect( await prisma.photo_jobs.count({ where: { photoId, type: 'process' } }), ).toBe(1); const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId } }); expect(photo.processingState).toBe('uploaded'); expect(photo.visibility).toBe('unpublished'); expect( await storage.head(photo.objectKey), ).toMatchObject({ bytes: photoId === one.photoId ? jpegA.length : jpegB.length }); } }); it('真实 Worker 消费 → 双照片 ready+published;outbox 恰 2 条 photo.published(无第三条,A03/A06);MinIO 变体在', async () => { // API complete 已把两张照片的 job 入 photo-process 队列;起真实 BullMQ // Worker(worker src,同 publish.e2e bootstrap)消费之——这是「上传即公开」 // 的 A06 全链路证据。 const workerConnection = new IORedis( process.env.REDIS_URL_CN ?? 'redis://localhost:6379', { maxRetriesPerRequest: null }, ); workerConnection.on('error', () => undefined); const worker = createPhotoProcessWorker({ prisma, storage, connection: workerConnection, }); try { await waitFor( async () => ( await prisma.photos.findMany({ where: { id: { in: [uploadA.photoId, uploadB.photoId] } }, select: { processingState: true }, }) ).every((p) => p.processingState === 'ready'), ); for (const { photoId } of [uploadA, uploadB]) { const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId }, }); expect(photo).toMatchObject({ processingState: 'ready', visibility: 'published', entityVersion: 2, width: 900, height: 600, }); expect(photo.publishedAt).not.toBeNull(); // job 闭环 done(attempt=1)。 const job = await prisma.photo_jobs.findUniqueOrThrow({ where: { photoId_type: { photoId, type: 'process' } }, }); expect(job).toMatchObject({ state: 'done', attempt: 1 }); // 变体行 + MinIO 真实变体对象(键布局取自 worker 生产实现)。 expect( await prisma.photo_variants.count({ where: { photoId } }), ).toBe(2); for (const variant of ['thumb', 'browse'] as const) { const head = await storage.head( variantsObjectKey(albumId, photoId, 1, variant), ); expect(head).not.toBeNull(); expect(head?.contentType).toBe('image/jpeg'); expect(head?.bytes).toBeGreaterThan(0); } } // A03 心脏断言:全库恰 2 条 photo.published(每照片一条,双 complete // 不多、同名两传不丢)。 expect( await prisma.outbox_events.count({ where: { type: 'photo.published' } }), ).toBe(2); expect( await prisma.outbox_events.count({ where: { photoId: uploadA.photoId } }), ).toBe(1); expect( await prisma.outbox_events.count({ where: { photoId: uploadB.photoId } }), ).toBe(1); } finally { await worker.close(); await workerConnection.quit().catch(() => undefined); } // 重复处理幂等(发布事务即幂等边界):再驱动一次 → already,无第三条事件。 const publish = new PublishService(prisma, storage); expect(await publish.processPhoto(uploadA.photoId)).toBe('already'); expect( await prisma.outbox_events.count({ where: { type: 'photo.published' } }), ).toBe(2); }); it('公开 API:en 命中/回退、列表 2 张、limit=1 翻页无重复、thumb 真实字节、单图 200', async () => { const albumInfo = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}`) .query({ locale: 'en' }); expect(albumInfo.status).toBe(200); expect(albumInfo.body.resolvedLocale).toBe('en'); expect(albumInfo.body.translation).toMatchObject({ locale: 'en', title: 'Full Chain Gala', }); // 分类同语言解析。 expect(albumInfo.body.categories[0]).toMatchObject({ id: categoryId, name: 'Main Stage', }); // 缺省 locale → defaultLocale(zh-CN)。 const zhInfo = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}`); expect(zhInfo.body.resolvedLocale).toBe('zh-CN'); expect(zhInfo.body.translation).toMatchObject({ title: '全链路晚会' }); // 列表 2 张(均为 published)。 const list = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos`); expect(list.status).toBe(200); const both = new Set( list.body.items.map((p: { photoId: string }) => p.photoId), ); expect(both).toEqual(new Set([uploadA.photoId, uploadB.photoId])); // limit=1 游标翻页走完两页:并集恰两张、无重复。 const seen: string[] = []; let cursor: string | null = null; let pages = 0; for (;;) { const page = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos`) .query({ limit: 1, ...(cursor ? { cursor } : {}) }); expect(page.status).toBe(200); expect(page.body.items).toHaveLength(1); seen.push(page.body.items[0].photoId); pages += 1; if (!page.body.hasMore) { expect(page.body.nextCursor).toBeNull(); break; } expect(typeof page.body.nextCursor).toBe('string'); cursor = page.body.nextCursor as string; } expect(pages).toBe(2); expect(new Set(seen).size).toBe(2); expect(new Set(seen)).toEqual(both); // 条目 thumb 签名 URL 取回真实 JPEG 字节。 const thumb = list.body.items[0].thumb as { url: string; expiresAt: string }; expect(Date.parse(thumb.expiresAt)).toBeGreaterThan(Date.now()); const fetched = await fetch(thumb.url); expect(fetched.status).toBe(200); expect(fetched.headers.get('content-type')).toBe('image/jpeg'); expect(Buffer.from(await fetched.arrayBuffer()).length).toBeGreaterThan(0); // 单图深链 200。 const single = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos/${uploadB.photoId}`); expect(single.status).toBe(200); expect(single.body.photoId).toBe(uploadB.photoId); expect(single.body.categoryId).toBe(categoryId); }); it('staffB 隐藏 photo1 → 公开列表剩 1、单图 404;outbox photo.hidden', async () => { const res = await request(app.getHttpServer()) .patch(`/${API_PREFIX}/photos/${uploadA.photoId}`) .set('Authorization', `Bearer ${staffBToken}`) .send({ visibility: 'hidden' }); expect(res.status).toBe(200); expect(res.body).toMatchObject({ photoId: uploadA.photoId, visibility: 'hidden', }); expect( (await prisma.photos.findUniqueOrThrow({ where: { id: uploadA.photoId } })) .entityVersion, ).toBe(3); // published(2) → hidden(3) const list = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos`); expect(list.body.items).toHaveLength(1); expect(list.body.items[0].photoId).toBe(uploadB.photoId); const single = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos/${uploadA.photoId}`); expect(single.status).toBe(404); expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); const event = await prisma.outbox_events.findFirstOrThrow({ where: { photoId: uploadA.photoId, type: 'photo.hidden' }, }); expect(event.entityVersion).toBe(3); }); it('owner 移除 staffB → 其上传/管理链即刻失效(A02 逐次校验)', async () => { const remove = await request(app.getHttpServer()) .delete(`/${API_PREFIX}/albums/${albumId}/members/${staffBId}`) .set('Authorization', `Bearer ${adminToken}`); expect(remove.status).toBe(204); // 会话仍有效,但成员行已删:init → 404 ALBUM_NOT_FOUND(非成员同码防枚举)。 const init = await request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads`) .set('Authorization', `Bearer ${staffBToken}`) .send({ albumId, clientTaskId: 'chain-after-removal', filename: 'show.jpg', bytes: 1000, contentType: 'image/jpeg', }); expect(init.status).toBe(404); expect(init.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); expect( await prisma.upload_sessions.count({ where: { clientTaskId: 'chain-after-removal' }, }), ).toBe(0); // 历史上传亦不可再管理(上传者本人仍须是当时的有效成员)→ 404 防枚举。 const manage = await request(app.getHttpServer()) .patch(`/${API_PREFIX}/photos/${uploadA.photoId}`) .set('Authorization', `Bearer ${staffBToken}`) .send({ visibility: 'hidden' }); expect(manage.status).toBe(404); expect(manage.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND); }); it('admin 软删除 photo2 → 公开列表空、单图 404;outbox photo.deleted;行/对象保留', async () => { const res = await request(app.getHttpServer()) .delete(`/${API_PREFIX}/photos/${uploadB.photoId}`) .set('Authorization', `Bearer ${adminToken}`); expect(res.status).toBe(204); const photo = await prisma.photos.findUniqueOrThrow({ where: { id: uploadB.photoId }, }); expect(photo.visibility).toBe('deleted'); expect(photo.entityVersion).toBe(3); // published(2) → deleted(3) // 软删除:行/对象保留(物理清除属 P4a)。 expect(await storage.head(photo.objectKey)).not.toBeNull(); const list = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos`); expect(list.status).toBe(200); expect(list.body.items).toHaveLength(0); expect(list.body.hasMore).toBe(false); const single = await request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${publicToken}/photos/${uploadB.photoId}`); expect(single.status).toBe(404); expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC); const event = await prisma.outbox_events.findFirstOrThrow({ where: { photoId: uploadB.photoId, type: 'photo.deleted' }, }); expect(event.entityVersion).toBe(3); }); it('audit 全程可查:动作齐全且 actor 正确(A01/A02 证据)', async () => { const rows = (action: string) => prisma.audit_logs.findMany({ where: { action } }); const created = await rows('album.create'); expect(created).toHaveLength(1); expect(created[0]).toMatchObject({ actorId: (await prisma.users.findUniqueOrThrow({ where: { account: ADMIN_ACCOUNT } })).id, resourceId: albumId, }); const added = await rows('album.member.add'); expect(added).toHaveLength(1); expect(added[0].actorId).toBe(created[0].actorId); expect(added[0].resourceId).toBe(albumId); const removed = await rows('album.member.remove'); expect(removed).toHaveLength(1); expect(removed[0]).toMatchObject({ actorId: created[0].actorId, resourceId: albumId, }); const completes = await rows('upload.complete'); expect(completes).toHaveLength(2); // 双 complete 幂等不落第二条 for (const row of completes) { expect(row.actorId).toBe(staffBId); } expect(new Set(completes.map((r) => r.resourceId))).toEqual( new Set([uploadA.photoId, uploadB.photoId]), ); const hide = await rows('photo.hide'); expect(hide).toHaveLength(1); expect(hide[0]).toMatchObject({ actorId: staffBId, resourceId: uploadA.photoId, }); const del = await rows('photo.delete'); expect(del).toHaveLength(1); expect(del[0]).toMatchObject({ actorId: created[0].actorId, resourceId: uploadB.photoId, }); }); it('admin 收尾操作正常:幂等重复删除 204 不再发事件;成员列表已无 staffB', async () => { const again = await request(app.getHttpServer()) .delete(`/${API_PREFIX}/photos/${uploadB.photoId}`) .set('Authorization', `Bearer ${adminToken}`); expect(again.status).toBe(204); expect( await prisma.outbox_events.count({ where: { type: 'photo.deleted' } }), ).toBe(1); const members = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}/members`) .set('Authorization', `Bearer ${adminToken}`); expect(members.status).toBe(200); const ids = members.body.items.map((m: { userId: string }) => m.userId); expect(ids).not.toContain(staffBId); }); });