Files
photo-live/apps/viewer/e2e/helpers.ts
T
2026-09-18 11:04:05 +08:00

244 lines
10 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.
// apps/viewer/e2e/helpers.ts —— Playwright 夹具:真实后端直种(决策见计划 Task 6
// Step 2:真实 API + compose 基础设施,fixture 走 Prisma/Storage 直种,镜像
// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。
// Task 7 起补 seedPhoto:直种「已发布完成态」照片(photos + thumb/browse 变体行
// + MinIO 真实 JPEG 对象,行形状同 worker 发布事务落库形态)。
import { randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { config } from 'dotenv';
import sharp from 'sharp';
import { PrismaClient } from '@photo/db-cn';
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
// 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在
// PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。
const repoRoot = fileURLToPath(new URL('../../..', import.meta.url));
config({ path: join(repoRoot, '.env') });
const prisma = new PrismaClient();
const storage: MinioStorageAdapter = createStorageFromEnv();
const KEY_ROOT = 'photo-live/dev';
/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */
export 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;
}
export 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;
}
const seededAlbumIds: string[] = [];
/** 经 Prisma 直种相册 + 翻译(行形状同 api 公开 e2e fixture)。 */
export async function seedAlbum(options: SeedAlbumOptions): Promise<{ id: string; publicToken: string }> {
const album = await prisma.albums.create({
data: {
publicToken: makePublicToken(),
ownerId: `u-viewer-e2e-${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 },
});
seededAlbumIds.push(album.id);
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;
}
/** 直种分类 + 翻译(名称按 locale 解析),返回 id。 */
export 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;
}
/** 640x200 纯色 JPEG(sharp 生成,仅作真实可取回对象——公开端点只做签名,不校验内容)。 */
export const BANNER_JPEG = Buffer.from(
'/9j/2wBDAA0JCgsKCA0LCgsODg0PEyAVExISEyccHhcgLikxMC4pLSwzOko+MzZGNywtQFdBRkxOUlNSMj5aYVpQYEpRUk//2wBDAQ4ODhMREyYVFSZPNS01T09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT0//wAARCADIAoADASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAH/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFgEBAQEAAAAAAAAAAAAAAAAAAAQF/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAwDAQACEQMRAD8AgDSRgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP/2Q==',
'base64',
);
/** 直种 banner 资产行 + MinIO 真实对象(kind=banner, state=effective, locale 全局)。 */
export async function seedBannerAsset(albumId: string): Promise<void> {
const objectKey = `${KEY_ROOT}/albums/${albumId}/assets/banner.jpg`;
await storage.put(objectKey, BANNER_JPEG, 'image/jpeg');
await prisma.album_assets.create({
data: { albumId, kind: 'banner', locale: null, objectKey, state: 'effective' },
});
}
/** 测试内生成真实 JPEG(sharp,同 services/api test 约定;尺寸小控制体积/耗时)。 */
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: 70 })
.toBuffer();
}
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
/** 与 worker publish.service variantsObjectKey 同布局的变体键。 */
function variantKey(albumId: string, photoId: string, variant: 'thumb' | 'browse'): string {
return `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
}
export interface SeedPhotoOptions {
categoryId?: string | null;
/** browse 宽高(驱动列表占位与瀑布流比例;thumb 减半生成)。 */
width?: number;
height?: number;
publishedAt: Date;
}
/** 直种已发布照片:photos 行(ready/published,entityVersion=2 对齐发布事务)+
* thumb/browse 变体行 + MinIO 真实对象。publishedAt 必传(列表排序确定性)。 */
export async function seedPhoto(
albumId: string,
alias: string,
options: SeedPhotoOptions,
): Promise<string> {
const photoId = cuidLike(alias);
const width = options.width ?? 480;
const height = options.height ?? 300;
const thumbWidth = Math.max(1, Math.round(width / 2));
const thumbHeight = Math.max(1, Math.round(height / 2));
const [thumb, browse] = await Promise.all([
makeJpeg(thumbWidth, thumbHeight),
makeJpeg(width, height),
]);
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-viewer-e2e-${alias}`,
categoryId: options.categoryId ?? null,
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
bytes: browse.length,
width,
height,
uploadedAt: options.publishedAt,
publishedAt: options.publishedAt,
processingState: 'ready',
visibility: 'published',
entityVersion: 2,
},
});
await prisma.photo_variants.createMany({
data: [
{
photoId,
variant: 'thumb',
objectKey: variantKey(albumId, photoId, 'thumb'),
bytes: thumb.length,
version: 1,
width: thumbWidth,
height: thumbHeight,
},
{
photoId,
variant: 'browse',
objectKey: variantKey(albumId, photoId, 'browse'),
bytes: browse.length,
version: 1,
width,
height,
},
],
});
return photoId;
}
/** 直删照片域行(Task 8 e2e:模拟已分享深链的照片下线——变体行在前、主行在后;
* MinIO 对象随 dev 前缀清理约定,不在此删)。 */
export async function deleteSeededPhoto(photoId: string): Promise<void> {
await prisma.photo_variants.deleteMany({ where: { photoId } });
await prisma.photos.deleteMany({ where: { id: photoId } });
}
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
* category_translations 只挂 categoryId,先取分类 id 再删翻译;Task 7 起照片域
* 同理:先按相册取照片 id 删变体行,再删 photos 主行)。
* MinIO 对象不在此删:services/api e2e 的 reset 会整体清 `photo-live/dev` 前缀,
* dev 桶内残余有界(同 api e2e 约定)。 */
export async function cleanupSeededAlbums(): Promise<void> {
try {
if (seededAlbumIds.length > 0) {
const photos = await prisma.photos.findMany({
where: { albumId: { in: seededAlbumIds } },
select: { id: true },
});
if (photos.length > 0) {
await prisma.photo_variants.deleteMany({
where: { photoId: { in: photos.map((p) => p.id) } },
});
}
await prisma.photos.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
const categories = await prisma.categories.findMany({
where: { albumId: { in: seededAlbumIds } },
select: { id: true },
});
const categoryIds = categories.map((c) => c.id);
if (categoryIds.length > 0) {
await prisma.category_translations.deleteMany({ where: { categoryId: { in: categoryIds } } });
}
await prisma.categories.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
await prisma.album_assets.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
await prisma.album_translations.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
await prisma.albums.deleteMany({ where: { id: { in: seededAlbumIds } } });
seededAlbumIds.length = 0;
}
} finally {
await prisma.$disconnect();
}
}