From 1faefa42278f9c702235630a272fecd11f0658b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 17 Sep 2026 22:05:36 +0800 Subject: [PATCH] feat(api): album update with independent upload/public switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/api/src/albums/albums.controller.ts | 34 ++- services/api/src/albums/albums.service.ts | 173 +++++++++++- services/api/src/albums/dto.ts | 88 ++++++- services/api/test/albums.e2e-spec.ts | 261 +++++++++++++++++++ 4 files changed, 544 insertions(+), 12 deletions(-) diff --git a/services/api/src/albums/albums.controller.ts b/services/api/src/albums/albums.controller.ts index 7a3f5fe..be33752 100644 --- a/services/api/src/albums/albums.controller.ts +++ b/services/api/src/albums/albums.controller.ts @@ -1,6 +1,6 @@ -// services/api/src/albums/albums.controller.ts —— /albums 端点(Task 15)。 -// 方法名与契约 operationId 一一对应(createAlbum/listAlbums/getAlbum; -// updateAlbum 属 Task 16)。控制器级 SessionGuard:三端点全需认证 +// services/api/src/albums/albums.controller.ts —— /albums 端点(Task 15;Task 16 +// 增补 updateAlbum)。方法名与契约 operationId 一一对应(createAlbum/ +// listAlbums/getAlbum/updateAlbum)。控制器级 SessionGuard:四端点全需认证 // (契约均有 bearerAuth、无 security: [] 例外)。职责仅剩: // · 透传认证用户(req.user,守卫挂载); // · 查询参数规整(limit 1..100 整数、cursor 长度),非法 → 400 @@ -12,6 +12,7 @@ import { Get, HttpStatus, Param, + Patch, Post, Query, Req, @@ -28,11 +29,14 @@ import { type AlbumResponse, AlbumsService, } from './albums.service'; -import { AlbumCreateRequestDto } from './dto'; +import { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto'; import { MAX_CURSOR_LENGTH } from '../shared/cursor'; -/** 守卫放行后提取 req.user;防御性兜底(守卫未挂载属装配错误,按未认证拒绝)。 */ -function userOf(req: AuthenticatedRequest): AuthUser { +/** + * 守卫放行后提取 req.user;防御性兜底(守卫未挂载属装配错误,按未认证拒绝)。 + * members/categories 控制器(Task 17)复用同一判定,导出。 + */ +export function userOf(req: AuthenticatedRequest): AuthUser { if (req.user === undefined) { throw new ApiException( ErrorCodes.AUTH_TOKEN_EXPIRED, @@ -43,8 +47,9 @@ function userOf(req: AuthenticatedRequest): AuthUser { return req.user; } -/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。 */ -function parseLimit(raw: string | undefined): number { +/** limit 查询参数:缺省 20,须为 1..100 的十进制整数(契约 bounds)。导出供 + * members 列表(Task 17,同契约 bounds)复用。 */ +export function parseLimit(raw: string | undefined): number { if (raw === undefined || raw === '') { return DEFAULT_ALBUM_PAGE_SIZE; } @@ -66,7 +71,9 @@ function parseLimit(raw: string | undefined): number { return limit; } -function parseCursor(raw: string | undefined): string | undefined { +/** cursor 查询参数:仅做长度规整(语义校验在服务层解码时);导出供 members + * 列表(Task 17)复用。 */ +export function parseCursor(raw: string | undefined): string | undefined { if (raw === undefined || raw === '') { return undefined; } @@ -113,4 +120,13 @@ export class AlbumsController { ): Promise { return this.albumsService.get(userOf(req), albumId); } + + @Patch(':albumId') + updateAlbum( + @Param('albumId') albumId: string, + @Body() dto: AlbumUpdateRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.albumsService.update(userOf(req), albumId, dto); + } } diff --git a/services/api/src/albums/albums.service.ts b/services/api/src/albums/albums.service.ts index a4729a8..4b7365d 100644 --- a/services/api/src/albums/albums.service.ts +++ b/services/api/src/albums/albums.service.ts @@ -27,7 +27,7 @@ import { encodeKeysetCursor, keysetWhereDesc, } from '../shared/cursor'; -import type { AlbumCreateRequestDto } from './dto'; +import type { AlbumCreateRequestDto, AlbumUpdateRequestDto } from './dto'; import { PermissionsService } from './permissions.service'; export type AlbumTranslationResponse = @@ -43,6 +43,22 @@ export const MAX_ALBUM_PAGE_SIZE = 100; const PUBLIC_TOKEN_LENGTH = 24; const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; +/** + * updateAlbum 审计 metadata.fields 的字段名序列(契约 AlbumUpdateRequest + * 属性序):固定顺序保证同形请求产出同形审计,便于比对/去重。 + */ +const UPDATE_FIELD_ORDER = [ + 'translations', + 'defaultLocale', + 'startAt', + 'endAt', + 'timezone', + 'uploadEnabled', + 'publicEnabled', + 'downloadEnabled', + 'originalDownloadEnabled', +] as const satisfies readonly (keyof AlbumUpdateRequestDto)[]; + function generatePublicToken(): string { const bytes = randomBytes(PUBLIC_TOKEN_LENGTH); let token = ''; @@ -231,6 +247,161 @@ export class AlbumsService { return album; } + /** + * 部分更新(契约 updateAlbum):仅提交的字段被修改,各开关互相独立; + * translations 全量替换(deleteMany+createMany,提交即整组换血)。 + * 权限判定顺序与 get/契约一致,防 id 枚举: + * 1. canViewAlbum=false → 404(非成员与非存在同码); + * 2. canManageAlbum=false → 403(协作者可见不可管); + * 3. 事务内再读行判存在(admin 视角 canView 恒真,空值兜底 404)。 + * minProperties:1(空体 400)与跨字段合并校验(endAt vs 存量 startAt、 + * defaultLocale vs 存量翻译行)都在事务内对「合并后的最终状态」裁决—— + * 单边提交与同请求提交两种路径同一出口。事务:更新标量 + 替换翻译 + + * audit(album.update, metadata.fields=本次提交字段名)。 + */ + async update( + user: AuthUser, + albumId: string, + dto: AlbumUpdateRequestDto, + ): Promise { + // 契约 minProperties:1。注意不能用 Object.keys(dto).length:经 + // plainToInstance 后 DTO 声明的属性以 undefined 值成为自有键,空体同样 + // 「有键」;以「存在任一已赋值字段」为空体判据(whitelist 已剥离未知 + // 字段,只含未知字段的请求体同样按空体 400)。属性级装饰器在空体时 + // 不运行(DTO 层无法表达 minProperties),在此拒绝——与创建路径的重复 + // locale 检查同一 400 出口。 + const fields = UPDATE_FIELD_ORDER.filter( + (key) => dto[key] !== undefined, + ) as string[]; + if (fields.length === 0) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + '请求体至少需要包含一个待更新字段', + HttpStatus.BAD_REQUEST, + ); + } + if (dto.translations !== undefined) { + // 同 create:重复 locale 会在事务内 createMany 撞唯一索引变 500,先 400。 + const locales = dto.translations.map((t) => t.locale); + if (new Set(locales).size !== locales.length) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'translations 存在重复 locale', + HttpStatus.BAD_REQUEST, + ); + } + } + + if (!(await this.permissions.canViewAlbum(user, albumId))) { + throw albumNotFound(); + } + if (!(await this.permissions.canManageAlbum(user, albumId))) { + throw new ApiException( + ErrorCodes.AUTH_FORBIDDEN, + '仅创建者或管理员可修改相册', + HttpStatus.FORBIDDEN, + ); + } + + await this.prisma.$transaction(async (tx) => { + const album = await tx.albums.findUnique({ where: { id: albumId } }); + if (album === null) { + throw albumNotFound(); + } + + // 合并校验①:endAt(给值时)晚于合并后的 startAt(单边提交对比存量)。 + const startAt = dto.startAt !== undefined ? new Date(dto.startAt) : album.startAt; + const endAt = + dto.endAt === undefined + ? album.endAt + : dto.endAt === null + ? null + : new Date(dto.endAt); + if (endAt !== null && endAt.getTime() <= startAt.getTime()) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'endAt 必须晚于 startAt', + HttpStatus.BAD_REQUEST, + ); + } + + // 合并校验②:合并后的 defaultLocale 必须被合并后的翻译集覆盖。 + const defaultLocale = dto.defaultLocale ?? album.defaultLocale; + if (dto.translations !== undefined) { + if (!dto.translations.some((t) => t.locale === defaultLocale)) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'translations 必须包含(更新后的)defaultLocale 对应语言的条目', + HttpStatus.BAD_REQUEST, + ); + } + } else if (dto.defaultLocale !== undefined) { + // 只切 defaultLocale:存量翻译行必须已含新语言。 + const rows = await tx.album_translations.findMany({ + where: { albumId }, + select: { locale: true }, + }); + if (!rows.some((r) => r.locale === defaultLocale)) { + throw new ApiException( + ErrorCodes.VALIDATION_FAILED, + 'defaultLocale 必须有对应翻译条目', + HttpStatus.BAD_REQUEST, + ); + } + } + + await tx.albums.update({ + where: { id: albumId }, + data: { + startAt: dto.startAt === undefined ? undefined : new Date(dto.startAt), + endAt: + dto.endAt === undefined + ? undefined + : dto.endAt === null + ? null + : new Date(dto.endAt), + timezone: dto.timezone, + defaultLocale: dto.defaultLocale, + uploadEnabled: dto.uploadEnabled, + publicEnabled: dto.publicEnabled, + downloadEnabled: dto.downloadEnabled, + originalDownloadEnabled: dto.originalDownloadEnabled, + }, + }); + if (dto.translations !== undefined) { + await tx.album_translations.deleteMany({ where: { albumId } }); + await tx.album_translations.createMany({ + data: dto.translations.map((t) => ({ + albumId, + locale: t.locale, + title: t.title, + description: t.description ?? null, + venue: t.venue ?? null, + address: t.address ?? null, + })), + }); + } + await tx.audit_logs.create({ + data: { + actorId: user.id, + action: 'album.update', + resourceId: albumId, + metadata: { fields }, + }, + }); + }); + + const album = await this.loadAlbum(albumId); + if (album === null) { + throw new ApiException( + ErrorCodes.INTERNAL_ERROR, + 'Internal server error', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + return album; + } + /** 相册列表:admin 全量;否则成员行(status=active)对应的相册。 */ async list( user: AuthUser, diff --git a/services/api/src/albums/dto.ts b/services/api/src/albums/dto.ts index 0569f1e..9c8f2fc 100644 --- a/services/api/src/albums/dto.ts +++ b/services/api/src/albums/dto.ts @@ -1,5 +1,5 @@ -// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15)。 -// 校验约束对齐契约 AlbumCreateRequest / AlbumTranslation: +// services/api/src/albums/dto.ts —— 相册端点入参 DTO(Task 15;Task 16 增补更新版)。 +// 校验约束对齐契约 AlbumCreateRequest / AlbumUpdateRequest / AlbumTranslation: // · translations 至少 1 条(minItems: 1),locale 限 P1a 双语枚举; // · defaultLocale 必须有对应翻译条目(跨字段校验,契约 required 语义); // · endAt 可空(契约 [string,'null']),给值时须晚于 startAt(跨字段校验); @@ -138,3 +138,87 @@ export class AlbumCreateRequestDto { @IsBoolean() originalDownloadEnabled?: boolean; } + +/** + * 更新版 defaultLocale 覆盖约束(契约 AlbumUpdateRequest): + * 仅当同一请求里 defaultLocale 与 translations 同时出现时裁决—— + * defaultLocale 未提交时本约束放行(服务层对存量 defaultLocale 做合并校验, + * 覆盖「只换 translations」与「只切 defaultLocale」两种单边情形)。 + */ +@ValidatorConstraint({ name: 'albumUpdateDefaultLocaleCovered', async: false }) +class UpdateDefaultLocaleCoveredConstraint + implements ValidatorConstraintInterface +{ + validate(value: unknown, args: ValidationArguments): boolean { + if (value === undefined || value === null) { + return true; // 本次请求不改 defaultLocale。 + } + const { translations } = args.object as { translations?: unknown }; + if (!Array.isArray(translations) || translations.length === 0) { + return true; // translations 不在本次请求里——交服务层对存量行校验。 + } + return translations.some( + (entry) => + entry !== null && + typeof entry === 'object' && + (entry as { locale?: unknown }).locale === value, + ); + } + + defaultMessage(): string { + return 'translations 必须包含(更新后的)defaultLocale 对应语言的条目'; + } +} + +/** + * 契约 AlbumUpdateRequest:全部字段可选 + minProperties:1。 + * minProperties 无法用 class-validator 内建表达(空体时任何属性级装饰器都 + * 不会运行),空体拒绝由服务层 update() 入口承担(与创建路径的重复 locale + * 检查同一模式);跨字段约束分两层: + * · DTO 层——同请求内 endAt>startAt(复用创建版约束:startAt 缺席时放行)、 + * defaultLocale+translations 同请求时的覆盖关系; + * · 服务层——与存量行合并后的时间先后 / defaultLocale 覆盖(单边提交场景: + * 只改 endAt 对比存量 startAt、只切 defaultLocale 对比存量翻译行)。 + */ +export class AlbumUpdateRequestDto { + @IsOptional() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => AlbumTranslationDto) + translations?: AlbumTranslationDto[]; + + @IsOptional() + @IsIn(ALBUM_LOCALES) + @Validate(UpdateDefaultLocaleCoveredConstraint) + defaultLocale?: AlbumLocale; + + @IsOptional() + @IsISO8601({ strict: true }) + startAt?: string; + + @IsOptional() + @IsISO8601({ strict: true }) + @Validate(EndAfterStartConstraint) + endAt?: string | null; + + @IsOptional() + @IsString() + @MaxLength(TIMEZONE_MAX_LENGTH) + timezone?: string; + + @IsOptional() + @IsBoolean() + uploadEnabled?: boolean; + + @IsOptional() + @IsBoolean() + publicEnabled?: boolean; + + @IsOptional() + @IsBoolean() + downloadEnabled?: boolean; + + @IsOptional() + @IsBoolean() + originalDownloadEnabled?: boolean; +} diff --git a/services/api/test/albums.e2e-spec.ts b/services/api/test/albums.e2e-spec.ts index 3eb581e..b5b97b4 100644 --- a/services/api/test/albums.e2e-spec.ts +++ b/services/api/test/albums.e2e-spec.ts @@ -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 => { + 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); + }); + }); });