1f1c8ce191
Co-Authored-By: Claude Code <noreply@anthropic.com>
752 lines
28 KiB
TypeScript
752 lines
28 KiB
TypeScript
// 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) + 真实 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}$/);
|
||
});
|
||
});
|
||
|
||
// 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();
|
||
});
|
||
});
|
||
});
|