Files
photo-live/services/api/test/public.e2e-spec.ts
T
浅念 2bb6f291d3 feat(ov,viewer): two-tier download with wechat guidance
P4a Task 4(T12 收口):ov 域公开照片下载端点 + viewer 下载 UI。

- interactions 增 downloads:GET /public/albums/{token}/photos/{photoId}
  /download?variant=browse|original。两级开关(A18):browse 需
  downloadEnabled;original 需叠加 originalDownloadEnabled(级联,browse 关
  则两者均 403 DOWNLOAD_DISABLED)。水印相册 browse 现签 watermark pattern
  key(browse-key 解析抽取 shared/ov-browse-key 与 public-ov 复用);原图
  现签 originalObjectKey(SIGNED_URL_TTL)。来源 IP 固定窗限速
  (DOWNLOAD_WINDOW_REQUESTS/DOWNLOAD_WINDOW_SECONDS,env 可调);零浏览
  计数写入(排除规则)。STORAGE_PORT_OV 经新 StorageOvModule 与 public-ov
  共享同一实例。
- 契约微调登记:PublicAlbum 增 readOnly 必返 downloadEnabled/
  originalDownloadEnabled(cn+ov 同形返回;public e2e 精确键断言同步)。
- viewer:lightbox 下载入口随开关渲染(无/单按钮/分体菜单两选项)、过期
  凭证静默重取一次、微信内置浏览器(MicroMessenger UA)出保存图片指引
  浮层(复制深链 + 反馈)替代直接下载;i18n zh/en 词条。
- tests:api e2e +7(开关矩阵 + URL/字节级断言、水印键一致、隐藏 404、
  限速 429、零计数行);viewer vitest +9(UA/矩阵/菜单/重取/失败/指引/
  复制);viewer Playwright +3(入口矩阵、微信 UA 浮层、真实端点 200 +
  签名 URL 模式)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 18:32:34 +08:00

