2026-09-17 21:51:50 +08:00
|
|
|
|
// 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';
|
|
|
|
|
|
|
2026-09-21 14:09:35 +08:00
|
|
|
|
/** 契约 AlbumSummary 的必返字段全集(列表条目形状精确断言用;UI-01 增 cover)。 */
|
2026-09-17 21:51:50 +08:00
|
|
|
|
const ALBUM_SUMMARY_KEYS = [
|
2026-09-21 14:09:35 +08:00
|
|
|
|
'cover',
|
2026-09-17 21:51:50 +08:00
|
|
|
|
'createdAt',
|
|
|
|
|
|
'defaultLocale',
|
|
|
|
|
|
'downloadEnabled',
|
|
|
|
|
|
'endAt',
|
|
|
|
|
|
'id',
|
|
|
|
|
|
'originalDownloadEnabled',
|
|
|
|
|
|
'ownerId',
|
|
|
|
|
|
'publicEnabled',
|
|
|
|
|
|
'publicToken',
|
|
|
|
|
|
'startAt',
|
2026-09-18 18:45:43 +08:00
|
|
|
|
'statsSyncedAt',
|
2026-09-17 21:51:50 +08:00
|
|
|
|
'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();
|
|
|
|
|
|
});
|
2026-09-17 22:05:36 +08:00
|
|
|
|
|
|
|
|
|
|
// ── 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
2026-09-17 21:51:50 +08:00
|
|
|
|
});
|