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>
This commit is contained in:
浅念
2026-09-17 22:05:36 +08:00
parent 1c48a4108b
commit 1faefa4227
4 changed files with 544 additions and 12 deletions
+261
View File
@@ -426,4 +426,265 @@ describe('albums (e2e)', () => {
});
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);
});
});
});