591 lines
22 KiB
TypeScript
591 lines
22 KiB
TypeScript
|
|
// 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);
|
|||
|
|
});
|
|||
|
|
});
|