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:
@@ -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<AlbumResponse> {
|
||||
return this.albumsService.get(userOf(req), albumId);
|
||||
}
|
||||
|
||||
@Patch(':albumId')
|
||||
updateAlbum(
|
||||
@Param('albumId') albumId: string,
|
||||
@Body() dto: AlbumUpdateRequestDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<AlbumResponse> {
|
||||
return this.albumsService.update(userOf(req), albumId, dto);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<AlbumResponse> {
|
||||
// 契约 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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user