Files
photo-live/services/api/test/members-categories.e2e-spec.ts
T
浅念 e04e1f3bdf feat(api): members and categories management
成员(/v1/albums/{albumId}/members):owner/管理员邀请已开通同事为协作者
(重复 409 MEMBER_CONFLICT、owner 目标 400、未开通/停用账号 400)、成员列表
((addedAt,id) DESC 键集分页)、移除协作者(幂等 204;成员行是唯一权限来源,
删除行即失去可见性 A02;owner 不可移除 403)。分类(/albums/{albumId}/categories
与 /categories/{categoryId}):创建/部分更新/删除,translations 全量替换,
相册详情按 sortOrder 升序带出;404 统一 ALBUM_NOT_FOUND(分类不存在与相册
不可见同码)。审计 album.member.add/remove、album.category.add/update/remove
(resourceId=albumId,metadata 记目标 id/字段)。共用错误/P2002 判定抽至
support.ts(修复 meta.target 为 DB 约束名时的误判)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 22:16:16 +08:00

591 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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';
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<string> => {
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<string, string | number>,
) =>
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);
});
});