// services/api/test/albums.e2e-spec.ts —— Task 15 相册创建/详情/列表 e2e(先写后实现)。 // 真实 app + MySQL(reset.ts 隔离)。认证不走登录接口:直接经 Prisma 种 sessions // 行(tokenHash=sha256(已知明文))拿 Bearer 令牌——比走 /auth/password 少一层 //无关耦合(登录语义已有 auth.e2e-spec 覆盖),albums 断言聚焦相册域本身。 // 覆盖: // 创建:201 契约 Album 形状(publicToken ^[0-9a-z]{20,32}$、四开关默认值、 // watermarkVersion=0、translations 回读、categories=[])+ DB 行/翻译/ // owner 成员行(album_members role=owner status=active)+ audit album.create; // 校验:translations 条目缺 title / defaultLocale 无对应条目 / endAt { let app: INestApplication; let prisma: PrismaClient; let ownerId: string; let adminId: string; let outsiderId: string; let pagerId: string; let ownerToken: string; let adminToken: string; let outsiderToken: string; let pagerToken: string; /** 首个用例创建的相册(后续详情/审计用例复用)。 */ let primaryAlbumId: string; const validCreateBody = { translations: [ { locale: 'zh-CN', title: '2026 公司年会', description: '年度盛会', venue: '大宴会厅', address: '上海市浦东新区世纪大道 100 号', }, { locale: 'en', title: 'Annual Party 2026' }, ], defaultLocale: 'zh-CN', startAt: '2026-10-01T02:00:00.000Z', }; 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, role = 'staff') => prisma.users.create({ data: { account, role, passwordHash: 'x', displayName: account }, select: { id: true }, }); ownerId = (await mkUser('album-owner')).id; adminId = (await mkUser('album-admin', 'admin')).id; outsiderId = (await mkUser('album-outsider')).id; pagerId = (await mkUser('album-pager')).id; // 直种会话行换 Bearer 令牌:守卫只认 tokenHash,无需走登录接口。 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); adminToken = await mkToken(adminId); outsiderToken = await mkToken(outsiderId); pagerToken = await mkToken(pagerId); }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); function postAlbum(token: string, body: object) { return request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${token}`) .send(body); } function getAlbum(token: string, albumId: string) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}`) .set('Authorization', `Bearer ${token}`); } function listAlbums(token: string, query?: Record) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${token}`) .query(query ?? {}); } it('创建相册(zh-CN+en 翻译)→ 201 契约 Album 形状;DB 落 albums+2 翻译+owner 成员行', async () => { const res = await postAlbum(ownerToken, validCreateBody); expect(res.status).toBe(201); const body = res.body as Record; expect(typeof body.id).toBe('string'); expect(body.publicToken).toMatch(/^[0-9a-z]{20,32}$/); expect(body.ownerId).toBe(ownerId); expect(body.status).toBe('active'); expect(body.defaultLocale).toBe('zh-CN'); expect(body.timezone).toBe('Asia/Shanghai'); // 契约缺省 expect(body.startAt).toBe('2026-10-01T02:00:00.000Z'); expect(body.endAt).toBeNull(); expect(body.uploadEnabled).toBe(true); expect(body.publicEnabled).toBe(true); expect(body.downloadEnabled).toBe(true); expect(body.originalDownloadEnabled).toBe(false); expect(body.watermarkVersion).toBe(0); expect(body.categories).toEqual([]); expect(Number.isNaN(Date.parse(String(body.createdAt)))).toBe(false); expect(Number.isNaN(Date.parse(String(body.updatedAt)))).toBe(false); primaryAlbumId = body.id as string; // 翻译回读:未填可选字段为 null(契约「未填的可选字段存储为 null」)。 expect(body.translations).toHaveLength(2); const translations = body.translations as Array>; const zh = translations.find((t) => t.locale === 'zh-CN'); const en = translations.find((t) => t.locale === 'en'); expect(zh).toMatchObject(validCreateBody.translations[0]); expect(en).toMatchObject({ title: 'Annual Party 2026', description: null, venue: null, address: null, }); // DB:albums 行默认值全部落库。 const row = await prisma.albums.findUnique({ where: { id: primaryAlbumId }, }); expect(row).not.toBeNull(); expect(row).toMatchObject({ publicToken: body.publicToken, ownerId: ownerId, status: 'active', defaultLocale: 'zh-CN', timezone: 'Asia/Shanghai', uploadEnabled: true, publicEnabled: true, downloadEnabled: true, originalDownloadEnabled: false, watermarkVersion: 0, }); expect(row?.startAt.getTime()).toBe(Date.parse(validCreateBody.startAt)); expect(row?.endAt).toBeNull(); // DB:两条翻译行。 const translationRows = await prisma.album_translations.findMany({ where: { albumId: primaryAlbumId }, }); expect(translationRows.map((t) => t.locale).sort()).toEqual([ 'en', 'zh-CN', ]); // DB:创建者自动成为 owner 成员(权限全部源自成员行——Task 14 结论)。 const member = await prisma.album_members.findUnique({ where: { albumId_userId: { albumId: primaryAlbumId, userId: ownerId }, }, }); expect(member).toMatchObject({ role: 'owner', status: 'active' }); // DB:审计。 const audit = await prisma.audit_logs.findFirst({ where: { action: 'album.create', actorId: ownerId, resourceId: primaryAlbumId, }, }); expect(audit).not.toBeNull(); }); it('translations 条目缺 title → 400 VALIDATION_FAILED', async () => { const res = await postAlbum(ownerToken, { ...validCreateBody, translations: [ { locale: 'zh-CN' }, { locale: 'en', title: 'No Title Entry' }, ], }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); expect(typeof res.body.requestId).toBe('string'); }); it('defaultLocale 无对应翻译条目 → 400 VALIDATION_FAILED', async () => { const res = await postAlbum(ownerToken, { ...validCreateBody, defaultLocale: 'en', translations: [{ locale: 'zh-CN', title: '只有中文' }], }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('endAt 早于 startAt → 400 VALIDATION_FAILED', async () => { const res = await postAlbum(ownerToken, { ...validCreateBody, endAt: '2026-09-30T00:00:00.000Z', }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('未认证创建/查询 → 401 AUTH_TOKEN_EXPIRED', async () => { const post = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .send(validCreateBody); expect(post.status).toBe(401); expect(post.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); const list = await request(app.getHttpServer()).get( `/${API_PREFIX}/albums`, ); expect(list.status).toBe(401); const detail = await request(app.getHttpServer()).get( `/${API_PREFIX}/albums/${primaryAlbumId}`, ); expect(detail.status).toBe(401); expect(detail.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); it('相册详情:owner 200 全量;非成员 staff 404 ALBUM_NOT_FOUND(防枚举);admin 200', async () => { const ownerRes = await getAlbum(ownerToken, primaryAlbumId); expect(ownerRes.status).toBe(200); expect(Object.keys(ownerRes.body).sort()).toEqual( [...ALBUM_SUMMARY_KEYS, 'categories'].sort(), ); expect(ownerRes.body.id).toBe(primaryAlbumId); expect(ownerRes.body.translations).toHaveLength(2); expect(ownerRes.body.categories).toEqual([]); const outsiderRes = await getAlbum(outsiderToken, primaryAlbumId); expect(outsiderRes.status).toBe(404); expect(outsiderRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); // admin 无任何成员行仍可见(公司管理员全量)。 const adminRes = await getAlbum(adminToken, primaryAlbumId); expect(adminRes.status).toBe(200); expect(adminRes.body.id).toBe(primaryAlbumId); // 不存在的相册 id:owner 视角同样 404(与不可见同码防枚举)。 const ghostRes = await getAlbum(ownerToken, 'no-such-album'); expect(ghostRes.status).toBe(404); expect(ghostRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); }); it('列表:成员只见自己参与相册;外人员工空列表;admin 可见全部', async () => { const ownerList = await listAlbums(ownerToken); expect(ownerList.status).toBe(200); expect(ownerList.body.items.map((a: { id: string }) => a.id)).toEqual([ primaryAlbumId, ]); // 列表条目形状精确等于契约 AlbumSummary(无 categories)。 expect(Object.keys(ownerList.body.items[0]).sort()).toEqual( ALBUM_SUMMARY_KEYS, ); expect(ownerList.body.items[0].publicToken).toMatch(/^[0-9a-z]{20,32}$/); const outsiderList = await listAlbums(outsiderToken); expect(outsiderList.status).toBe(200); expect(outsiderList.body.items).toEqual([]); expect(outsiderList.body.nextCursor).toBeNull(); expect(outsiderList.body.hasMore).toBe(false); const adminList = await listAlbums(adminToken, { limit: 100 }); expect(adminList.status).toBe(200); const adminIds = adminList.body.items.map((a: { id: string }) => a.id); expect(adminIds).toContain(primaryAlbumId); }); describe('列表游标分页(createdAt DESC, id DESC 稳定序)', () => { const CREATED_AT = [ `${PAGINATION_BASE}08:00:00.000Z`, `${PAGINATION_BASE}10:00:00.000Z`, `${PAGINATION_BASE}12:00:00.000Z`, ]; let pagerAlbumIds: string[]; beforeAll(async () => { // pager 用户独占三个相册(endAt 显式给值顺带验证晚于 startAt 的落库回读)。 const created: string[] = []; for (let i = 0; i < 3; i++) { const res = await postAlbum(pagerToken, { ...validCreateBody, startAt: '2026-10-01T02:00:00.000Z', endAt: '2026-10-01T12:00:00.000Z', }); expect(res.status).toBe(201); created.push(res.body.id as string); } // 显式回写互不相同的 createdAt:排序断言与真实写入时序解耦(jest 串行下 // 三个请求的 DateTime(6) 可能同毫秒,靠 id 次序断言会依赖 cuid 生成序)。 for (let i = 0; i < 3; i++) { await prisma.albums.update({ where: { id: created[i] }, data: { createdAt: new Date(CREATED_AT[i]) }, }); } pagerAlbumIds = created; }); it('limit=2 首页 → 新两条 + nextCursor/hasMore;次页 → 最旧一条,无重复;created 降序', async () => { const page1 = await listAlbums(pagerToken, { limit: 2 }); expect(page1.status).toBe(200); expect(page1.body.items).toHaveLength(2); expect(page1.body.hasMore).toBe(true); expect(typeof page1.body.nextCursor).toBe('string'); // created 降序:12:00 → 10:00。 expect(page1.body.items.map((a: { id: string }) => a.id)).toEqual([ pagerAlbumIds[2], pagerAlbumIds[1], ]); expect(page1.body.items[0].endAt).toBe('2026-10-01T12:00:00.000Z'); const page2 = await listAlbums(pagerToken, { limit: 2, cursor: page1.body.nextCursor, }); expect(page2.status).toBe(200); expect(page2.body.items.map((a: { id: string }) => a.id)).toEqual([ pagerAlbumIds[0], ]); expect(page2.body.hasMore).toBe(false); expect(page2.body.nextCursor).toBeNull(); // 两页并集恰为 pager 的三个相册(无重复、无丢失,也不含 owner 的相册)。 const union = [ ...page1.body.items.map((a: { id: string }) => a.id), ...page2.body.items.map((a: { id: string }) => a.id), ]; expect(new Set(union).size).toBe(3); expect(union.sort()).toEqual([...pagerAlbumIds].sort()); }); it('admin 列表包含多位用户的相册(全量视角)', async () => { const res = await listAlbums(adminToken, { limit: 100 }); expect(res.status).toBe(200); const ids = res.body.items.map((a: { id: string }) => a.id); expect(ids).toContain(primaryAlbumId); for (const id of pagerAlbumIds) { expect(ids).toContain(id); } }); it('limit 越界(0/101/非整数)→ 400 VALIDATION_FAILED', async () => { for (const limit of [0, 101, 'abc']) { const res = await listAlbums(pagerToken, { limit }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); } }); it('非法 cursor → 400 VALIDATION_FAILED', async () => { const res = await listAlbums(pagerToken, { cursor: 'not-a-cursor' }); expect(res.status).toBe(400); expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); }); it('audit_logs:album.create 以创建者为 actor、相册 id 为 resourceId', async () => { const audit = await prisma.audit_logs.findFirst({ where: { action: 'album.create', actorId: ownerId, resourceId: primaryAlbumId, }, }); expect(audit).not.toBeNull(); }); // ── Task 16:PATCH 更新与开关 ── // 专用相册 + 专用账号(owner/协作者/外人员工):PATCH 会持续修改相册状态, // 与上方详情/列表用例解耦;协作者成员行直接经 Prisma 种(成员 API 属 Task 17)。 describe('PATCH /albums/:albumId 更新与开关', () => { let updAlbumId: string; let updOwnerId: string; let updCollabId: string; let updOwnerToken: string; let updCollabToken: string; let updOutsiderToken: string; const patchAlbum = (token: string, body: object) => request(app.getHttpServer()) .patch(`/${API_PREFIX}/albums/${updAlbumId}`) .set('Authorization', `Bearer ${token}`) .send(body); beforeAll(async () => { const mkUser = (account: string) => prisma.users.create({ data: { account, role: 'staff', passwordHash: 'x', displayName: account }, select: { id: true }, }); updOwnerId = (await mkUser('upd-owner')).id; updCollabId = (await mkUser('upd-collab')).id; const outsiderId = (await mkUser('upd-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; }; updOwnerToken = await mkToken(updOwnerId); updCollabToken = await mkToken(updCollabId); updOutsiderToken = await mkToken(outsiderId); const res = await postAlbum(updOwnerToken, { translations: [ { locale: 'zh-CN', title: '更新前中文标题' }, { locale: 'en', title: 'Title Before Update' }, ], defaultLocale: 'zh-CN', startAt: '2026-11-01T02:00:00.000Z', }); expect(res.status).toBe(201); updAlbumId = res.body.id as string; // 协作者成员行(可见不可管)+ 一个既定 endAt 供合并校验用例。 await prisma.album_members.create({ data: { albumId: updAlbumId, userId: updCollabId, role: 'collaborator', status: 'active', }, }); await prisma.albums.update({ where: { id: updAlbumId }, data: { endAt: new Date('2026-11-02T02:00:00.000Z') }, }); }); it('owner 仅改 zh-CN 标题(全量替换带全双語)→ 200,en 原样;DB 行更新', async () => { const res = await patchAlbum(updOwnerToken, { translations: [ { locale: 'zh-CN', title: '更新后中文标题' }, { locale: 'en', title: 'Title Before Update' }, ], }); expect(res.status).toBe(200); expect(res.body.id).toBe(updAlbumId); const zh = res.body.translations.find( (t: { locale: string }) => t.locale === 'zh-CN', ); const en = res.body.translations.find( (t: { locale: string }) => t.locale === 'en', ); expect(zh.title).toBe('更新后中文标题'); expect(en.title).toBe('Title Before Update'); const rows = await prisma.album_translations.findMany({ where: { albumId: updAlbumId }, }); expect(rows).toHaveLength(2); expect( rows.find((r) => r.locale === 'zh-CN')?.title, ).toBe('更新后中文标题'); expect(rows.find((r) => r.locale === 'en')?.title).toBe( 'Title Before Update', ); }); it('开关独立:仅 uploadEnabled=false → publicEnabled 仍 true;DB 同步', async () => { const res = await patchAlbum(updOwnerToken, { uploadEnabled: false }); expect(res.status).toBe(200); expect(res.body.uploadEnabled).toBe(false); expect(res.body.publicEnabled).toBe(true); expect(res.body.downloadEnabled).toBe(true); expect(res.body.originalDownloadEnabled).toBe(false); // 再单独开 originalDownloadEnabled:其余开关不受牵连(双向独立)。 const res2 = await patchAlbum(updOwnerToken, { originalDownloadEnabled: true, }); expect(res2.status).toBe(200); expect(res2.body.uploadEnabled).toBe(false); expect(res2.body.originalDownloadEnabled).toBe(true); const row = await prisma.albums.findUnique({ where: { id: updAlbumId }, select: { uploadEnabled: true, publicEnabled: true, downloadEnabled: true, originalDownloadEnabled: true, }, }); expect(row).toMatchObject({ uploadEnabled: false, publicEnabled: true, downloadEnabled: true, originalDownloadEnabled: true, }); }); it('translations 全量替换:缺 defaultLocale 条目 → 400;连同 defaultLocale 一起改 → 200 且 zh-CN 行删除', async () => { // 请求只带 en:存量 defaultLocale=zh-CN 无对应条目 → 400。 const bad = await patchAlbum(updOwnerToken, { translations: [{ locale: 'en', title: 'EN Only Roster' }], }); expect(bad.status).toBe(400); expect(bad.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 同一请求里切换 defaultLocale=en 并全量替换为仅 en → 合法。 const res = await patchAlbum(updOwnerToken, { defaultLocale: 'en', translations: [{ locale: 'en', title: 'EN Only Roster' }], }); expect(res.status).toBe(200); expect(res.body.defaultLocale).toBe('en'); const rows = await prisma.album_translations.findMany({ where: { albumId: updAlbumId }, }); expect(rows.map((r) => r.locale)).toEqual(['en']); expect(rows[0]?.title).toBe('EN Only Roster'); const albumRow = await prisma.albums.findUnique({ where: { id: updAlbumId }, select: { defaultLocale: true }, }); expect(albumRow?.defaultLocale).toBe('en'); // 还原 defaultLocale(后续用例仍以 en 全量替换语义为准)。 const restore = await patchAlbum(updOwnerToken, { defaultLocale: 'zh-CN', translations: [ { locale: 'zh-CN', title: '还原中文' }, { locale: 'en', title: 'EN Only Roster' }, ], }); expect(restore.status).toBe(200); }); it('协作者 PATCH → 403 AUTH_FORBIDDEN;外人员工 → 404 ALBUM_NOT_FOUND;未认证 → 401', async () => { const collab = await patchAlbum(updCollabToken, { timezone: 'UTC' }); expect(collab.status).toBe(403); expect(collab.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const outsider = await patchAlbum(updOutsiderToken, { timezone: 'UTC' }); expect(outsider.status).toBe(404); expect(outsider.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const anon = await request(app.getHttpServer()) .patch(`/${API_PREFIX}/albums/${updAlbumId}`) .send({ timezone: 'UTC' }); expect(anon.status).toBe(401); expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); it('空请求体 → 400;endAt<=startAt(同请求 / 单边合并语义)→ 400', async () => { const empty = await patchAlbum(updOwnerToken, {}); expect(empty.status).toBe(400); expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const both = await patchAlbum(updOwnerToken, { startAt: '2026-11-03T00:00:00.000Z', endAt: '2026-11-01T00:00:00.000Z', }); expect(both.status).toBe(400); expect(both.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 只改 endAt:与存量 startAt(2026-11-01T02:00Z)合并后早于它 → 400。 const endOnly = await patchAlbum(updOwnerToken, { endAt: '2026-11-01T01:00:00.000Z', }); expect(endOnly.status).toBe(400); expect(endOnly.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 只改 startAt:与存量 endAt(2026-11-02T02:00Z)合并后晚于它 → 400。 const startOnly = await patchAlbum(updOwnerToken, { startAt: '2026-11-05T00:00:00.000Z', }); expect(startOnly.status).toBe(400); expect(startOnly.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // endAt=null 清除 + 合法 startAt → 200(null 语义本身也在此覆盖)。 const clear = await patchAlbum(updOwnerToken, { startAt: '2026-11-01T00:00:00.000Z', endAt: null, }); expect(clear.status).toBe(200); expect(clear.body.endAt).toBeNull(); }); it('audit_logs:album.update 记录 actor/resourceId 与 metadata.fields', async () => { const audits = await prisma.audit_logs.findMany({ where: { action: 'album.update', actorId: updOwnerId, resourceId: updAlbumId, }, }); expect(audits.length).toBeGreaterThanOrEqual(5); const fieldsList = audits.map( (a) => (a.metadata as { fields?: string[] })?.fields ?? [], ); expect(fieldsList).toContainEqual(['translations']); expect(fieldsList).toContainEqual(['uploadEnabled']); expect(fieldsList).toContainEqual(['originalDownloadEnabled']); expect(fieldsList).toContainEqual(['translations', 'defaultLocale']); // 单边日期合并校验失败的请求不应落审计(校验在事务内先于写入)。 expect(fieldsList).not.toContainEqual(['endAt']); }); it('admin PATCH → 200(管理员可管理任意相册)', async () => { const res = await patchAlbum(adminToken, { downloadEnabled: false }); expect(res.status).toBe(200); expect(res.body.downloadEnabled).toBe(false); const row = await prisma.albums.findUnique({ where: { id: updAlbumId }, select: { downloadEnabled: true }, }); expect(row?.downloadEnabled).toBe(false); }); it('不存在的相册 id:owner 视角 → 404 ALBUM_NOT_FOUND', async () => { const res = await request(app.getHttpServer()) .patch(`/${API_PREFIX}/albums/no-such-album`) .set('Authorization', `Bearer ${updOwnerToken}`) .send({ timezone: 'UTC' }); expect(res.status).toBe(404); expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); }); }); });