Files
photo-live/services/api/test/albums.e2e-spec.ts
T
浅念 1c48a4108b feat(api): album creation with translations and owner membership
Task 15: POST/GET /v1/albums, GET /v1/albums/{id}.
- create: single transaction writes albums + album_translations +
  album_members(owner) + audit_logs(album.create); publicToken 24-char
  [0-9a-z] with one P2002 retry; duplicate-locale input rejected as 400.
- dto: class-validator per contract AlbumCreateRequest, incl. cross-field
  rules (defaultLocale must be covered by translations; endAt>startAt when
  present; timezone string-only per P1a scope).
- get: PermissionsService.canViewAlbum gate -> 404 ALBUM_NOT_FOUND for
  non-member/nonexistent alike (anti-enumeration); admin bypass; joins
  categories (empty until Task 17).
- list: admin sees all, members see joined albums; stable createdAt DESC,
  id DESC keyset pagination via new shared/cursor.ts (encode/decode +
  typed where-builder, designed for Task 23 photo list reuse).
- e2e: services/api/test/albums.e2e-spec.ts (12 cases, seeded sessions).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-17 21:51:50 +08:00

430 lines
16 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();
});
});