// services/api/test/uploads.e2e-spec.ts —— Task 18/19 上传域 e2e(先写后实现)。 // 认证同 Task 15:Prisma 直种 sessions 行换 Bearer 令牌;相册/成员 fixture 经 // Prisma 直写。真实 MinIO(compose photo-live-minio-1):presign PUT 实际写入、 // head 核验、copyIfAbsent 受控复制;真实 Redis(BullMQ photo-process 队列 peek)。 // 覆盖(A03 幂等 / A02 逐次权限): // init:成员初始化→201 有效凭证(PUT 实写 MinIO head 可见)+ DB 双行 // (photos.objectKey=正式 originals 键;upload_sessions.objectKey=staging 键); // 同 clientTaskId 同参→201 复用同一 uploadId+photoId(不重复建行); // 同 clientTaskId 异参(album/bytes/contentType/扩展名)→409 UPLOAD_SESSION_CONFLICT; // 不同 clientTaskId 同名文件→两会话两照片(同名不覆盖); // 非成员(含他册成员)→404 ALBUM_NOT_FOUND;uploadEnabled=false→409 // (owner 亦拒);超限/类型/缺字段/坏扩展名/异册 categoryId→400; // init 不落 audit(高频低风险;审计在 complete)。 // renew/complete/history(Task 19,见下方 describe)。 import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { PrismaClient } from '@photo/db-cn'; import { ErrorCodes } from '@photo/contracts'; import { AppCnModule } from '../src/app-cn.module'; import { hashSessionToken } from '../src/auth/session.guard'; import { API_PREFIX } from '../src/shared/constants'; import { resetDb } from './reset'; /** 契约 UploadInitResponse 字段全集(形状精确断言用)。 */ const INIT_KEYS = ['uploadId', 'photoId', 'objectKey', 'credential'].sort(); const CREDENTIAL_KEYS = ['mode', 'url', 'method', 'headers', 'expiresAt'].sort(); /** 测试隔离边界内的对象键根(与 reset.ts 的 MinIO 清理前缀一致)。 */ const KEY_ROOT = 'photo-live/dev'; describe('uploads init (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let ownerId: string; let memberId: string; /** 仅属于 otherAlbum 的成员(「他册成员对本册不可见」用例)。 */ let otherMemberId: string; let outsiderId: string; let ownerToken: string; let memberToken: string; let otherMemberToken: string; let outsiderToken: string; /** T1 幂等锚会话 id(首个用例捕获,后续用例复用断言)。 */ let t1UploadId: string; let albumId: string; let otherAlbumId: string; /** album 内合法分类(Prisma 直建)。 */ let categoryId: string; /** otherAlbum 的分类(跨册 400 用例)。 */ let foreignCategoryId: string; const postInit = (token: string, body: object) => request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads`) .set('Authorization', `Bearer ${token}`) .send(body); const initBody = (overrides: Partial> = {}) => ({ albumId, clientTaskId: 'T1', filename: 'a.jpg', bytes: 1000, contentType: 'image/jpeg', ...overrides, }); beforeAll(async () => { await resetDb(); const moduleRef = await Test.createTestingModule({ imports: [AppCnModule], }).compile(); app = moduleRef.createNestApplication(); app.setGlobalPrefix(API_PREFIX); await app.init(); prisma = new PrismaClient(); const mkUser = (account: string) => prisma.users.create({ data: { account, passwordHash: 'x', displayName: account }, select: { id: true }, }); ownerId = (await mkUser('up-owner')).id; memberId = (await mkUser('up-member')).id; otherMemberId = (await mkUser('up-other-member')).id; outsiderId = (await mkUser('up-outsider')).id; const mkToken = async (userId: string): Promise => { const token = randomBytes(32).toString('hex'); await prisma.sessions.create({ data: { userId, tokenHash: hashSessionToken(token), expiresAt: new Date(Date.now() + 3_600_000), }, }); return token; }; ownerToken = await mkToken(ownerId); memberToken = await mkToken(memberId); otherMemberToken = await mkToken(otherMemberId); outsiderToken = await mkToken(outsiderId); // 相册 fixture:owner 建 A(API 建册自动落 owner 成员行),member 为协作者; // 相册 B 属 owner(Prisma 直建 + owner 成员行),member 亦加入 B(跨册幂等 // 冲突用例需要同一用户在两册均可见),otherMember 仅在 B(他册成员 404 用例)。 const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${ownerToken}`) .send({ translations: [ { locale: 'zh-CN', title: '上传相册' }, { locale: 'en', title: 'Uploads' }, ], defaultLocale: 'zh-CN', startAt: '2026-12-01T02:00:00.000Z', }); expect(res.status).toBe(201); albumId = res.body.id as string; await prisma.album_members.create({ data: { albumId, userId: memberId, role: 'collaborator' }, }); const other = await prisma.albums.create({ data: { publicToken: 'otheralbum0000000000000a', ownerId, startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true }, }); otherAlbumId = other.id; await prisma.album_members.createMany({ data: [ { albumId: otherAlbumId, userId: ownerId, role: 'owner' }, { albumId: otherAlbumId, userId: memberId, role: 'collaborator' }, { albumId: otherAlbumId, userId: otherMemberId, role: 'collaborator' }, ], }); categoryId = ( await prisma.categories.create({ data: { albumId, sortOrder: 0 }, select: { id: true }, }) ).id; await prisma.category_translations.create({ data: { categoryId, locale: 'zh-CN', name: '默认' }, }); foreignCategoryId = ( await prisma.categories.create({ data: { albumId: otherAlbumId, sortOrder: 0 }, select: { id: true }, }) ).id; }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); it('成员初始化 → 201;凭证可实写 MinIO;DB:staging 会话行 + 正式键 photos 行', async () => { const res = await postInit(memberToken, initBody()); expect(res.status).toBe(201); expect(Object.keys(res.body).sort()).toEqual(INIT_KEYS); expect(Object.keys(res.body.credential).sort()).toEqual(CREDENTIAL_KEYS); expect(res.body.credential).toMatchObject({ mode: 'presigned', method: 'PUT', headers: { 'Content-Type': 'image/jpeg' }, }); expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan( Date.now(), ); const { uploadId, photoId, objectKey } = res.body as { uploadId: string; photoId: string; objectKey: string; }; t1UploadId = uploadId; expect(objectKey).toBe(`${KEY_ROOT}/staging/${uploadId}/${photoId}.jpg`); // 凭证真实可用:按凭证 PUT 后 head 可见精确字节数(fetch 全局可用,Node 22)。 const body = Buffer.alloc(1000, 1); const put = await fetch(res.body.credential.url, { method: res.body.credential.method, headers: res.body.credential.headers, body: new Uint8Array(body), }); expect(put.ok).toBe(true); // DB:会话行(pending、expiresAt≈now+SIGNED_URL_TTL_SECONDS)。 const ttl = Number.parseInt( process.env.SIGNED_URL_TTL_SECONDS ?? '900', 10, ); const session = await prisma.upload_sessions.findUnique({ where: { id: uploadId }, }); expect(session).toMatchObject({ userId: memberId, albumId, clientTaskId: 'T1', photoId, objectKey, expectedBytes: 1000, contentType: 'image/jpeg', state: 'pending', }); const drift = (session?.expiresAt.getTime() ?? Number.NaN) - (Date.now() + ttl * 1000); expect(Math.abs(drift)).toBeLessThan(10_000); // DB:photos 行——objectKey 是 FINAL originals 键(init 即写入),未发布待传。 const photo = await prisma.photos.findUnique({ where: { id: photoId } }); expect(photo).toMatchObject({ albumId, uploaderId: memberId, categoryId: null, objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`, bytes: 0, processingState: 'pending_upload', visibility: 'unpublished', }); }); it('同 clientTaskId 同参重发 → 201 复用同一 uploadId+photoId+objectKey(A03,无第二行)', async () => { const first = await postInit(memberToken, initBody()); expect(first.status).toBe(201); const again = await postInit(memberToken, initBody()); expect(again.status).toBe(201); expect(again.body.uploadId).toBe(first.body.uploadId); expect(again.body.photoId).toBe(first.body.photoId); expect(again.body.objectKey).toBe(first.body.objectKey); expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(1); expect(await prisma.photos.count({ where: { albumId } })).toBe(1); }); it('同 clientTaskId 异参(album/bytes/contentType/扩展名)→ 409 UPLOAD_SESSION_CONFLICT', async () => { // T1 锚定 albumId/1000/image/jpeg/a.jpg(前述用例)。 const albumConflict = await postInit( memberToken, initBody({ albumId: otherAlbumId }), ); expect(albumConflict.status).toBe(409); expect(albumConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT); const bytesConflict = await postInit(memberToken, initBody({ bytes: 2000 })); expect(bytesConflict.status).toBe(409); expect(bytesConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT); const typeConflict = await postInit( memberToken, initBody({ filename: 'a.png', contentType: 'image/png', bytes: 2000 }), ); expect(typeConflict.status).toBe(409); // filename 未持久化(schema 无该列):以扩展名近似锚定——同 clientTaskId 换 // 扩展名视为参数漂移 → 409。 const extConflict = await postInit( memberToken, initBody({ filename: 'b.png' }), ); expect(extConflict.status).toBe(409); expect(extConflict.body.code).toBe(ErrorCodes.UPLOAD_SESSION_CONFLICT); // 对应地:同扩展名改名(a2.jpg)不构成漂移 → 仍复用原会话(幂等意图)。 const rename = await postInit(memberToken, initBody({ filename: 'a2.jpg' })); expect(rename.status).toBe(201); expect(rename.body.uploadId).toBe(t1UploadId); expect(rename.body.photoId).toBe( (await prisma.upload_sessions.findUnique({ where: { id: t1UploadId } })) ?.photoId, ); }); it('不同 clientTaskId 同名文件 → 两会话两照片(同名不覆盖,A03)', async () => { const one = await postInit(memberToken, initBody({ clientTaskId: 'same-name-1' })); const two = await postInit(memberToken, initBody({ clientTaskId: 'same-name-2' })); expect(one.status).toBe(201); expect(two.status).toBe(201); expect(one.body.uploadId).not.toBe(two.body.uploadId); expect(one.body.photoId).not.toBe(two.body.photoId); expect(one.body.objectKey).not.toBe(two.body.objectKey); expect(await prisma.upload_sessions.count({ where: { userId: memberId } })).toBe(3); expect(await prisma.photos.count({ where: { albumId } })).toBe(3); }); it('非成员 → 404 ALBUM_NOT_FOUND(他册成员同)', async () => { const res = await postInit(outsiderToken, initBody({ clientTaskId: 'T-out' })); expect(res.status).toBe(404); expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); // otherMember(B 册成员)对 A 不可见 → 同码 404。 const foreign = await request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads`) .set('Authorization', `Bearer ${otherMemberToken}`) .send(initBody({ clientTaskId: 'T-foreign' })); expect(foreign.status).toBe(404); expect(foreign.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); }); it('uploadEnabled=false → 409 ALBUM_UPLOAD_DISABLED(owner 亦拒)', async () => { await prisma.albums.update({ where: { id: albumId }, data: { uploadEnabled: false }, }); try { const res = await postInit(ownerToken, initBody({ clientTaskId: 'T-off' })); expect(res.status).toBe(409); expect(res.body.code).toBe(ErrorCodes.ALBUM_UPLOAD_DISABLED); // 未创建任何行(开关是相册级运营状态,先于参数与建行)。 expect( await prisma.upload_sessions.count({ where: { clientTaskId: 'T-off' } }), ).toBe(0); } finally { await prisma.albums.update({ where: { id: albumId }, data: { uploadEnabled: true }, }); } }); it('bytes 超 UPLOAD_MAX_BYTES(env 临时下调,逐请求惰性读取)→ 400', async () => { const previous = process.env.UPLOAD_MAX_BYTES; process.env.UPLOAD_MAX_BYTES = '500'; try { const res = await postInit(memberToken, initBody({ clientTaskId: 'T-big' })); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 边界内(=500)通过。 const edge = await postInit( memberToken, initBody({ clientTaskId: 'T-edge', bytes: 500 }), ); expect(edge.status).toBe(201); } finally { if (previous === undefined) { delete process.env.UPLOAD_MAX_BYTES; } else { process.env.UPLOAD_MAX_BYTES = previous; } } }); it('contentType 白名单外 / 缺字段 / bytes<1 / 坏扩展名 → 400 VALIDATION_FAILED', async () => { const badType = await postInit( memberToken, initBody({ clientTaskId: 'T-plain', contentType: 'text/plain' }), ); expect(badType.status).toBe(400); expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const missing = await postInit(memberToken, { albumId, clientTaskId: 'T-missing', bytes: 100, contentType: 'image/jpeg', }); expect(missing.status).toBe(400); expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const zeroBytes = await postInit( memberToken, initBody({ clientTaskId: 'T-zero', bytes: 0 }), ); expect(zeroBytes.status).toBe(400); const badExt = await postInit( memberToken, initBody({ clientTaskId: 'T-ext', filename: 'virus.exe' }), ); expect(badExt.status).toBe(400); expect(badExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('categoryId:未知/异册 → 400;本册合法 → 201 且 photos.categoryId 落库', async () => { const unknown = await postInit( memberToken, initBody({ clientTaskId: 'T-cat-unknown', categoryId: 'no-such-category' }), ); expect(unknown.status).toBe(400); expect(unknown.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const foreign = await postInit( memberToken, initBody({ clientTaskId: 'T-cat-foreign', categoryId: foreignCategoryId }), ); expect(foreign.status).toBe(400); expect(foreign.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const ok = await postInit( memberToken, initBody({ clientTaskId: 'T-cat-ok', categoryId }), ); expect(ok.status).toBe(201); const photo = await prisma.photos.findUnique({ where: { id: ok.body.photoId }, }); expect(photo?.categoryId).toBe(categoryId); }); it('init 不落 audit(高频低风险;审计在 complete,A06 前置约定)', async () => { const audits = await prisma.audit_logs.count({ where: { action: { startsWith: 'upload.' } }, }); expect(audits).toBe(0); }); it('未认证 → 401', async () => { const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads`) .send(initBody({ clientTaskId: 'T-anon' })); expect(res.status).toBe(401); expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); });