// services/api/test/photos-admin.e2e-spec.ts —— P1b Task 4 管理照片列表 e2e // (先写后实现)。真实 app + MySQL。覆盖: // ① owner GET /albums/{albumId}/photos:全部可见性状态可见(unpublished/ // published/hidden/deleted,含 uploaded/processing/processing_failed 处理 // 态),(uploadedAt,id) DESC 稳定倒序(同 uploadedAt 以 id DESC 决胜), // 条目 = PhotoRecord + uploader{userId,account,displayName} 脱敏内嵌 // (无邮箱/密码/状态字段;契约 PhotoRecord 不含 errorCode——管理侧对失败 // 的可见性经 processingState=processing_failed 表达);未完成上传 // (pending_upload、uploadedAt NULL)不入列表(排序键未就绪,见服务注释); // 他册照片不入列表; // ② visibility 过滤(四态逐一 + 非法值 400);categoryId 过滤(含与 // visibility 组合;幽灵/跨册分类 400); // ③ 游标分页:limit=3 三页跨页无重复、尾页 hasMore=false;非法 cursor / // limit 越界 → 400; // ④ 权限:协作者(非 owner 成员)403 AUTH_FORBIDDEN;外人员工 404 // ALBUM_NOT_FOUND;公司 admin 200;幽灵相册 404;未认证 401。 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'; jest.setTimeout(120_000); /** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */ function makePublicToken(): string { const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz'; let token = ''; const bytes = randomBytes(24); for (let i = 0; i < 24; i++) { token += alphabet[bytes[i] % alphabet.length]; } return token; } /** 契约 PhotoRecord 必返字段 + 管理列表内嵌 uploader(形状精确断言用; * UI-01 增 preview——服务恒发(无权/无变体为 null),键恒在)。 */ const ADMIN_PHOTO_KEYS = [ 'albumId', 'bytes', 'categoryId', 'height', 'likeCount', 'photoId', 'preview', 'processingState', 'publishedAt', 'shotAt', 'uploadedAt', 'uploader', 'uploaderId', 'visibility', 'width', ].sort(); /** 固定时间基(分钟偏移构造确定性 uploadedAt 序列)。 */ const T0 = Date.UTC(2026, 8, 12, 8, 0, 0); const atMinute = (m: number): Date => new Date(T0 + m * 60_000); describe('管理照片列表 GET /albums/{albumId}/photos (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let albumId: string; let catAId: string; let catBId: string; let catOtherId: string; let ownerId: string; let collabId: string; let ownerToken: string; let collabToken: string; let adminToken: string; let outsiderToken: string; // 种子照片(显式 ID + 显式 uploadedAt,期望序可静态推出)。 let p1: string; // published / collab / catA / t+50 let p2: string; // published / owner / 无分类 / t+40 let p3: string; // hidden / collab / catA / t+30 let p4: string; // deleted / owner / t+20 let p5: string; // unpublished processing_failed / collab / catB / t+10 let tieA: string; // unpublished processing / owner / t+0(与 tieB 同刻) let tieB: string; // unpublished uploaded / collab / catB / t+0(同刻 id 决胜) /** 全量无过滤期望序(uploadedAt DESC, id DESC;tie-b > tie-a)。 */ const FULL_ORDER = () => [p1, p2, p3, p4, p5, tieB, tieA]; 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(); // 账号:owner / collab(上传协作者)/ admin / outsider(外人员工)。 // displayName 与 account 不同值,验证 uploader 映射取的是资料字段。 const mkUser = ( account: string, displayName: string, role = 'staff', ) => prisma.users.create({ data: { account, role, passwordHash: 'x', displayName }, select: { id: true }, }); ownerId = (await mkUser('pa-owner', '潘欧文')).id; collabId = (await mkUser('pa-collab', '程澜')).id; const adminId = (await mkUser('pa-admin', '艾管', 'admin')).id; const outsiderId = (await mkUser('pa-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); // 主相册:owner + collab 成员行;分类 catA/catB。 const album = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId, startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true }, }); albumId = album.id; await prisma.album_translations.create({ data: { albumId, locale: 'zh-CN', title: '管理列表相册' }, }); await prisma.album_members.createMany({ data: [ { albumId, userId: ownerId, role: 'owner', status: 'active' }, { albumId, userId: collabId, role: 'collaborator', status: 'active' }, ], }); const catA = await prisma.categories.create({ data: { albumId, sortOrder: 1 }, select: { id: true }, }); const catB = await prisma.categories.create({ data: { albumId, sortOrder: 2 }, select: { id: true }, }); catAId = catA.id; catBId = catB.id; await prisma.category_translations.createMany({ data: [ { categoryId: catAId, locale: 'zh-CN', name: '晚宴' }, { categoryId: catBId, locale: 'zh-CN', name: '后台' }, ], }); // 他册(跨册分类/照片排除用):owner 名下独立相册 + 分类 + 一张 published。 const otherAlbum = await prisma.albums.create({ data: { publicToken: makePublicToken(), ownerId, startAt: new Date(), defaultLocale: 'zh-CN', }, select: { id: true }, }); const catOther = await prisma.categories.create({ data: { albumId: otherAlbum.id, sortOrder: 1 }, select: { id: true }, }); catOtherId = catOther.id; await seedPhoto({ id: 'pa-photo-other', targetAlbumId: otherAlbum.id, uploaderId: ownerId, visibility: 'published', processingState: 'ready', uploadedAt: atMinute(45), }); // 主册种子:两上传人 × 全可见性状态 × 处理态变体;tie 对同 uploadedAt。 p1 = await seedPhoto({ id: 'pa-photo-p1', targetAlbumId: albumId, uploaderId: collabId, visibility: 'published', processingState: 'ready', categoryId: catAId, uploadedAt: atMinute(50), }); p2 = await seedPhoto({ id: 'pa-photo-p2', targetAlbumId: albumId, uploaderId: ownerId, visibility: 'published', processingState: 'ready', uploadedAt: atMinute(40), }); p3 = await seedPhoto({ id: 'pa-photo-p3', targetAlbumId: albumId, uploaderId: collabId, visibility: 'hidden', processingState: 'ready', categoryId: catAId, uploadedAt: atMinute(30), }); p4 = await seedPhoto({ id: 'pa-photo-p4', targetAlbumId: albumId, uploaderId: ownerId, visibility: 'deleted', processingState: 'ready', uploadedAt: atMinute(20), }); p5 = await seedPhoto({ id: 'pa-photo-p5', targetAlbumId: albumId, uploaderId: collabId, visibility: 'unpublished', processingState: 'processing_failed', categoryId: catBId, uploadedAt: atMinute(10), }); tieA = await seedPhoto({ id: 'pa-photo-tie-a', targetAlbumId: albumId, uploaderId: ownerId, visibility: 'unpublished', processingState: 'processing', uploadedAt: atMinute(0), }); tieB = await seedPhoto({ id: 'pa-photo-tie-b', targetAlbumId: albumId, uploaderId: collabId, visibility: 'unpublished', processingState: 'uploaded', categoryId: catBId, uploadedAt: atMinute(0), }); // 未完成上传(init 后未 complete 的会话占位行):uploadedAt NULL → 不入列表。 await prisma.photos.create({ data: { id: 'pa-photo-pending', albumId, uploaderId: collabId, objectKey: `photo-live/dev/albums/${albumId}/originals/pa-photo-pending.jpg`, visibility: 'unpublished', processingState: 'pending_upload', }, }); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); /** * 直种照片行(管理列表不触及对象存储/变体——契约 PhotoRecord 无 URL 字段, * 故无需 MinIO 对象与 photo_variants 行)。 */ async function seedPhoto(spec: { id: string; targetAlbumId: string; uploaderId: string; visibility: string; processingState: string; categoryId?: string; uploadedAt: Date; }): Promise { await prisma.photos.create({ data: { id: spec.id, albumId: spec.targetAlbumId, uploaderId: spec.uploaderId, categoryId: spec.categoryId ?? null, objectKey: `photo-live/dev/albums/${spec.targetAlbumId}/originals/${spec.id}.jpg`, bytes: 12345, width: 800, height: 500, uploadedAt: spec.uploadedAt, publishedAt: spec.visibility === 'unpublished' ? null : new Date(spec.uploadedAt), processingState: spec.processingState, visibility: spec.visibility, entityVersion: 2, }, }); return spec.id; } function listMgmt( token: string, query?: Record, ) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}/photos`) .set('Authorization', `Bearer ${token}`) .query(query ?? {}); } function idsOf(body: { items: { photoId: string }[] }): string[] { return body.items.map((item) => item.photoId); } it('owner → 200:全状态可见、(uploadedAt,id) DESC、uploader 脱敏内嵌正确', async () => { const res = await listMgmt(ownerToken); expect(res.status).toBe(200); expect(Object.keys(res.body).sort()).toEqual([ 'hasMore', 'items', 'nextCursor', ]); expect(res.body.hasMore).toBe(false); expect(res.body.nextCursor).toBeNull(); // 全状态 + 处理态变体全部可见;未完成上传(NULL uploadedAt)与他册照片排除。 expect(idsOf(res.body)).toEqual(FULL_ORDER()); expect(idsOf(res.body)).not.toContain('pa-photo-pending'); expect(idsOf(res.body)).not.toContain('pa-photo-other'); // 条目形状 = PhotoRecord 全字段 + uploader(无任何凭据/状态冗余字段)。 expect(Object.keys(res.body.items[0]).sort()).toEqual(ADMIN_PHOTO_KEYS); // uploader 内嵌:两上传人各自映射到 account/displayName(脱敏资料字段)。 const byId = new Map( (res.body.items as { photoId: string; uploader: { userId: string; account: string; displayName: string }; processingState: string; publishedAt: string | null; categoryId: string | null; }[]).map((item) => [item.photoId, item]), ); for (const item of byId.values()) { expect(item.uploader).toEqual( item.uploader.userId === ownerId ? { userId: ownerId, account: 'pa-owner', displayName: '潘欧文' } : { userId: collabId, account: 'pa-collab', displayName: '程澜' }, ); } // 处理态可见性(管理侧失败洞察):processing_failed / processing / uploaded。 expect(byId.get(p5)?.processingState).toBe('processing_failed'); expect(byId.get(tieA)?.processingState).toBe('processing'); expect(byId.get(tieB)?.processingState).toBe('uploaded'); // 未发布 publishedAt=null(与 published 行对照);分类映射。 expect(byId.get(p5)?.publishedAt).toBeNull(); expect(byId.get(p1)?.publishedAt).not.toBeNull(); expect(byId.get(p1)?.categoryId).toBe(catAId); expect(byId.get(p2)?.categoryId).toBeNull(); }); it('visibility 过滤:published/unpublished/hidden/deleted 逐一命中;非法值 → 400', async () => { const published = await listMgmt(ownerToken, { visibility: 'published' }); expect(published.status).toBe(200); expect(idsOf(published.body)).toEqual([p1, p2]); const unpublished = await listMgmt(ownerToken, { visibility: 'unpublished', }); expect(unpublished.status).toBe(200); expect(idsOf(unpublished.body)).toEqual([p5, tieB, tieA]); const hidden = await listMgmt(ownerToken, { visibility: 'hidden' }); expect(hidden.status).toBe(200); expect(idsOf(hidden.body)).toEqual([p3]); const deleted = await listMgmt(ownerToken, { visibility: 'deleted' }); expect(deleted.status).toBe(200); expect(idsOf(deleted.body)).toEqual([p4]); for (const garbage of ['public', 'PUBLISHED', '42']) { const res = await listMgmt(ownerToken, { visibility: garbage }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } }); it('categoryId 过滤与组合;幽灵/跨册 categoryId → 400', async () => { const catA = await listMgmt(ownerToken, { categoryId: catAId }); expect(catA.status).toBe(200); expect(idsOf(catA.body)).toEqual([p1, p3]); const catB = await listMgmt(ownerToken, { categoryId: catBId }); expect(catB.status).toBe(200); expect(idsOf(catB.body)).toEqual([p5, tieB]); const combined = await listMgmt(ownerToken, { visibility: 'published', categoryId: catAId, }); expect(combined.status).toBe(200); expect(idsOf(combined.body)).toEqual([p1]); const ghost = await listMgmt(ownerToken, { categoryId: 'no-such-cat' }); expect(ghost.status).toBe(400); expect(ghost.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 跨册分类:真实存在但不属于本相册——同码 400(不区分两种失败,防探测)。 const cross = await listMgmt(ownerToken, { categoryId: catOtherId }); expect(cross.status).toBe(400); expect(cross.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('游标分页:limit=3 三页跨页无重复,尾页 hasMore=false / nextCursor=null', async () => { const seen: string[] = []; let cursor: string | undefined; let pages = 0; for (;;) { const res = await listMgmt( ownerToken, cursor === undefined ? { limit: 3 } : { limit: 3, cursor }, ); expect(res.status).toBe(200); pages += 1; seen.push(...idsOf(res.body)); if (res.body.hasMore === false) { expect(res.body.nextCursor).toBeNull(); expect(pages).toBe(3); break; } expect(res.body.nextCursor).toEqual(expect.any(String)); cursor = res.body.nextCursor as string; } expect(seen).toEqual(FULL_ORDER()); expect(new Set(seen).size).toBe(seen.length); // 无重复 }); it('非法 cursor / limit 越界 → 400 VALIDATION_FAILED', async () => { const badCursor = await listMgmt(ownerToken, { cursor: 'not-a-cursor!' }); expect(badCursor.status).toBe(400); expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED); for (const limit of [0, 101, 'abc']) { const res = await listMgmt(ownerToken, { limit }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } // 合法界内:1 与 100 均 200。 expect((await listMgmt(ownerToken, { limit: 1 })).status).toBe(200); expect((await listMgmt(ownerToken, { limit: 100 })).status).toBe(200); }); it('权限:collab 403 / outsider 404 / admin 200 / 幽灵相册 404 / 未认证 401', async () => { const collabRes = await listMgmt(collabToken); expect(collabRes.status).toBe(403); expect(collabRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const outsiderRes = await listMgmt(outsiderToken); expect(outsiderRes.status).toBe(404); expect(outsiderRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const adminRes = await listMgmt(adminToken, { visibility: 'deleted' }); expect(adminRes.status).toBe(200); expect(idsOf(adminRes.body)).toEqual([p4]); const ghost = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/no-such-album/photos`) .set('Authorization', `Bearer ${ownerToken}`); expect(ghost.status).toBe(404); expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const anon = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}/photos`); expect(anon.status).toBe(401); expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); });