// 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 { 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 { 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) { return request(app.getHttpServer()) .get(`/${API_PREFIX}/public/albums/${token}`) .query(query ?? {}); } function listPublicPhotos( token: string, query?: Record, ) { 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 { 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 coverOnlyToken: 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; // 宣传图/封面共用(2026-09-23 决策):仅 cover 相册 → banner 回退 cover。 const coverOnly = await seedAlbum({ alias: 'pub-coveronly', translations: [{ locale: 'zh-CN', title: '仅封面相册' }], }); coverOnlyToken = coverOnly.publicToken; const coverOnlyKey = `${KEY_ROOT}/albums/${coverOnly.id}/assets/cover.jpg`; await storage.put(coverOnlyKey, await makeJpeg(900, 300), 'image/jpeg'); await prisma.album_assets.create({ data: { albumId: coverOnly.id, kind: 'cover', locale: null, objectKey: coverOnlyKey, state: 'effective', }, }); }); 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; 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>; 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('banner 回退 cover(宣传图/封面共用:仅 cover 相册 → banner=cover 对象)', async () => { const res = await getPublic(coverOnlyToken); expect(res.status).toBe(200); const banner = res.body.banner as { url: string; expiresAt: string }; expect(banner).not.toBeNull(); const fetched = await fetch(banner.url); expect(fetched.status).toBe(200); expect(fetched.headers.get('content-type')).toBe('image/jpeg'); }); 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; // 契约 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(); }); }); });