diff --git a/services/api/test/e2e.full-chain.e2e-spec.ts b/services/api/test/e2e.full-chain.e2e-spec.ts new file mode 100644 index 0000000..66e3d88 --- /dev/null +++ b/services/api/test/e2e.full-chain.e2e-spec.ts @@ -0,0 +1,591 @@ +// 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); + }); +});