// services/api/test/ui01-contracts.e2e-spec.ts —— UI-01 Task 2 契约扩展 e2e: // cover 封面摘要 / preview 受权限预览 / scope=own 数据范围(真实 app + MySQL + // MinIO:签名 URL 实取回字节,N+1 用 Prisma 中间件计真实查询数)。覆盖: // ① listAlbums cover:有封面 → url 短期签名且可取回真实字节;同 kind 多行 // 取最新 effective(createdAt/id DESC 首行);pending 行不计;无封面 → // null;getAlbum 详情同形;批量组装无 N+1(6 相册单页,相册域 Prisma 查 // 询数恒定:admin=3 albums+translations+album_assets / 非 admin=4 另加成 // 员行查询,与封面相册数无关); // ② preview 权限矩阵(scope=all,owner/admin):published 签发(thumb+browse // 可取回字节);hidden 对 manager 签发;deleted / 无变体 → null(记录 // 本身仍如实返回); // ③ scope=own:collab 仅见自己上传(服务端过滤,uploaderId 全等于本人); // 自己的 hidden 行可见但 preview=null(隐藏签发仅 admin/owner);owner // scope=own 过滤为自己上传;旧请求(无 scope)owner 全量行为不变; // ④ 权限边界:collab scope=all(缺省与显式)→ 403;被移除成员 scope=own → // 404(成员资格逐请求);非成员跨相册 → 404;scope 非法值 → 400。 import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; import { PutObjectCommand, S3Client, } from '@aws-sdk/client-s3'; 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 { PrismaService } from '../src/prisma/prisma.service'; import { API_PREFIX } from '../src/shared/constants'; import { resetDb } from './reset'; jest.setTimeout(120_000); /** 契约 pattern ^[0-9a-z]{20,32}$(同 albums.service 生成式口径)。 */ function makePublicToken(): string { const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; const bytes = randomBytes(24); let token = ''; for (let i = 0; i < 24; i++) { token += alphabet[bytes[i] % alphabet.length]; } return token; } /** 固定时间基(分钟偏移构造确定性 uploadedAt/createdAt 序列)。 */ const T0 = Date.UTC(2026, 8, 20, 8, 0, 0); const atMinute = (m: number): Date => new Date(T0 + m * 60_000); describe('UI-01 契约扩展:cover / preview / scope=own (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let s3: S3Client; let ownerId: string; let collabId: string; let adminId: string; let outsiderId: string; let ownerToken: string; let collabToken: string; let adminToken: string; let outsiderToken: string; /** 主相册 A(owner + collab):封面 + 全状态照片矩阵。 */ let albumAId: string; /** 相册 B(owner 独有,无封面无照片):无封面对照 + 跨册 404。 */ let albumBId: string; /** 其余 4 个带封面相册(N+1 批量断言用,owner 名下)。 */ const fillerIds: string[] = []; let coverOldAssetId: string; let coverNewAssetId: string; const coverOldBytes = Buffer.from([0x11, 0x12, 0x13, 0x14]); const coverNewBytes = Buffer.from([0x21, 0x22, 0x23, 0x24, 0x25]); let phPubC: string; // published / collab / 有变体 let phPubO: string; // published / owner / 有变体(字节取回断言用) let phHidC: string; // hidden / collab / 有变体 let phHidO: string; // hidden / owner / 有变体 let phDelO: string; // deleted / owner / 有变体 let phNovarC: string; // unpublished / collab / 无变体 const pubOBrowseBytes = Buffer.from([0x31, 0x32, 0x33, 0x34, 0x35, 0x36]); /** MinIO 实写对象(封面/预览可取回性)。 */ async function putObject(key: string, body: Buffer): Promise { await s3.send( new PutObjectCommand({ Bucket: process.env.S3_BUCKET, Key: key, Body: body, ContentType: 'image/jpeg', }), ); } async function fetchBody(url: string): Promise { const res = await fetch(url); expect(res.status).toBe(200); return Buffer.from(await res.arrayBuffer()); } function listAlbums(token: string, query?: Record) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${token}`) .query(query ?? {}); } function listPhotos( token: string, albumId: string, query?: Record, ) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}/photos`) .set('Authorization', `Bearer ${token}`) .query(query ?? {}); } 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(); 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, }); const mkUser = (account: string, displayName: string, role = 'staff') => prisma.users.create({ data: { account, role, passwordHash: 'x', displayName }, select: { id: true }, }); ownerId = (await mkUser('u01-owner', '欧阳雯')).id; collabId = (await mkUser('u01-collab', '程澜')).id; adminId = (await mkUser('u01-admin', '艾管', 'admin')).id; outsiderId = (await mkUser('u01-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); collabToken = await mkToken(collabId); adminToken = await mkToken(adminId); outsiderToken = await mkToken(outsiderId); // 相册 A:owner + collab;zh-CN 翻译。 const albumA = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId, startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true }, }); albumAId = albumA.id; await prisma.album_translations.create({ data: { albumId: albumAId, locale: 'zh-CN', title: 'UI01 契约相册' }, }); await prisma.album_members.createMany({ data: [ { albumId: albumAId, userId: ownerId, role: 'owner', status: 'active' }, { albumId: albumAId, userId: collabId, role: 'collaborator', status: 'active', }, ], }); // 相册 B:owner 独有(无 collab)——无封面对照 + 跨册 404。 const albumB = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId, startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true }, }); albumBId = albumB.id; await prisma.album_translations.create({ data: { albumId: albumBId, locale: 'zh-CN', title: 'UI01 无封面相册' }, }); await prisma.album_members.create({ data: { albumId: albumBId, userId: ownerId, role: 'owner', status: 'active' }, }); // 4 个补充相册(带封面):N+1 批量断言(单页 6 相册,5 有封面)。 for (let i = 0; i < 4; i++) { const filler = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId, startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true }, }); fillerIds.push(filler.id); await prisma.album_translations.create({ data: { albumId: filler.id, locale: 'zh-CN', title: `UI01 补充 ${i}` }, }); await prisma.album_members.create({ data: { albumId: filler.id, userId: ownerId, role: 'owner', status: 'active', }, }); const assetId = `u01assetfill${i}0000000000000`; const key = `photo-live/dev/albums/${filler.id}/assets/${assetId}.jpg`; await prisma.album_assets.create({ data: { id: assetId, albumId: filler.id, kind: 'cover', locale: null, objectKey: key, state: 'effective', expectedBytes: 4, contentType: 'image/jpeg', }, }); await putObject(key, Buffer.from([0x41, 0x42, 0x43])); } // 相册 A 封面矩阵:pending 行(不计)→ 旧 effective 行 → 新 effective 行 //(最新生效胜出)。 coverOldAssetId = 'u01assetold00000000000000000'; coverNewAssetId = 'u01assetnew00000000000000000'; const coverOldKey = `photo-live/dev/albums/${albumAId}/assets/${coverOldAssetId}.jpg`; const coverNewKey = `photo-live/dev/albums/${albumAId}/assets/${coverNewAssetId}.jpg`; await prisma.album_assets.createMany({ data: [ { id: 'u01assetpending0000000000000', albumId: albumAId, kind: 'cover', locale: null, objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetpending0000000000000.jpg`, state: 'pending', expectedBytes: 4, contentType: 'image/jpeg', createdAt: atMinute(50), // 最新但 pending → 不计 }, { id: coverOldAssetId, albumId: albumAId, kind: 'cover', locale: null, objectKey: coverOldKey, state: 'effective', expectedBytes: 4, contentType: 'image/jpeg', createdAt: atMinute(10), }, { id: coverNewAssetId, albumId: albumAId, kind: 'cover', locale: null, objectKey: coverNewKey, state: 'effective', expectedBytes: 5, contentType: 'image/jpeg', createdAt: atMinute(20), // 最新 effective → 胜出 }, { id: 'u01assetbanner000000000000', albumId: albumAId, kind: 'banner', // 非 cover kind → 不计 locale: null, objectKey: `photo-live/dev/albums/${albumAId}/assets/u01assetbanner000000000000.jpg`, state: 'effective', expectedBytes: 4, contentType: 'image/jpeg', createdAt: atMinute(30), }, ], }); await putObject(coverOldKey, coverOldBytes); await putObject(coverNewKey, coverNewBytes); // 照片矩阵(album A;uploadedAt 递减稳定序)。 const seedPhoto = async (spec: { id: string; uploaderId: string; visibility: string; processingState: string; uploadedAt: Date; withVariants: boolean; }): Promise => { await prisma.photos.create({ data: { id: spec.id, albumId: albumAId, uploaderId: spec.uploaderId, objectKey: `photo-live/dev/albums/${albumAId}/originals/${spec.id}.jpg`, bytes: 9999, width: 1600, height: 1000, uploadedAt: spec.uploadedAt, publishedAt: spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt), processingState: spec.processingState, visibility: spec.visibility, entityVersion: 2, }, }); if (!spec.withVariants) { return; } await prisma.photo_variants.createMany({ data: (['thumb', 'browse'] as const).map((variant, i) => ({ photoId: spec.id, variant, objectKey: `photo-live/dev/albums/${albumAId}/variants/${spec.id}/1/${variant}.jpg`, bytes: 100 + i, version: 1, width: variant === 'thumb' ? 320 : 1600, height: variant === 'thumb' ? 200 : 1000, })), }); }; phPubC = 'u01-photo-pub-c'; phPubO = 'u01-photo-pub-o'; phHidC = 'u01-photo-hid-c'; phHidO = 'u01-photo-hid-o'; phDelO = 'u01-photo-del-o'; phNovarC = 'u01-photo-novar-c'; await seedPhoto({ id: phPubC, uploaderId: collabId, visibility: 'published', processingState: 'ready', uploadedAt: atMinute(60), withVariants: true, }); await seedPhoto({ id: phPubO, uploaderId: ownerId, visibility: 'published', processingState: 'ready', uploadedAt: atMinute(50), withVariants: true, }); await seedPhoto({ id: phHidC, uploaderId: collabId, visibility: 'hidden', processingState: 'ready', uploadedAt: atMinute(40), withVariants: true, }); await seedPhoto({ id: phHidO, uploaderId: ownerId, visibility: 'hidden', processingState: 'ready', uploadedAt: atMinute(30), withVariants: true, }); await seedPhoto({ id: phDelO, uploaderId: ownerId, visibility: 'deleted', processingState: 'ready', uploadedAt: atMinute(20), withVariants: true, }); await seedPhoto({ id: phNovarC, uploaderId: collabId, visibility: 'unpublished', processingState: 'processing_failed', uploadedAt: atMinute(10), withVariants: false, }); // 预览字节取回断言用:phPubO 的 browse 对象实写 MinIO。 await putObject( `photo-live/dev/albums/${albumAId}/variants/${phPubO}/1/browse.jpg`, pubOBrowseBytes, ); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); await s3.destroy(); }); it('① listAlbums cover:最新 effective 胜出、pending/非 cover 不计、URL 可取回字节', async () => { const res = await listAlbums(ownerToken); expect(res.status).toBe(200); const items = res.body.items as { id: string; cover: { url: string; expiresAt: string } | null; }[]; expect(items).toHaveLength(6); // A + B + 4 filler(owner 全部可见) const byId = new Map(items.map((item) => [item.id, item])); // A:cover 非 null,签名 URL 实取回新 effective 行字节(非旧行/pending)。 const coverA = byId.get(albumAId)?.cover; expect(coverA).not.toBeNull(); expect(coverA?.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); expect(await fetchBody(coverA!.url)).toEqual(coverNewBytes); // B:无封面 → null(键在,值为 null;旧字段不受影响)。 expect(byId.get(albumBId)?.cover).toBeNull(); // filler:全部非 null。 for (const id of fillerIds) { expect(byId.get(id)?.cover).not.toBeNull(); } }); it('① getAlbum 详情:cover 同形(批量路径与单查路径一致)', async () => { const res = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumAId}`) .set('Authorization', `Bearer ${ownerToken}`); expect(res.status).toBe(200); expect(res.body.cover).not.toBeNull(); expect(await fetchBody(res.body.cover.url as string)).toEqual(coverNewBytes); const resB = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumBId}`) .set('Authorization', `Bearer ${ownerToken}`); expect(resB.status).toBe(200); expect(resB.body.cover).toBeNull(); }); it('① 批量无 N+1:6 相册(5 封面)单页列表 = 恒定 Prisma 查询数', async () => { // Prisma 5 $use 无卸载钩子:只挂一次(纯计数透传,不改变行为),以 // 计数器清零分隔两次测量。只计相册域模型——SessionGuard 的 sessions // 查询等基础设施流量不入断言(防守卫演进误伤本断言)。 const prismaService = app.get(PrismaService); const ALBUM_DOMAIN_MODELS = new Set([ 'albums', 'album_translations', 'album_assets', 'album_members', ]); let ops = 0; prismaService.$use((params, next) => { if (ALBUM_DOMAIN_MODELS.has(params.model as string)) { ops += 1; } return next(params); }); try { const res = await listAlbums(ownerToken); expect(res.status).toBe(200); expect((res.body.items as unknown[]).length).toBe(6); // owner:成员行 1 + albums 1 + translations 1 + album_assets 1 = 4。 // 封面相册数(5)不进入查询数——若退化为逐相册查封面(N+1)至少 +5。 expect(ops).toBe(4); // admin 对照:无成员行查询,恒 3(与封面相册数无关)。 ops = 0; const adminRes = await listAlbums(adminToken); expect(adminRes.status).toBe(200); expect((adminRes.body.items as unknown[]).length).toBe(6); expect(ops).toBe(3); } finally { // 中间件无法卸载(Prisma 5),保留纯计数透传不影响后续用例;置回 0 // 仅为语义整洁。 ops = 0; } }); it('② owner scope=all(缺省):preview 矩阵 + browse 字节取回', async () => { const res = await listPhotos(ownerToken, albumAId); expect(res.status).toBe(200); const byId = new Map( (res.body.items as { photoId: string; preview: unknown }[]).map( (item) => [item.photoId, item.preview], ), ); expect(res.body.items).toHaveLength(6); // published/hidden(含他人上传)对 manager 签发;deleted/无变体 null。 expect(byId.get(phPubC)).not.toBeNull(); expect(byId.get(phPubO)).not.toBeNull(); expect(byId.get(phHidC)).not.toBeNull(); expect(byId.get(phHidO)).not.toBeNull(); expect(byId.get(phDelO)).toBeNull(); expect(byId.get(phNovarC)).toBeNull(); // 签名 URL 实取回字节(thumb/browse 双通道,preview.browse 为浏览图)。 const previewO = byId.get(phPubO) as { thumb: { url: string }; browse: { url: string }; }; expect(await fetchBody(previewO.browse.url)).toEqual(pubOBrowseBytes); }); it('② admin scope=all:全部签发(他人 hidden 亦签)+ preview 形状', async () => { const res = await listPhotos(adminToken, albumAId, { scope: 'all' }); expect(res.status).toBe(200); const items = res.body.items as { photoId: string; preview: { thumb: { url: string; expiresAt: string }; browse: { url: string } } | null; }[]; const byId = new Map(items.map((item) => [item.photoId, item.preview])); expect(byId.get(phPubC)).not.toBeNull(); expect(byId.get(phHidC)).not.toBeNull(); // 他人 hidden 对 admin 签发 expect(byId.get(phHidO)).not.toBeNull(); expect(byId.get(phDelO)).toBeNull(); expect(byId.get(phNovarC)).toBeNull(); for (const item of items) { if (item.preview !== null) { expect(Object.keys(item.preview).sort()).toEqual(['browse', 'thumb']); expect(item.preview.thumb.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); } } }); it('③ collab scope=own:仅自己上传(服务端过滤)+ 自己 hidden 行 preview=null', async () => { const res = await listPhotos(collabToken, albumAId, { scope: 'own' }); expect(res.status).toBe(200); const items = res.body.items as { photoId: string; uploaderId: string; preview: unknown; }[]; expect(items.map((i) => i.photoId)).toEqual([phPubC, phHidC, phNovarC]); for (const item of items) { expect(item.uploaderId).toBe(collabId); // 服务端过滤,无 owner 照片 } // 自己的 published/未完成处理照常签发;hidden 仅 admin/owner 签发 → null //(记录仍可见,图片不可见)。 const byId = new Map(items.map((i) => [i.photoId, i.preview])); expect(byId.get(phPubC)).not.toBeNull(); expect(byId.get(phHidC)).toBeNull(); expect(byId.get(phNovarC)).toBeNull(); }); it('③ owner scope=own:过滤为自己上传(collab 照片不入列表)', async () => { const res = await listPhotos(ownerToken, albumAId, { scope: 'own' }); expect(res.status).toBe(200); const ids = (res.body.items as { photoId: string }[]).map((i) => i.photoId); expect(ids).toEqual([phPubO, phHidO, phDelO]); // 组合过滤:scope=own + visibility=published。 const combined = await listPhotos(ownerToken, albumAId, { scope: 'own', visibility: 'published', }); expect(combined.status).toBe(200); expect( (combined.body.items as { photoId: string }[]).map((i) => i.photoId), ).toEqual([phPubO]); }); it('④ collab scope=all(缺省与显式)→ 403;非成员任何 scope → 404;非法 scope → 400', async () => { const implicit = await listPhotos(collabToken, albumAId); expect(implicit.status).toBe(403); expect(implicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const explicit = await listPhotos(collabToken, albumAId, { scope: 'all' }); expect(explicit.status).toBe(403); expect(explicit.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); // 非成员(outsider 对 A)任何 scope → 404(防枚举)。 for (const scope of ['own', 'all', undefined]) { const res = await listPhotos(outsiderToken, albumAId, scope === undefined ? {} : { scope }); expect(res.status).toBe(404); expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); } // collab 对非成员相册 B → 404。 const cross = await listPhotos(collabToken, albumBId, { scope: 'own' }); expect(cross.status).toBe(404); expect(cross.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); // scope 非法值 → 400;空串视为缺省(collab → 403,同缺省口径)。 const garbage = await listPhotos(ownerToken, albumAId, { scope: 'mine' }); expect(garbage.status).toBe(400); expect(garbage.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const emptyScope = await listPhotos(collabToken, albumAId, { scope: '' }); expect(emptyScope.status).toBe(403); }); it('④ 被移除成员 scope=own → 404(成员资格逐请求检查)', async () => { // 基线:collab scope=own 可用(③ 已证,此处再验一次后移除)。 expect( (await listPhotos(collabToken, albumAId, { scope: 'own' })).status, ).toBe(200); await prisma.album_members.deleteMany({ where: { albumId: albumAId, userId: collabId }, }); const removed = await listPhotos(collabToken, albumAId, { scope: 'own' }); expect(removed.status).toBe(404); expect(removed.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); // 恢复成员(后续若有 spec 复用;本 spec 至此为止)。 await prisma.album_members.create({ data: { albumId: albumAId, userId: collabId, role: 'collaborator', status: 'active', }, }); }); it('cover 回退 banner(宣传图/封面共用:仅 banner 相册 → cover=banner 对象)', async () => { // 直种仅 banner 相册(复用本文件资产直种模式)。 const album = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId: 'u-ui01-coverfb', startAt: new Date('2026-10-03T02:00:00.000Z'), endAt: new Date('2026-10-03T12:00:00.000Z'), defaultLocale: 'zh-CN', }, }); await prisma.album_translations.create({ data: { albumId: album.id, locale: 'zh-CN', title: '仅宣传图相册' }, }); const key = `photo-live/dev/albums/${album.id}/assets/u01assetbannerfb00000000.jpg`; await putObject(key, Buffer.from([0x51, 0x52, 0x53])); await prisma.album_assets.create({ data: { id: 'u01assetbannerfb00000000', albumId: album.id, kind: 'banner', locale: null, objectKey: key, state: 'effective', expectedBytes: 3, contentType: 'image/jpeg', }, }); const res = await listAlbums(adminToken); expect(res.status).toBe(200); const item = (res.body.items as Array<{ id: string; cover: { url: string } | null }>).find( (row) => row.id === album.id, ); expect(item).toBeDefined(); expect(item?.cover).not.toBeNull(); const fetched = await fetch(item!.cover!.url); expect(fetched.status).toBe(200); expect(Buffer.from(await fetched.arrayBuffer()).length).toBe(3); }); });