Files
photo-live/services/api/test/albums.e2e-spec.ts
T
浅念 1faefa4227 feat(api): album update with independent upload/public switches
PATCH /v1/albums/{albumId}(契约 updateAlbum):部分更新,各开关互相独立;
translations 全量替换(deleteMany+createMany),defaultLocale 可同请求切换但
替换后的翻译集必须覆盖最终 defaultLocale。权限顺序 404(不可见)→403(可见
不可管)与 getAlbum 一致防枚举;合并校验(endAt vs 存量 startAt、单边提交)
在事务内对最终状态裁决。audit album.update 记录 metadata.fields(契约属性序)。

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

691 lines
26 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/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<startAt /
// 未认证 → 400/400/400/401;
// 详情:owner 200 全量;非成员 staff 404 ALBUM_NOT_FOUND(防枚举);admin 200;
// 不存在的 id 404;
// 列表:仅本成员相册(他人相册不可见);admin 全量;(createdAt,id) 游标分页
// created-desc、无重复、hasMore/nextCursor 成对;limit 非法/游标非法 400。
// 分页确定性:三个相册创建后经 Prisma 显式回写互不相同的 createdAt(排序键
// 与真实写入时序解耦),游标语义按回写时间断言。
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';
/** 契约 AlbumSummary 的必返字段全集(列表条目形状精确断言用)。 */
const ALBUM_SUMMARY_KEYS = [
'createdAt',
'defaultLocale',
'downloadEnabled',
'endAt',
'id',
'originalDownloadEnabled',
'ownerId',
'publicEnabled',
'publicToken',
'startAt',
'status',
'timezone',
'translations',
'updatedAt',
'uploadEnabled',
'watermarkVersion',
].sort();
const PAGINATION_BASE = '2026-09-10T';
describe('albums (e2e)', () => {
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<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);
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<string, string | number>) {
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<string, unknown>;
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<Record<string, unknown>>;
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<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;
};
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);
});
});
});