761 lines
28 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 原样返回。
// Task 23 覆盖(listPublicPhotos,见下方第二个 describe 块头注)。
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();
}
/**
* cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。
* alias 零_pad 索引参与 ID 字典序:同批创建(时间戳相同或单调)时 ID 升序 ==
* 创建序,为 id DESC 决胜断言提供确定性。
*/
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
const KEY_ROOT = 'photo-live/dev';
/** 与 src/publish.service.ts(worker)variantsObjectKey 同布局的断言字面量。 */
const variantKey = (
albumId: string,
photoId: string,
variant: 'thumb' | 'browse',
) => `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
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 ?? {});
}
function listPublicPhotos(
token: string,
query?: Record<string, string | number>,
) {
return request(app.getHttpServer())
.get(`/${API_PREFIX}/public/albums/${token}/photos`)
.query(query ?? {});
}
/**
* 直种「已发布完成态」照片:photos 行(ready/published/尺寸/entityVersion=2,
* 对齐 worker 发布事务落库形状)+ 两条 photo_variants + MinIO 真实变体对象
* (thumb 480x300 / browse 800x500,键布局同 worker publish.service)。
* visibility/publishedAt 可覆写(hidden/deleted/unpublished/同批时间戳用例)。
*/
async function seedPhoto(
alias: string,
albumId: string,
options: {
categoryId?: string | null;
visibility?: string;
publishedAt?: Date;
withVariants?: boolean; // false = 连变体行/对象都不种(防御性跳过用例)
} = {},
): Promise<string> {
const photoId = cuidLike(alias);
const [thumb, browse] = await Promise.all([
makeJpeg(480, 300),
makeJpeg(800, 500),
]);
if (options.withVariants !== false) {
await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg');
await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg');
}
await prisma.photos.create({
data: {
id: photoId,
albumId,
uploaderId: `u-${alias}`,
categoryId: options.categoryId ?? null,
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
bytes: browse.length,
width: 800,
height: 500,
uploadedAt: new Date(),
publishedAt: options.publishedAt ?? new Date(),
processingState: 'ready',
visibility: options.visibility ?? 'published',
entityVersion: 2,
},
});
if (options.withVariants !== false) {
await prisma.photo_variants.createMany({
data: [
{
photoId,
variant: 'thumb',
objectKey: variantKey(albumId, photoId, 'thumb'),
bytes: thumb.length,
version: 1,
width: 480,
height: 300,
},
{
photoId,
variant: 'browse',
objectKey: variantKey(albumId, photoId, 'browse'),
bytes: browse.length,
version: 1,
width: 800,
height: 500,
},
],
});
}
return photoId;
}
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, state=effective) + 真实 MinIO
// 对象(fixture 直种;P1b Task 3 起公开查询过滤 state='effective',且
// 走语言偏好解析——此行为全局(locale NULL),任何 resolved locale 都回退到它)。
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',
locale: null,
objectKey: bannerKey,
state: 'effective',
},
});
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 必返字段全集(形状精确断言;P4a Task 4 增下载开关)。
expect(Object.keys(res.body).sort()).toEqual([
'banner',
'categories',
'defaultLocale',
'downloadEnabled',
'endAt',
'originalDownloadEnabled',
'publicToken',
'resolvedLocale',
'startAt',
'timezone',
'translation',
'viewCount',
]);
expect(res.body.publicToken).toMatch(/^[0-9a-z]{20,32}$/);
});
});
// Task 23:GET /public/albums/{token}/photos(listPublicPhotos)。
// 35 张已发布照片跨两分类(catA 20 张 / catB 15 张),publishedAt 显式回写
// 互不相同的分钟(排序与真实写入时序解耦);另种 hidden/deleted/unpublished
// 各一张(带完整变体)验证永不出现。变体对象为 MinIO 真实 JPEG。
// 分页确定性:ID 内嵌零_pad 索引(cuidLike),同批创建下 ID 升序==创建序,
// id DESC 决胜可精确断言。
describe('Task 23:GET /public/albums/{token}/photos(listPublicPhotos)', () => {
const PAGINATION_BASE = Date.parse('2026-09-12T08:00:00.000Z');
let feedToken: string;
let feedAlbumId: string;
let catAId: string;
let catBId: string;
let feedIds: string[] = []; // indices 0..34,publishedAt 升序(索引大=新)
beforeAll(async () => {
const feed = await seedAlbum({
alias: 'pub-feed',
translations: [
{ locale: 'zh-CN', title: '照片流相册' },
{ locale: 'en', title: 'Photo Feed Album' },
],
});
feedAlbumId = feed.id;
feedToken = feed.publicToken;
catAId = await seedCategory(feed.id, 1, [
{ locale: 'zh-CN', name: '日场' },
{ locale: 'en', name: 'Day' },
]);
catBId = await seedCategory(feed.id, 2, [
{ locale: 'zh-CN', name: '晚场' },
{ locale: 'en', name: 'Night' },
]);
// 35 张:i=0..34,前 20 张 catA、后 15 张 catB;publishedAt = base + i 分钟。
const ids: string[] = [];
for (let i = 0; i < 35; i++) {
ids.push(
await seedPhoto(`pl${String(i).padStart(2, '0')}`, feedAlbumId, {
categoryId: i < 20 ? catAId : catBId,
publishedAt: new Date(PAGINATION_BASE + i * 60_000),
}),
);
}
feedIds = ids;
// 同 publishedAt 批次:索引 10..14 五张回写同一时间戳(第 12 张的),
// 该批次在排序中连续且按 id DESC(== 索引倒序)决胜。
await prisma.photos.updateMany({
where: { id: { in: ids.slice(10, 15) } },
data: { publishedAt: new Date(PAGINATION_BASE + 12 * 60_000) },
});
// 永不出现的三种状态(带完整变体,排除「无变体被跳过」的干扰归因)。
await seedPhoto('plhidden', feedAlbumId, {
categoryId: catAId,
visibility: 'hidden',
});
await seedPhoto('pldeleted', feedAlbumId, {
categoryId: catAId,
visibility: 'deleted',
});
await seedPhoto('plunpub', feedAlbumId, {
categoryId: catAId,
visibility: 'unpublished',
});
});
it('缺省 limit=30 → 首页 30 条 + nextCursor/hasMore;次页 5 条;两页并集恰 35 张无重复', async () => {
const page1 = await listPublicPhotos(feedToken);
expect(page1.status).toBe(200);
expect(page1.body.items).toHaveLength(30);
expect(page1.body.hasMore).toBe(true);
expect(typeof page1.body.nextCursor).toBe('string');
const page2 = await listPublicPhotos(feedToken, {
cursor: page1.body.nextCursor,
});
expect(page2.status).toBe(200);
expect(page2.body.items).toHaveLength(5);
expect(page2.body.hasMore).toBe(false);
expect(page2.body.nextCursor).toBeNull();
const page1Ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
const page2Ids = page2.body.items.map((p: { photoId: string }) => p.photoId);
const union = [...page1Ids, ...page2Ids];
// 无重复(跨页稳定:键集谓词从不回看已发射行)。
expect(new Set(union).size).toBe(35);
expect(union.sort()).toEqual([...feedIds].sort());
// 首页 = 最新 30 张(索引 34..5),次页 = 最旧 5 张(索引 4..0)。
expect(page2Ids.sort()).toEqual(feedIds.slice(0, 5).sort());
});
it('首页序:publishedAt 降序;末位(第 30 条)为索引 5', async () => {
const page1 = await listPublicPhotos(feedToken);
expect(page1.status).toBe(200);
const ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
// 索引 34..15 publishedAt 互不相同(每分钟一张):严格降序段。
const expectedHead = [];
for (let i = 34; i >= 15; i--) {
expectedHead.push(feedIds[i]);
}
expect(ids.slice(0, 20)).toEqual(expectedHead);
expect(ids[29]).toBe(feedIds[5]);
});
it('同 publishedAt 批次 → id DESC 决胜稳定(批次连续且为索引倒序 14..10)', async () => {
const page1 = await listPublicPhotos(feedToken);
expect(page1.status).toBe(200);
const ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
// 批次时间戳 = base+12min:排序位置在索引 13 之后、11 之前 → 首页内
// 第 22..26 条(索引 34..15 共 20 条之后)。批次内 id DESC == 索引倒序。
expect(ids.slice(20, 25)).toEqual([
feedIds[14],
feedIds[13],
feedIds[12],
feedIds[11],
feedIds[10],
]);
// publishedAt 相等性自检:批次五张确实同时间戳(否则上面断言是巧合)。
const batchRows = await prisma.photos.findMany({
where: { id: { in: feedIds.slice(10, 15) } },
select: { publishedAt: true },
});
expect(
new Set(batchRows.map((r) => (r.publishedAt as Date).getTime())).size,
).toBe(1);
});
it('hidden / deleted / unpublished 照片永不出现', async () => {
const page1 = await listPublicPhotos(feedToken, { limit: 60 });
expect(page1.status).toBe(200);
expect(page1.body.items).toHaveLength(35);
const visibilityRows = await prisma.photos.findMany({
where: { albumId: feedAlbumId },
select: { id: true, visibility: true },
});
const nonPublished = visibilityRows
.filter((r) => r.visibility !== 'published')
.map((r) => r.id);
expect(nonPublished).toHaveLength(3);
const ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
for (const id of nonPublished) {
expect(ids).not.toContain(id);
}
});
it('条目形状:width/height 数字、thumb 签名 URL 可取回真实字节、expiresAt 未来、likeCount 0', async () => {
const page1 = await listPublicPhotos(feedToken, { limit: 1 });
expect(page1.status).toBe(200);
const item = page1.body.items[0] as Record<string, unknown>;
// 契约 PublicPhoto 必返字段全集。
expect(Object.keys(item).sort()).toEqual([
'browse',
'height',
'likeCount',
'photoId',
'publishedAt',
'thumb',
'width',
]);
expect(item.photoId).toBe(feedIds[34]); // 最新一张
expect(typeof item.width).toBe('number');
expect(typeof item.height).toBe('number');
expect(item.width).toBe(800); // browse 变体宽高(列表占位语义)
expect(item.height).toBe(500);
expect(item.likeCount).toBe(0);
expect(Number.isNaN(Date.parse(String(item.publishedAt)))).toBe(false);
const thumb = item.thumb as { url: string; expiresAt: string };
const browse = item.browse as { url: string; expiresAt: string };
for (const signed of [thumb, browse]) {
expect(Date.parse(signed.expiresAt)).toBeGreaterThan(Date.now());
const fetched = await fetch(signed.url);
expect(fetched.status).toBe(200);
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
expect(Buffer.from(await fetched.arrayBuffer()).length).toBeGreaterThan(0);
}
// thumb 与 browse 是不同对象(不同键 → 不同 URL)。
expect(thumb.url).not.toBe(browse.url);
});
it('category 过滤:仅该分类;跨册/幽灵 category → 400 VALIDATION_FAILED', async () => {
const catB = await listPublicPhotos(feedToken, {
category: catBId,
limit: 60,
});
expect(catB.status).toBe(200);
expect(catB.body.items).toHaveLength(15);
// catB = 索引 20..34;降序首条为索引 34。
expect(catB.body.items[0].photoId).toBe(feedIds[34]);
expect(catB.body.items.map((p: { photoId: string }) => p.photoId).sort())
.toEqual(feedIds.slice(20, 35).sort());
// 幽灵分类:契约 400 语义(category 属查询参数校验)。
const ghost = await listPublicPhotos(feedToken, { category: 'no-such-cat' });
expect(ghost.status).toBe(400);
expect(ghost.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 跨册分类:不属于本相册同样 400(不区分幽灵/跨册,防探测)。
const other = await seedAlbum({
alias: 'pub-feed-other',
translations: [{ locale: 'zh-CN', title: '另一本相册' }],
});
const otherCat = await seedCategory(other.id, 1, [
{ locale: 'zh-CN', name: '别册分类' },
]);
const cross = await listPublicPhotos(feedToken, { category: otherCat });
expect(cross.status).toBe(400);
expect(cross.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('locale 参数:非法值(fr)静默回退语义 → 200(列表无语言敏感字段,不报 400)', async () => {
const res = await listPublicPhotos(feedToken, { locale: 'fr', limit: 5 });
expect(res.status).toBe(200);
expect(res.body.items).toHaveLength(5);
});
it('未公开相册:publicEnabled=false / offline → 404 ALBUM_NOT_PUBLIC', async () => {
const disabled = await seedAlbum({
alias: 'pub-feed-off',
publicEnabled: false,
translations: [{ locale: 'zh-CN', title: '未公开照片流' }],
});
const offline = await seedAlbum({
alias: 'pub-feed-dis',
status: 'offline',
translations: [{ locale: 'zh-CN', title: '下线照片流' }],
});
for (const token of [disabled.publicToken, offline.publicToken]) {
const res = await listPublicPhotos(token);
expect(res.status).toBe(404);
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
}
});
it('limit=60 → 200 全量一页;limit=0/61/非整数 → 400;非法 cursor → 400', async () => {
const max = await listPublicPhotos(feedToken, { limit: 60 });
expect(max.status).toBe(200);
expect(max.body.items).toHaveLength(35);
expect(max.body.hasMore).toBe(false);
for (const limit of [0, 61, 'abc']) {
const res = await listPublicPhotos(feedToken, { limit });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
const badCursor = await listPublicPhotos(feedToken, {
cursor: 'not-a-cursor',
});
expect(badCursor.status).toBe(400);
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
});
it('无变体行的已发布照片被防御性跳过(post-publish 不变量破坏时不炸公开页)', async () => {
// 直种一张 published 但无 photo_variants 的照片(模拟发布事务半途/手工
// 破坏);契约 width/height/thumb/browse 必返,缺变体无法成形 → 跳过而非 5xx。
const orphanId = cuidLike('plorphan');
await prisma.photos.create({
data: {
id: orphanId,
albumId: feedAlbumId,
uploaderId: 'u-plorphan',
objectKey: `${KEY_ROOT}/albums/${feedAlbumId}/originals/${orphanId}.jpg`,
bytes: 1000,
width: 800,
height: 500,
uploadedAt: new Date(),
publishedAt: new Date(PAGINATION_BASE + 99 * 60_000), // 最新
processingState: 'ready',
visibility: 'published',
entityVersion: 2,
},
});
const res = await listPublicPhotos(feedToken, { limit: 60 });
expect(res.status).toBe(200);
const ids = res.body.items.map((p: { photoId: string }) => p.photoId);
expect(ids).not.toContain(orphanId);
expect(ids).toHaveLength(35);
// 游标不因跳过而错位:nextCursor 依旧 null(后面没有更多行)。
expect(res.body.nextCursor).toBeNull();
});
});
});