Files
photo-live/services/api/test/public.e2e-spec.ts
T
2026-09-17 23:33:57 +08:00

373 lines
13 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/public.e2e-spec.ts —— Task 22/23 公开读端点 e2e(先写后实现)。
// 真实 app + MySQL(Prisma 直种)+ 真实 MinIO(storage.put 真 JPEG:banner 与
// thumb/browse 变体对象)。公开端点匿名访问(契约 security: []):全程不带
// Authorization 头——若误挂 SessionGuard 会以 401 暴露,测试即失败。
// Task 22 覆盖(getPublicAlbum):
// · locale 解析:en/zh-CN 精确命中;缺省 → defaultLocale;翻译缺失 → 回退
// defaultLocale(需求 4.7);非法 locale('fr')→ 静默回退(Task 5 决策,
// 不报 400);
// · 可见性:offline / publicEnabled=false / 幽灵 token → 404 ALBUM_NOT_PUBLIC;
// · categories:按请求 locale 解析名称、sortOrder 升序;分类缺 resolved 语言
// 条目时按 defaultLocale 回退;
// · banner:album_assets(kind=banner) + 真实对象 → 签名 URL 可取回真实字节、
// expiresAt 晚于现在;无资产行 → null;
// · viewCount P1a 占位 null;startAt/endAt ISO + timezone 原样返回。
import { randomBytes } from 'node:crypto';
import { type INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import {
createStorageFromEnv,
type MinioStorageAdapter,
} from '@photo/storage';
import { AppCnModule } from '../src/app-cn.module';
import { API_PREFIX } from '../src/shared/constants';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */
function makePublicToken(): string {
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
let token = '';
const bytes = randomBytes(24);
for (let i = 0; i < 24; i++) {
token += alphabet[bytes[i] % alphabet.length];
}
return token;
}
/** 测试内生成真实 JPEG(无二进制入库,同 worker publish e2e)。 */
async function makeJpeg(
width: number,
height: number,
): Promise<Buffer> {
return sharp({
create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } },
})
.jpeg({ quality: 85 })
.toBuffer();
}
const KEY_ROOT = 'photo-live/dev';
interface SeedAlbumOptions {
alias: string;
defaultLocale?: string;
translations: Array<{
locale: string;
title: string;
description?: string;
venue?: string;
address?: string;
}>;
status?: string;
publicEnabled?: boolean;
timezone?: string;
startAt?: Date;
endAt?: Date | null;
}
describe('public (e2e)', () => {
let app: INestApplication;
let prisma: PrismaClient;
let storage: MinioStorageAdapter;
/** 经 Prisma 直种相册 + 翻译,返回完整行(publicToken 由调用方持久化使用)。 */
async function seedAlbum(
options: SeedAlbumOptions,
): Promise<{ id: string; publicToken: string }> {
const album = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId: `u-${options.alias}`,
startAt: options.startAt ?? new Date('2026-10-01T02:00:00.000Z'),
endAt:
options.endAt === undefined
? new Date('2026-10-01T12:00:00.000Z')
: options.endAt,
timezone: options.timezone ?? 'Asia/Shanghai',
status: options.status ?? 'active',
defaultLocale: options.defaultLocale ?? 'zh-CN',
publicEnabled: options.publicEnabled ?? true,
},
select: { id: true, publicToken: true },
});
await prisma.album_translations.createMany({
data: options.translations.map((t) => ({
albumId: album.id,
locale: t.locale,
title: t.title,
description: t.description ?? null,
venue: t.venue ?? null,
address: t.address ?? null,
})),
});
return album;
}
/** 直种分类 + 翻译,返回 id。 */
async function seedCategory(
albumId: string,
sortOrder: number,
translations: Array<{ locale: string; name: string }>,
): Promise<string> {
const category = await prisma.categories.create({
data: { albumId, sortOrder },
select: { id: true },
});
await prisma.category_translations.createMany({
data: translations.map((t) => ({
categoryId: category.id,
locale: t.locale,
name: t.name,
})),
});
return category.id;
}
function getPublic(token: string, query?: Record<string, string>) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/public/albums/${token}`)
.query(query ?? {});
}
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();
storage = createStorageFromEnv();
});
afterAll(async () => {
await app.close();
await prisma.$disconnect();
});
describe('Task 22:GET /public/albums/{token}(getPublicAlbum)', () => {
let mainToken: string;
let fallbackToken: string;
let offlineToken: string;
let disabledToken: string;
let bannerToken: string;
let noBannerToken: string;
let catAId: string;
let catBId: string;
let catZhOnlyId: string;
beforeAll(async () => {
// 主相册:zh-CN(default)+en 双语 + 三分类(含一个仅 zh 的分类)。
const main = await seedAlbum({
alias: 'pub-main',
translations: [
{
locale: 'zh-CN',
title: '2026 公司年会',
description: '年度盛会',
venue: '大宴会厅',
address: '上海市浦东新区世纪大道 100 号',
},
{
locale: 'en',
title: 'Annual Party 2026',
description: 'Yearly gala',
venue: 'Grand Ballroom',
address: '100 Century Ave, Pudong, Shanghai',
},
],
});
mainToken = main.publicToken;
catAId = await seedCategory(main.id, 1, [
{ locale: 'zh-CN', name: '开场' },
{ locale: 'en', name: 'Opening' },
]);
catBId = await seedCategory(main.id, 2, [
{ locale: 'zh-CN', name: '颁奖' },
{ locale: 'en', name: 'Awards' },
]);
catZhOnlyId = await seedCategory(main.id, 0, [
{ locale: 'zh-CN', name: '签到墙' },
]);
// 回退相册:仅 zh-CN 翻译(defaultLocale=zh-CN)。
const fallback = await seedAlbum({
alias: 'pub-fallback',
translations: [{ locale: 'zh-CN', title: '只有中文的相册' }],
});
fallbackToken = fallback.publicToken;
const offline = await seedAlbum({
alias: 'pub-offline',
status: 'offline',
translations: [{ locale: 'zh-CN', title: '已下线相册' }],
});
offlineToken = offline.publicToken;
const disabled = await seedAlbum({
alias: 'pub-disabled',
publicEnabled: false,
translations: [{ locale: 'zh-CN', title: '未公开相册' }],
});
disabledToken = disabled.publicToken;
// banner 相册:album_assets(kind=banner) + 真实 MinIO 对象(fixture 直种,
// 上传链路属 P1b)。
const banner = await seedAlbum({
alias: 'pub-banner',
translations: [
{ locale: 'zh-CN', title: '有封面的相册' },
{ locale: 'en', title: 'Album With Banner' },
],
});
bannerToken = banner.publicToken;
const bannerKey = `${KEY_ROOT}/albums/${banner.id}/assets/banner.jpg`;
await storage.put(bannerKey, await makeJpeg(1200, 400), 'image/jpeg');
await prisma.album_assets.create({
data: { albumId: banner.id, kind: 'banner', objectKey: bannerKey },
});
const noBanner = await seedAlbum({
alias: 'pub-nobanner',
translations: [{ locale: 'zh-CN', title: '无封面相册' }],
});
noBannerToken = noBanner.publicToken;
});
it('locale=en → 200 translation=en 内容且 resolvedLocale=en;categories 同语言解析且按 sortOrder 升序', async () => {
const res = await getPublic(mainToken, { locale: 'en' });
expect(res.status).toBe(200);
const body = res.body as Record<string, unknown>;
expect(body.resolvedLocale).toBe('en');
expect(body.defaultLocale).toBe('zh-CN');
expect(body.translation).toMatchObject({
locale: 'en',
title: 'Annual Party 2026',
description: 'Yearly gala',
venue: 'Grand Ballroom',
address: '100 Century Ave, Pudong, Shanghai',
});
const categories = body.categories as Array<Record<string, unknown>>;
expect(categories.map((c) => c.id)).toEqual([
catZhOnlyId,
catAId,
catBId,
]);
// zh-only 分类在 en 请求下按 defaultLocale 回退出名称。
expect(categories.map((c) => c.name)).toEqual([
'签到墙',
'Opening',
'Awards',
]);
expect(categories.map((c) => c.sortOrder)).toEqual([0, 1, 2]);
});
it('locale=zh-CN → zh 内容;无 locale → defaultLocale(zh-CN)', async () => {
const zh = await getPublic(mainToken, { locale: 'zh-CN' });
expect(zh.status).toBe(200);
expect(zh.body.resolvedLocale).toBe('zh-CN');
expect(zh.body.translation).toMatchObject({
locale: 'zh-CN',
title: '2026 公司年会',
});
expect(
(zh.body.categories as Array<{ name: string }>).map((c) => c.name),
).toEqual(['签到墙', '开场', '颁奖']);
const none = await getPublic(mainToken);
expect(none.status).toBe(200);
expect(none.body.resolvedLocale).toBe('zh-CN');
expect(none.body.translation).toMatchObject({
locale: 'zh-CN',
title: '2026 公司年会',
});
});
it('请求语言无翻译条目 → 回退 defaultLocale 内容且 resolvedLocale=defaultLocale(需求 4.7)', async () => {
const res = await getPublic(fallbackToken, { locale: 'en' });
expect(res.status).toBe(200);
expect(res.body.resolvedLocale).toBe('zh-CN');
expect(res.body.translation).toMatchObject({
locale: 'zh-CN',
title: '只有中文的相册',
});
});
it('非法 locale(fr)→ 200 静默回退 defaultLocale(Task 5 决策,不报 400)', async () => {
const res = await getPublic(mainToken, { locale: 'fr' });
expect(res.status).toBe(200);
expect(res.body.resolvedLocale).toBe('zh-CN');
expect(res.body.translation).toMatchObject({
locale: 'zh-CN',
title: '2026 公司年会',
});
});
it('offline / publicEnabled=false / 幽灵 token → 404 ALBUM_NOT_PUBLIC(同码防枚举)', async () => {
for (const token of [offlineToken, disabledToken, 'no-such-token-xyz']) {
const res = await getPublic(token);
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
expect(typeof res.body.requestId).toBe('string');
}
});
it('banner:签名 URL 可取回真实对象且 expiresAt 未来;无资产行 → null', async () => {
const withBanner = await getPublic(bannerToken);
expect(withBanner.status).toBe(200);
const banner = withBanner.body.banner as {
url: string;
expiresAt: string;
};
expect(banner).not.toBeNull();
expect(Date.parse(banner.expiresAt)).toBeGreaterThan(Date.now());
// 预签名 GET 直取 MinIO:真实字节 + JPEG content-type。
const fetched = await fetch(banner.url);
expect(fetched.status).toBe(200);
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
const bytes = Buffer.from(await fetched.arrayBuffer());
expect(bytes.length).toBeGreaterThan(0);
const without = await getPublic(noBannerToken);
expect(without.status).toBe(200);
expect(without.body.banner).toBeNull();
});
it('viewCount P1a 占位 null;startAt/endAt ISO + timezone 返回', async () => {
const res = await getPublic(mainToken);
expect(res.status).toBe(200);
expect(res.body.viewCount).toBeNull();
expect(res.body.timezone).toBe('Asia/Shanghai');
expect(res.body.startAt).toBe('2026-10-01T02:00:00.000Z');
expect(res.body.endAt).toBe('2026-10-01T12:00:00.000Z');
expect(Number.isNaN(Date.parse(res.body.startAt))).toBe(false);
expect(Number.isNaN(Date.parse(res.body.endAt))).toBe(false);
// 契约 PublicAlbum 必返字段全集(形状精确断言)。
expect(Object.keys(res.body).sort()).toEqual([
'banner',
'categories',
'defaultLocale',
'endAt',
'publicToken',
'resolvedLocale',
'startAt',
'timezone',
'translation',
'viewCount',
]);
expect(res.body.publicToken).toMatch(/^[0-9a-z]{20,32}$/);
});
});
});