feat(api): public album info with locale fallback

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 23:33:57 +08:00
parent 94357e9f03
commit 6863c736c3
8 changed files with 691 additions and 5 deletions
+372
View File
@@ -0,0 +1,372 @@
// 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}$/);
});
});
});