// 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 { Queue } from 'bullmq'; 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); }); }); // ── Task 19:renew / complete / history / 队列入队 ── describe('uploads renew & complete & history (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; /** 测试侧独立 Queue 实例(同一 Redis):peek photo-process 队列;beforeAll 清空。 */ let queue: Queue; /** 自建 ioredis 连接(BullMQ 不接管所有权,close 后须自行断开,否则 jest 挂起)。 */ let queueConnection: import('ioredis').default | null = null; let ownerId: string; let memberId: string; let removedId: string; let outsiderToken: string; let ownerToken: string; let memberToken: string; let removedToken: string; let albumId: string; /** 各用例捕获的会话锚点(clientTaskId → 会话/照片 id)。 */ let r1: { uploadId: string; photoId: string; objectKey: string }; let cBytes: { uploadId: string; photoId: string }; let cHappy: { uploadId: string; photoId: string; objectKey: string }; const initAs = async ( token: string, clientTaskId: string, overrides: Record = {}, ): Promise<{ uploadId: string; photoId: string; objectKey: string; credential: { url: string; method: string; headers: Record } }> => { const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads`) .set('Authorization', `Bearer ${token}`) .send({ albumId, clientTaskId, filename: 'a.jpg', bytes: 1000, contentType: 'image/jpeg', ...overrides, }); expect(res.status).toBe(201); return res.body; }; const renew = (token: string, uploadId: string) => request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads/${uploadId}/renew`) .set('Authorization', `Bearer ${token}`); const complete = (token: string, uploadId: string) => request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads/${uploadId}/complete`) .set('Authorization', `Bearer ${token}`); const listUploads = (token: string, query?: Record) => request(app.getHttpServer()) .get(`/${API_PREFIX}/uploads`) .set('Authorization', `Bearer ${token}`) .query(query ?? {}); /** 按凭证实写 staging(覆盖默认 content-type 以构造核验失败用例)。 */ const putStaging = async ( credential: { url: string; method: string; headers: Record }, bytes: number, contentType?: string, ): Promise => { const res = await fetch(credential.url, { method: credential.method, headers: contentType === undefined ? credential.headers : { 'Content-Type': contentType }, body: new Uint8Array(Buffer.alloc(bytes, 7)), }); return res.ok; }; 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('rc-owner')).id; memberId = (await mkUser('rc-member')).id; removedId = (await mkUser('rc-removed')).id; const outsiderId = (await mkUser('rc-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); removedToken = await mkToken(removedId); outsiderToken = await mkToken(outsiderId); const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${ownerToken}`) .send({ translations: [ { locale: 'zh-CN', title: '完成核验相册' }, { locale: 'en', title: 'Complete' }, ], 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.createMany({ data: [ { albumId, userId: memberId, role: 'collaborator' }, { albumId, userId: removedId, role: 'collaborator' }, ], }); // 测试侧队列实例:与生产者同一 Redis/队列名(契约常量),先清空历史。 const { QUEUE_PHOTO_PROCESS } = await import('@photo/contracts'); const IORedis = (await import('ioredis')).default; queueConnection = new IORedis( process.env.REDIS_URL_CN ?? 'redis://localhost:6379', { maxRetriesPerRequest: null }, ); queue = new Queue(QUEUE_PHOTO_PROCESS, { connection: queueConnection }); await queue.obliterate({ force: true }); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); await queue.close(); // BullMQ 对外部传入的连接不接管生命周期:queue.close 不 disconnect 它。 await queueConnection?.quit().catch(() => undefined); }); it('renew → 200 新凭证同 objectKey;会话续期仍 pending', async () => { const init = await initAs(memberToken, 'R1'); r1 = init; const res = await renew(memberToken, init.uploadId); expect(res.status).toBe(200); expect(Object.keys(res.body).sort()).toEqual(INIT_KEYS); expect(res.body.uploadId).toBe(init.uploadId); expect(res.body.photoId).toBe(init.photoId); expect(res.body.objectKey).toBe(init.objectKey); expect(res.body.credential.method).toBe('PUT'); expect(res.body.credential.headers['Content-Type']).toBe('image/jpeg'); expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan( Date.now(), ); const session = await prisma.upload_sessions.findUnique({ where: { id: init.uploadId }, }); expect(session?.state).toBe('pending'); // renew 滑动续期会话 expiresAt(契约:凭证过期 → renew 再试)。 expect(session?.expiresAt.getTime()).toBeGreaterThan(Date.now() + 800_000); }); it('renew 守卫:uploadEnabled=false → 409 ALBUM_UPLOAD_DISABLED;非本人/未知会话/被移除成员 → 403', async () => { // 相册关闭上传:续签被拒(§4.9),owner 亦同(开关是相册级运营状态)。 await prisma.albums.update({ where: { id: albumId }, data: { uploadEnabled: false }, }); try { const disabled = await renew(memberToken, r1.uploadId); expect(disabled.status).toBe(409); expect(disabled.body.code).toBe(ErrorCodes.ALBUM_UPLOAD_DISABLED); } finally { await prisma.albums.update({ where: { id: albumId }, data: { uploadEnabled: true }, }); } // 被移除成员:先建会话(此时仍是成员),移除后 renew/complete 双拦截(A02)。 const removedSession = await initAs(removedToken, 'C-removed'); await prisma.album_members.delete({ where: { albumId_userId: { albumId, userId: removedId } }, }); const removedRenew = await renew(removedToken, removedSession.uploadId); expect(removedRenew.status).toBe(403); expect(removedRenew.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const removedComplete = await complete(removedToken, removedSession.uploadId); expect(removedComplete.status).toBe(403); expect(removedComplete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); // 非本人会话与未知会话同码 403(契约:不区分以避免枚举)。 const notOwner = await renew(ownerToken, r1.uploadId); expect(notOwner.status).toBe(403); expect(notOwner.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const unknown = await renew(memberToken, 'no-such-upload'); expect(unknown.status).toBe(403); expect(unknown.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); }); it('complete:未上传 → 400 UPLOAD_VERIFY_FAILED,会话保持 pending 可重试', async () => { const init = await initAs(memberToken, 'C-before'); const res = await complete(memberToken, init.uploadId); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); const session = await prisma.upload_sessions.findUnique({ where: { id: init.uploadId }, }); expect(session?.state).toBe('pending'); }); it('complete:字节数不符 → 400;补传正确字节数后 complete 成功(请求级失败不毒化会话)', async () => { const init = await initAs(memberToken, 'C-bytes'); cBytes = { uploadId: init.uploadId, photoId: init.photoId }; expect(await putStaging(init.credential, 500)).toBe(true); // 申明 1000 实传 500 const mismatch = await complete(memberToken, init.uploadId); expect(mismatch.status).toBe(400); expect(mismatch.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); const session = await prisma.upload_sessions.findUnique({ where: { id: init.uploadId }, }); expect(session?.state).toBe('pending'); // 重试路径:重传正确字节数(同 staging 键覆盖写)→ complete 通过。 expect(await putStaging(init.credential, 1000)).toBe(true); const retry = await complete(memberToken, init.uploadId); expect(retry.status).toBe(200); expect(retry.body.processingState).toBe('uploaded'); }); it('complete:contentType 不符(presign 不签名 content-type,服务端兜底)→ 400', async () => { // bytes=1002:C-bytes 完成后本册已有 member 的 1000 字节非 deleted 照片, // 沿用 1000 会被 init ③.5 服务端查重拦截(90dcc58),换字节数绕开。 const init = await initAs(memberToken, 'C-type', { bytes: 1002 }); // 声明 image/jpeg,实传 application/octet-stream:PUT 成功(签名不覆盖该头), // complete 时 head().contentType 与申明值比对兜底。 expect(await putStaging(init.credential, 1002, 'application/octet-stream')).toBe(true); const res = await complete(memberToken, init.uploadId); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.UPLOAD_VERIFY_FAILED); const session = await prisma.upload_sessions.findUnique({ where: { id: init.uploadId }, }); expect(session?.state).toBe('pending'); }); it('complete 成功 → 200 uploaded;photo/session/photo_jobs/audit 同事务落库;重复 complete 幂等(A03)', async () => { // bytes=1003:同上,避开 C-bytes 已完成的 1000 字节照片(init ③.5 查重)。 const init = await initAs(memberToken, 'C-happy', { bytes: 1003 }); cHappy = { uploadId: init.uploadId, photoId: init.photoId, objectKey: init.objectKey }; expect(await putStaging(init.credential, 1003)).toBe(true); const res = await complete(memberToken, init.uploadId); expect(res.status).toBe(200); expect(Object.keys(res.body).sort()).toEqual(['photoId', 'processingState']); expect(res.body).toEqual({ photoId: init.photoId, processingState: 'uploaded' }); // DB 四处一致性。 const photo = await prisma.photos.findUnique({ where: { id: init.photoId } }); expect(photo).toMatchObject({ bytes: 1003, uploadedAt: expect.any(Date), processingState: 'uploaded', visibility: 'unpublished', }); const session = await prisma.upload_sessions.findUnique({ where: { id: init.uploadId }, }); expect(session?.state).toBe('confirmed'); const jobs = await prisma.photo_jobs.findMany({ where: { photoId: init.photoId }, }); expect(jobs).toHaveLength(1); expect(jobs[0]).toMatchObject({ type: 'process', state: 'pending' }); const audits = await prisma.audit_logs.findMany({ where: { action: 'upload.complete', resourceId: init.photoId }, }); expect(audits).toHaveLength(1); expect(audits[0]?.actorId).toBe(memberId); // 幂等重复 complete:同结果,不产生第二个 job/audit。 const again = await complete(memberToken, init.uploadId); expect(again.status).toBe(200); expect(again.body).toEqual({ photoId: init.photoId, processingState: 'uploaded' }); expect( await prisma.photo_jobs.count({ where: { photoId: init.photoId } }), ).toBe(1); expect( await prisma.audit_logs.count({ where: { action: 'upload.complete', resourceId: init.photoId }, }), ).toBe(1); }); it('受控复制核验:originals 与 staging 并存且字节一致;删除 staging 后 re-complete 仍 200(copyIfAbsent created:false 路径)', async () => { const { createStorageFromEnv } = await import('@photo/storage'); const storage = createStorageFromEnv(); const photo = await prisma.photos.findUnique({ where: { id: cHappy.photoId } }); expect(photo?.objectKey).toBe( `photo-live/dev/albums/${albumId}/originals/${cHappy.photoId}.jpg`, ); const stagingHead = await storage.head(cHappy.objectKey); const finalHead = await storage.head(photo?.objectKey as string); expect(stagingHead?.bytes).toBe(1003); expect(finalHead?.bytes).toBe(1003); // 手工删除 staging(模拟生命周期清理/超时)后重复 complete:originals 已在, // copyIfAbsent 幂等返回 created:false,不再依赖 staging 源。 const { DeleteObjectCommand, S3Client } = await import('@aws-sdk/client-s3'); const s3 = new S3Client({ endpoint: process.env.S3_ENDPOINT, region: process.env.S3_REGION, credentials: { accessKeyId: process.env.S3_ACCESS_KEY as string, secretAccessKey: process.env.S3_SECRET_KEY as string, }, forcePathStyle: true, }); try { await s3.send( new DeleteObjectCommand({ Bucket: process.env.S3_BUCKET as string, Key: cHappy.objectKey, }), ); } finally { await s3.destroy(); } expect(await storage.head(cHappy.objectKey)).toBeNull(); const res = await complete(memberToken, cHappy.uploadId); expect(res.status).toBe(200); expect(res.body.processingState).toBe('uploaded'); expect((await storage.head(photo?.objectKey as string))?.bytes).toBe(1003); }); it('GET /uploads:本人会话新在前,photo 状态内嵌,游标翻页,跨用户隔离', async () => { // 回写互不相同的 createdAt,排序断言与写入时序解耦(同 members 列表先例)。 const order: Array<{ taskId: string; at: string }> = [ { taskId: 'R1', at: '2026-09-12T08:00:00.000Z' }, // 最旧 { taskId: 'C-before', at: '2026-09-12T09:00:00.000Z' }, { taskId: 'C-bytes', at: '2026-09-12T10:00:00.000Z' }, { taskId: 'C-type', at: '2026-09-12T11:00:00.000Z' }, { taskId: 'C-happy', at: '2026-09-12T12:00:00.000Z' }, // 最新 ]; for (const { taskId, at } of order) { await prisma.upload_sessions.updateMany({ where: { userId: memberId, clientTaskId: taskId }, data: { createdAt: new Date(at) }, }); } const page1 = await listUploads(memberToken, { limit: 2 }); expect(page1.status).toBe(200); expect( page1.body.items.map((i: { clientTaskId: string }) => i.clientTaskId), ).toEqual(['C-happy', 'C-type']); expect(Object.keys(page1.body.items[0]).sort()).toEqual([ 'albumId', 'clientTaskId', 'contentType', 'createdAt', 'expectedBytes', 'photo', 'sourceDeviceId', 'state', 'uploadId', ]); expect(Object.keys(page1.body.items[0].photo).sort()).toEqual([ 'photoId', 'processingState', 'visibility', ]); // confirmed 会话内嵌服务端进度;pending 会话保持 pending_upload/unpublished。 expect(page1.body.items[0]).toMatchObject({ state: 'confirmed', albumId, expectedBytes: 1003, contentType: 'image/jpeg', photo: { photoId: cHappy.photoId, processingState: 'uploaded', visibility: 'unpublished', }, }); expect(page1.body.items[1]).toMatchObject({ state: 'pending', photo: { processingState: 'pending_upload', visibility: 'unpublished' }, }); expect(page1.body.hasMore).toBe(true); expect(typeof page1.body.nextCursor).toBe('string'); const page2 = await listUploads(memberToken, { limit: 2, cursor: page1.body.nextCursor, }); expect(page2.status).toBe(200); expect( page2.body.items.map((i: { clientTaskId: string }) => i.clientTaskId), ).toEqual(['C-bytes', 'C-before']); expect(page2.body.hasMore).toBe(true); const page3 = await listUploads(memberToken, { limit: 2, cursor: page2.body.nextCursor, }); expect( page3.body.items.map((i: { clientTaskId: string }) => i.clientTaskId), ).toEqual(['R1']); expect(page3.body.hasMore).toBe(false); expect(page3.body.nextCursor).toBeNull(); // 跨用户隔离:removed(仅有已被拦截的会话)与 outsider 看不到 member 会话。 const outsiderList = await listUploads(outsiderToken); expect(outsiderList.status).toBe(200); expect(outsiderList.body.items).toHaveLength(0); const removedList = await listUploads(removedToken); expect(removedList.status).toBe(200); expect(removedList.body.items).toHaveLength(1); // 仅自己的 C-removed // 非法 cursor → 400。 const bad = await listUploads(memberToken, { cursor: 'garbage' }); expect(bad.status).toBe(400); expect(bad.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('BullMQ 入队:complete 后 photo-process 队列恰有一条对应 job(重复 complete 不重复入队)', async () => { // C-bytes(重试成功)与 C-happy 各完成一次 → 各入队一次;jobId 以 photoId // 派生去重。等待短暂时间让 enqueue 落 Redis(生产者为 await,理论即时)。 await new Promise((resolve) => setTimeout(resolve, 200)); const jobs = await queue.getJobs( ['waiting', 'delayed', 'active', 'completed', 'failed'], 0, -1, ); const byPhoto = new Map(); for (const job of jobs) { const photoId = (job.data as { photoId?: string }).photoId ?? '(none)'; byPhoto.set(photoId, (byPhoto.get(photoId) ?? 0) + 1); } expect(byPhoto.get(cHappy.photoId)).toBe(1); expect(byPhoto.get(cBytes.photoId)).toBe(1); // 会话归属成员的全部完成(两次 complete 幂等重复不重复入队)——队列中不 // 存在其他 member 照片之外的 job。 expect(byPhoto.size).toBe(2); }); it('sourceDeviceId:init 携带 → 落库并回显;幂等重放换设备保留首值(first-writer)', async () => { // 设备 A 初始化(P3 Task 3 契约增补,A19 设备来源语义)。bytes=1004: // 避开本册已完成的 1000(C-bytes)字节数(init ③.5 查重)。 const first = await initAs(memberToken, 'DEV-1', { bytes: 1004, sourceDeviceId: 'device-aaaa-1111', }); const findByTask = async (clientTaskId: string) => { const list = await listUploads(memberToken, { limit: 100 }); expect(list.status).toBe(200); return list.body.items.find( (i: { clientTaskId: string }) => i.clientTaskId === clientTaskId, ); }; expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111'); // 同 clientTaskId 换设备 B 重放:复用原会话,sourceDeviceId 保留首值。 const replay = await initAs(memberToken, 'DEV-1', { bytes: 1004, sourceDeviceId: 'device-bbbb-2222', }); expect(replay.uploadId).toBe(first.uploadId); expect((await findByTask('DEV-1'))?.sourceDeviceId).toBe('device-aaaa-1111'); // 未携带 → null(老客户端兼容)。bytes=1005 同理避开已完成字节数。 await initAs(memberToken, 'DEV-2', { bytes: 1005 }); expect((await findByTask('DEV-2'))?.sourceDeviceId).toBeNull(); // 长度越界(<8 / >64)→ 400 VALIDATION_FAILED。 const rawInit = (sourceDeviceId: string) => request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads`) .set('Authorization', `Bearer ${memberToken}`) .send({ albumId, clientTaskId: 'DEV-bad', filename: 'a.jpg', bytes: 1000, contentType: 'image/jpeg', sourceDeviceId, }); const tooShort = await rawInit('short7'); expect(tooShort.status).toBe(400); expect(tooShort.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const tooLong = await rawInit('x'.repeat(65)); expect(tooLong.status).toBe(400); expect(tooLong.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); }); // ── 查重预检(checkUploadDuplicates,「上传查重前置到入队前」计划 Task 1)── describe('uploads duplicates check (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let memberAId: string; let memberBId: string; let memberAToken: string; let outsiderToken: string; let albumId: string; /** memberA 既有可见照片字节数(固定 uploadedAt 供逐字段断言回显)。 */ const BYTES_A = 777_777; const UPLOADED_AT_A = '2026-09-20T08:00:00.000Z'; /** memberB(同册协作者)照片字节数(uploaderId 隔离用例)。 */ const BYTES_B_OTHER = 888_888; /** memberA 已软删除照片字节数(visibility=deleted 不参与查重)。 */ const BYTES_A_DELETED = 999_999; const check = (token: string, body: object) => request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads/duplicates/check`) .set('Authorization', `Bearer ${token}`) .send(body); const item = (filename: string, bytes: number) => ({ filename, bytes }); /** 直种 photos 裸行:预检只读 photos 列,无变体/对象存储依赖。 */ const seedPhoto = ( uploaderId: string, bytes: number, options: { visibility?: string; uploadedAt?: Date } = {}, ) => prisma.photos.create({ data: { albumId, uploaderId, objectKey: `photo-live/dev/albums/${albumId}/originals/dc-${uploaderId}-${bytes}-${Math.random().toString(36).slice(2, 8)}.jpg`, bytes, uploadedAt: options.uploadedAt ?? new Date(), visibility: options.visibility ?? 'unpublished', }, select: { id: true }, }); 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 }, }); const ownerId = (await mkUser('dc-owner')).id; memberAId = (await mkUser('dc-member-a')).id; memberBId = (await mkUser('dc-member-b')).id; const outsiderId = (await mkUser('dc-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; }; const ownerToken = await mkToken(ownerId); memberAToken = await mkToken(memberAId); outsiderToken = await mkToken(outsiderId); // 相册 fixture:owner 建 A(API 建册自动落 owner 成员行),A/B 为协作者。 const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${ownerToken}`) .send({ translations: [ { locale: 'zh-CN', title: '查重预检相册' }, { locale: 'en', title: 'DupCheck' }, ], 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.createMany({ data: [ { albumId, userId: memberAId, role: 'collaborator' }, { albumId, userId: memberBId, role: 'collaborator' }, ], }); // memberA 既有可见照片(非 deleted,uploadedAt 固定)。 await seedPhoto(memberAId, BYTES_A, { uploadedAt: new Date(UPLOADED_AT_A) }); // memberB 同册照片:memberA 查同 bytes 不得命中(uploaderId 隔离)。 await seedPhoto(memberBId, BYTES_B_OTHER); // memberA 已软删除照片:谓词 visibility != deleted,不得命中。 await seedPhoto(memberAId, BYTES_A_DELETED, { visibility: 'deleted' }); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); it('命中:既有可见照片同 bytes → 按请求下标逐项返回(两个同 bytes 条目各一条)', async () => { const res = await check(memberAToken, { albumId, items: [ item('first.jpg', 111_111), item('a.jpg', BYTES_A), item('b.jpg', BYTES_A), ], }); expect(res.status).toBe(200); expect(res.body.duplicates).toHaveLength(2); // 命中子集按请求下标升序;每命中一个「请求条目」一条(index=1/2 共享命中)。 expect(res.body.duplicates[0]).toEqual({ index: 1, bytes: BYTES_A, uploadedAt: UPLOADED_AT_A, }); expect(res.body.duplicates[1]).toEqual({ index: 2, bytes: BYTES_A, uploadedAt: UPLOADED_AT_A, }); }); it('未命中字节数 → 空数组', async () => { const res = await check(memberAToken, { albumId, items: [item('x.jpg', 111_111), item('y.png', 222_222)], }); expect(res.status).toBe(200); expect(res.body.duplicates).toEqual([]); }); it('uploaderId 隔离:同册他人上传的同 bytes 照片不命中(谓词同 init ③.5)', async () => { const res = await check(memberAToken, { albumId, items: [item('other.jpg', BYTES_B_OTHER)], }); expect(res.status).toBe(200); expect(res.body.duplicates).toEqual([]); }); it('visibility=deleted 的照片不命中', async () => { const res = await check(memberAToken, { albumId, items: [item('gone.jpg', BYTES_A_DELETED)], }); expect(res.status).toBe(200); expect(res.body.duplicates).toEqual([]); }); it('非成员 → 404 ALBUM_NOT_FOUND(防枚举,同 init)', async () => { const res = await check(outsiderToken, { albumId, items: [item('a.jpg', BYTES_A)], }); expect(res.status).toBe(404); expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); }); it('items 边界:空数组 → 400;501 项 → 400 VALIDATION_FAILED', async () => { const empty = await check(memberAToken, { albumId, items: [] }); expect(empty.status).toBe(400); expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const overflow = await check(memberAToken, { albumId, items: Array.from({ length: 501 }, (_, i) => item(`f${i}.jpg`, 1_000_000 + i)), }); expect(overflow.status).toBe(400); expect(overflow.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('未认证 → 401', async () => { const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/uploads/duplicates/check`) .send({ albumId, items: [item('a.jpg', BYTES_A)] }); expect(res.status).toBe(401); expect(res.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); });