// services/api/test/members-categories.e2e-spec.ts —— Task 17 成员与分类 e2e(先写后实现)。 // 认证与 Task 15 相同:Prisma 直种 sessions 行换 Bearer 令牌。覆盖: // 成员:owner 邀请已开通同事(userId)→ 201 契约 Member 形状 + DB 行 + // audit album.member.add;重复邀请 409 MEMBER_CONFLICT;邀请 owner/ // role=owner/未知账号/未开通/已停用 → 400;成员列表(成员视角 200、 // 外人 404)与游标分页;移除协作者 204 幂等 + 被移除者即刻失去相册 // 可见性(A02:GET album → 404)+ audit album.member.remove;owner // 自移除 403(转移属 P4a 管理员操作);协作者增删成员 403。 // 分类:owner 创建 {sortOrder, translations} → 201 契约 Category;相册详情 // 按 sortOrder 升序带出分类;PATCH 重排/改名(translations 全量替换); // DELETE 204 且详情不再列出;不存在/越权 404/403 同相册语义 // (ALBUM_NOT_FOUND);audit album.category.add/update/remove。 import { randomBytes } from 'node:crypto'; import { type INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import request from 'supertest'; // 冷 infra(Docker 重启后)下 beforeAll 引导可超 jest 缺省 5s——对齐 // 同目录其它 e2e 的 120s(2026-09-29 两次冷启动误报教训)。 jest.setTimeout(120_000); 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'; /** 契约 Member 的字段全集(形状精确断言用)。 */ const MEMBER_KEYS = ['userId', 'account', 'displayName', 'role', 'status'].sort(); /** 契约 Category 的字段全集。 */ const CATEGORY_KEYS = ['id', 'albumId', 'sortOrder', 'translations'].sort(); describe('members & categories (e2e)', () => { let app: INestApplication; let prisma: PrismaClient; let ownerId: string; let inviteeId: string; let collabId: string; let outsiderId: string; let ownerToken: string; let inviteeToken: string; let collabToken: string; let outsiderToken: string; let adminToken: string; let albumId: string; /** 依次创建的分类 id(cat1=sortOrder 5、cat2=sortOrder 1)。 */ let cat1Id: string; let cat2Id: string; 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, extra: { role?: string; sourceStatus?: string; credentialStatus?: string } = {}, ) => prisma.users.create({ data: { account, role: extra.role ?? 'staff', passwordHash: 'x', displayName: account, sourceStatus: extra.sourceStatus, credentialStatus: extra.credentialStatus, }, select: { id: true }, }); ownerId = (await mkUser('mc-owner')).id; inviteeId = (await mkUser('mc-invitee')).id; collabId = (await mkUser('mc-collab')).id; outsiderId = (await mkUser('mc-outsider')).id; // 未开通 / 已停用账号:邀请应 400。 await mkUser('mc-pending', { credentialStatus: 'pending' }); await mkUser('mc-disabled', { sourceStatus: 'disabled' }); const adminId = (await mkUser('mc-admin', { role: 'admin' })).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); inviteeToken = await mkToken(inviteeId); collabToken = await mkToken(collabId); outsiderToken = await mkToken(outsiderId); adminToken = await mkToken(adminId); const res = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums`) .set('Authorization', `Bearer ${ownerToken}`) .send({ translations: [ { locale: 'zh-CN', title: '成员与分类相册' }, { locale: 'en', title: 'Members and Categories' }, ], defaultLocale: 'zh-CN', startAt: '2026-12-01T02:00:00.000Z', }); expect(res.status).toBe(201); albumId = res.body.id as string; }); afterAll(async () => { await app.close(); await prisma.$disconnect(); }); // ── 成员 ── const postMember = (token: string, body: object) => request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/members`) .set('Authorization', `Bearer ${token}`) .send(body); const listMembers = ( token: string, query?: Record, ) => request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}/members`) .set('Authorization', `Bearer ${token}`) .query(query ?? {}); const deleteMember = (token: string, userId: string) => request(app.getHttpServer()) .delete(`/${API_PREFIX}/albums/${albumId}/members/${userId}`) .set('Authorization', `Bearer ${token}`); it('owner 邀请已开通同事 → 201 Member;DB 成员行;audit album.member.add', async () => { const res = await postMember(ownerToken, { userId: inviteeId }); expect(res.status).toBe(201); expect(Object.keys(res.body).sort()).toEqual(MEMBER_KEYS); expect(res.body).toMatchObject({ userId: inviteeId, account: 'mc-invitee', displayName: 'mc-invitee', role: 'collaborator', status: 'active', }); const row = await prisma.album_members.findUnique({ where: { albumId_userId: { albumId, userId: inviteeId } }, }); expect(row).toMatchObject({ role: 'collaborator', status: 'active' }); const audit = await prisma.audit_logs.findFirst({ where: { action: 'album.member.add', actorId: ownerId, resourceId: albumId, }, }); expect(audit).not.toBeNull(); expect((audit?.metadata as { userId?: string })?.userId).toBe(inviteeId); }); it('重复邀请 → 409 MEMBER_CONFLICT', async () => { const res = await postMember(ownerToken, { userId: inviteeId }); expect(res.status).toBe(409); expect(res.body.code).toBe(ErrorCodes.MEMBER_CONFLICT); }); it('owner 邀请自己 / role=owner / 未知账号 / 未开通 / 已停用 → 400', async () => { const self = await postMember(ownerToken, { userId: ownerId }); expect(self.status).toBe(400); expect(self.body.code).toBe(ErrorCodes.VALIDATION_FAILED); // 本端点仅可添加 collaborator(owner 转移属 P4a 管理员操作)。 const asOwner = await postMember(ownerToken, { userId: collabId, role: 'owner', }); expect(asOwner.status).toBe(400); expect(asOwner.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const unknown = await postMember(ownerToken, { userId: 'no-such-user' }); expect(unknown.status).toBe(400); expect(unknown.body.code).toBe(ErrorCodes.VALIDATION_FAILED); expect(typeof unknown.body.message).toBe('string'); // 凭据未开通(credentialStatus=pending)与已停用(sourceStatus=disabled)。 const pendingUser = await prisma.users.findUnique({ where: { account: 'mc-pending' }, select: { id: true }, }); const pending = await postMember(ownerToken, { userId: pendingUser?.id, }); expect(pending.status).toBe(400); const disabledUser = await prisma.users.findUnique({ where: { account: 'mc-disabled' }, select: { id: true }, }); const disabled = await postMember(ownerToken, { userId: disabledUser?.id, }); expect(disabled.status).toBe(400); expect(disabled.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('成员列表:成员/管理员视角 200(owner+协作者);外人员工 404;admin 200', async () => { const ownerList = await listMembers(ownerToken); expect(ownerList.status).toBe(200); expect(ownerList.body.items).toHaveLength(2); const ids = ownerList.body.items.map((m: { userId: string }) => m.userId); expect(ids.sort()).toEqual([inviteeId, ownerId].sort()); expect(Object.keys(ownerList.body.items[0]).sort()).toEqual(MEMBER_KEYS); // owner 行的 account/displayName 来自 users 表(扁平外键应用层组装)。 const ownerRow = ownerList.body.items.find( (m: { userId: string }) => m.userId === ownerId, ); expect(ownerRow).toMatchObject({ account: 'mc-owner', displayName: 'mc-owner', role: 'owner', status: 'active', }); // 被邀请的协作者本人也是成员 → 可见。 const memberList = await listMembers(inviteeToken); expect(memberList.status).toBe(200); expect(memberList.body.items).toHaveLength(2); const outsiderList = await listMembers(outsiderToken); expect(outsiderList.status).toBe(404); expect(outsiderList.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const adminList = await listMembers(adminToken); expect(adminList.status).toBe(200); expect(adminList.body.items).toHaveLength(2); }); it('成员列表游标分页:addedAt DESC 稳定序,hasMore/nextCursor 成对', async () => { // 显式回写互不相同的 addedAt:排序断言与真实写入时序解耦。 await prisma.album_members.updateMany({ where: { albumId, userId: ownerId }, data: { addedAt: new Date('2026-09-10T08:00:00.000Z') }, }); await prisma.album_members.updateMany({ where: { albumId, userId: inviteeId }, data: { addedAt: new Date('2026-09-10T10:00:00.000Z') }, }); const page1 = await listMembers(ownerToken, { limit: 1 }); expect(page1.status).toBe(200); expect(page1.body.items.map((m: { userId: string }) => m.userId)).toEqual([ inviteeId, ]); expect(page1.body.hasMore).toBe(true); expect(typeof page1.body.nextCursor).toBe('string'); const page2 = await listMembers(ownerToken, { limit: 1, cursor: page1.body.nextCursor, }); expect(page2.status).toBe(200); expect(page2.body.items.map((m: { userId: string }) => m.userId)).toEqual([ ownerId, ]); expect(page2.body.hasMore).toBe(false); expect(page2.body.nextCursor).toBeNull(); const badCursor = await listMembers(ownerToken, { cursor: 'garbage' }); expect(badCursor.status).toBe(400); expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('owner 移除协作者 → 204;被移除者即刻失去相册可见性(A02);重复移除幂等 204;audit album.member.remove', async () => { const res = await deleteMember(ownerToken, inviteeId); expect(res.status).toBe(204); // A02 链路:成员行是唯一权限来源,删除行后下一次请求即刻 404。 const albumRes = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}`) .set('Authorization', `Bearer ${inviteeToken}`); expect(albumRes.status).toBe(404); expect(albumRes.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); // 成员列表回到仅 owner。 const ownerList = await listMembers(ownerToken); expect(ownerList.body.items).toHaveLength(1); // 幂等:再次移除同一用户仍 204(目标成员行已不存在)。 const again = await deleteMember(ownerToken, inviteeId); expect(again.status).toBe(204); // 移除成功的那次落审计(幂等重删不重复落)。 const audits = await prisma.audit_logs.findMany({ where: { action: 'album.member.remove', actorId: ownerId, resourceId: albumId, }, }); expect(audits).toHaveLength(1); expect((audits[0]?.metadata as { userId?: string })?.userId).toBe(inviteeId); }); it('owner 自移除 → 403 AUTH_FORBIDDEN(owner 转移属 P4a 管理员操作)', async () => { const res = await deleteMember(ownerToken, ownerId); expect(res.status).toBe(403); expect(res.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); // owner 成员行仍在。 const row = await prisma.album_members.findUnique({ where: { albumId_userId: { albumId, userId: ownerId } }, }); expect(row).not.toBeNull(); }); it('协作者增删成员 → 403;外人增删 → 404;未认证 → 401', async () => { // 先把 standing collaborator 加进来(owner 操作,复用前述成功路径)。 const invite = await postMember(ownerToken, { userId: collabId }); expect(invite.status).toBe(201); const collabAdd = await postMember(collabToken, { userId: outsiderId }); expect(collabAdd.status).toBe(403); expect(collabAdd.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const collabRemove = await deleteMember(collabToken, ownerId); expect(collabRemove.status).toBe(403); expect(collabRemove.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const outsiderAdd = await postMember(outsiderToken, { userId: collabId }); expect(outsiderAdd.status).toBe(404); expect(outsiderAdd.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const outsiderRemove = await deleteMember(outsiderToken, collabId); expect(outsiderRemove.status).toBe(404); expect(outsiderRemove.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const anon = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/members`) .send({ userId: collabId }); expect(anon.status).toBe(401); expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); it('不存在的相册:邀请/列表/移除 → 404 ALBUM_NOT_FOUND', async () => { const invite = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/no-such-album/members`) .set('Authorization', `Bearer ${ownerToken}`) .send({ userId: collabId }); expect(invite.status).toBe(404); expect(invite.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const list = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/no-such-album/members`) .set('Authorization', `Bearer ${ownerToken}`); expect(list.status).toBe(404); const remove = await request(app.getHttpServer()) .delete(`/${API_PREFIX}/albums/no-such-album/members/${collabId}`) .set('Authorization', `Bearer ${ownerToken}`); expect(remove.status).toBe(404); // admin 对不存在的 id 权限恒真:同样兜底 404(而非空列表 200)。 const adminList = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/no-such-album/members`) .set('Authorization', `Bearer ${adminToken}`); expect(adminList.status).toBe(404); expect(adminList.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); }); // ── 分类 ── const postCategory = (token: string, body: object) => request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/categories`) .set('Authorization', `Bearer ${token}`) .send(body); const patchCategory = (token: string, categoryId: string, body: object) => request(app.getHttpServer()) .patch(`/${API_PREFIX}/categories/${categoryId}`) .set('Authorization', `Bearer ${token}`) .send(body); const deleteCategory = (token: string, categoryId: string) => request(app.getHttpServer()) .delete(`/${API_PREFIX}/categories/${categoryId}`) .set('Authorization', `Bearer ${token}`); const getAlbumCategories = async (): Promise< Array<{ id: string; sortOrder: number; translations: Array<{ locale: string; name: string }> }> > => { const res = await request(app.getHttpServer()) .get(`/${API_PREFIX}/albums/${albumId}`) .set('Authorization', `Bearer ${ownerToken}`); expect(res.status).toBe(200); return res.body.categories; }; it('owner 创建分类 → 201 Category;DB 行;audit album.category.add', async () => { const res = await postCategory(ownerToken, { sortOrder: 5, translations: [ { locale: 'zh-CN', name: '外场' }, { locale: 'en', name: 'Outdoor' }, ], }); expect(res.status).toBe(201); expect(Object.keys(res.body).sort()).toEqual(CATEGORY_KEYS); expect(res.body.albumId).toBe(albumId); expect(res.body.sortOrder).toBe(5); // 翻译按 locale 升序(确定性输出)。 expect(res.body.translations).toEqual([ { locale: 'en', name: 'Outdoor' }, { locale: 'zh-CN', name: '外场' }, ]); cat1Id = res.body.id as string; const rows = await prisma.category_translations.findMany({ where: { categoryId: cat1Id }, }); expect(rows.map((r) => r.locale).sort()).toEqual(['en', 'zh-CN']); const audit = await prisma.audit_logs.findFirst({ where: { action: 'album.category.add', actorId: ownerId, resourceId: albumId, }, }); expect(audit).not.toBeNull(); expect((audit?.metadata as { categoryId?: string })?.categoryId).toBe( cat1Id, ); // 第二个分类 sortOrder=1:相册详情按 sortOrder 升序带出。 const res2 = await postCategory(ownerToken, { sortOrder: 1, translations: [{ locale: 'zh-CN', name: '签到台' }], }); expect(res2.status).toBe(201); cat2Id = res2.body.id as string; const categories = await getAlbumCategories(); expect(categories.map((c) => c.id)).toEqual([cat2Id, cat1Id]); expect(categories[0]).toMatchObject({ sortOrder: 1 }); expect(categories[0].translations).toEqual([ { locale: 'zh-CN', name: '签到台' }, ]); }); it('分类校验:重复 locale / 空 translations / 空更新体 → 400', async () => { const dup = await postCategory(ownerToken, { translations: [ { locale: 'zh-CN', name: '重复' }, { locale: 'zh-CN', name: '重复2' }, ], }); expect(dup.status).toBe(400); expect(dup.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const empty = await postCategory(ownerToken, { translations: [] }); expect(empty.status).toBe(400); expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED); const emptyPatch = await patchCategory(ownerToken, cat1Id, {}); expect(emptyPatch.status).toBe(400); expect(emptyPatch.body.code).toBe(ErrorCodes.VALIDATION_FAILED); }); it('owner PATCH 分类:重排 + 改名(translations 全量替换)→ 200;详情次序更新;audit album.category.update', async () => { const res = await patchCategory(ownerToken, cat1Id, { sortOrder: 0, translations: [{ locale: 'zh-CN', name: '主会场' }], }); expect(res.status).toBe(200); expect(res.body.sortOrder).toBe(0); // 全量替换:en 名称不复存在。 expect(res.body.translations).toEqual([{ locale: 'zh-CN', name: '主会场' }]); // DB:旧 en 翻译行已删。 const rows = await prisma.category_translations.findMany({ where: { categoryId: cat1Id }, }); expect(rows).toHaveLength(1); expect(rows[0]).toMatchObject({ locale: 'zh-CN', name: '主会场' }); // 相册详情:cat1(0) 现在排在 cat2(1) 之前。 const categories = await getAlbumCategories(); expect(categories.map((c) => c.id)).toEqual([cat1Id, cat2Id]); const audit = await prisma.audit_logs.findFirst({ where: { action: 'album.category.update', actorId: ownerId, resourceId: albumId, }, }); expect(audit).not.toBeNull(); expect( (audit?.metadata as { categoryId?: string })?.categoryId, ).toBe(cat1Id); }); it('owner DELETE 分类 → 204;详情不再列出;再删 → 404;audit album.category.remove', async () => { const res = await deleteCategory(ownerToken, cat2Id); expect(res.status).toBe(204); const categories = await getAlbumCategories(); expect(categories.map((c) => c.id)).toEqual([cat1Id]); const again = await deleteCategory(ownerToken, cat2Id); expect(again.status).toBe(404); expect(again.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); // DB:分类与翻译行俱删。 const categoryRow = await prisma.categories.findUnique({ where: { id: cat2Id }, }); expect(categoryRow).toBeNull(); const translationRows = await prisma.category_translations.findMany({ where: { categoryId: cat2Id }, }); expect(translationRows).toHaveLength(0); const audit = await prisma.audit_logs.findFirst({ where: { action: 'album.category.remove', actorId: ownerId, resourceId: albumId, }, }); expect(audit).not.toBeNull(); }); it('不存在的分类 id:PATCH/DELETE → 404 ALBUM_NOT_FOUND(契约分类 404 语义)', async () => { const patch = await patchCategory(ownerToken, 'no-such-category', { sortOrder: 2, }); expect(patch.status).toBe(404); expect(patch.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const del = await deleteCategory(ownerToken, 'no-such-category'); expect(del.status).toBe(404); expect(del.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); }); it('协作者分类增删改 → 403;外人员工 → 404;admin 创建 → 201', async () => { const collabCreate = await postCategory(collabToken, { translations: [{ locale: 'zh-CN', name: '协作者的分类' }], }); expect(collabCreate.status).toBe(403); expect(collabCreate.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const collabPatch = await patchCategory(collabToken, cat1Id, { sortOrder: 9, }); expect(collabPatch.status).toBe(403); expect(collabPatch.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const collabDelete = await deleteCategory(collabToken, cat1Id); expect(collabDelete.status).toBe(403); expect(collabDelete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); const outsiderCreate = await postCategory(outsiderToken, { translations: [{ locale: 'zh-CN', name: '外人的分类' }], }); expect(outsiderCreate.status).toBe(404); expect(outsiderCreate.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND); const adminCreate = await postCategory(adminToken, { sortOrder: 2, translations: [{ locale: 'en', name: 'Admin Corner' }], }); expect(adminCreate.status).toBe(201); const anon = await request(app.getHttpServer()) .post(`/${API_PREFIX}/albums/${albumId}/categories`) .send({ translations: [{ locale: 'zh-CN', name: '匿名分类' }] }); expect(anon.status).toBe(401); expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED); }); });