Files
photo-live/apps/viewer/e2e/helpers.ts
T
浅念 1d1979f540 feat(viewer,ui04): E02 static fidelity — real photo fixtures, measured shell/photo/collapse specs, edge fade
- fixtures: 8 picsum-seeded real photos (mixed ratios); seedPhoto thumbBytes/browseBytes injection
- e2e E02 block: wrapper shell (195x280/radius/border/#10161E), photo strip (193w, ratio-height, 3px gap), collapse (44/44 hit, 24 visual, sole button), desktop+mobile screenshots
- EmbedStream: top/bottom 12px dark fade (0.12, §2.3 optional item)
- fix(admin): pre-existing eslint globals gap for ElNotification (template drift, blocks lint gate)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 13:01:34 +08:00

383 lines
17 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 发布事务落库形态)。
// P2 Task 6 起 viewer 读 ov 副本(VITE_API_BASE 直连 8081)——直种只落 cn 库,
// 故 seed* 后必须 flushSync(albumId):经 cn bootstrap(当前态快照写回 outbox)
// + deliverPendingOnce(真实发送管线排空)把种子投影到 ov 副本,无旁路。
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';
import {
SYNC_SIGNATURE_HEADER,
SYNC_TIMESTAMP_HEADER,
signSyncBody,
} from '@photo/contracts';
import { deliverPendingOnce } from '../../../services/sync/src/sender';
import { seedAdmin } from '../../../services/api/src/scripts/seed-admin';
// 仓库根 .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;
/** 下载两级开关(P4a Task 4):缺省库默认 true/false(同 D06 工作默认)。 */
downloadEnabled?: boolean;
originalDownloadEnabled?: 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,
downloadEnabled: options.downloadEnabled ?? true,
originalDownloadEnabled: options.originalDownloadEnabled ?? false,
},
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;
/** UI-04:注入真实图片字节(如 e2e/fixtures 摄影样本)替代 sharp 色块;
* thumb/browse 各自可选,缺省仍按尺寸生成。bytes 与宽高须与元数据一致。 */
thumbBytes?: Buffer;
browseBytes?: Buffer;
}
/** 直种已发布照片: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([
options.thumbBytes ?? makeJpeg(thumbWidth, thumbHeight),
options.browseBytes ?? 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;
}
/** cn→ov 种子同步(P2 Task 6):先打 cn bootstrap(当前态快照事件写回 outbox,
* 优先级 normal),再 deliverPendingOnce 经真实发送管线(HMAC 签名
* content-batches)排空至 ov 副本。幂等可重入:同版本新 eventId 在 ov 侧按
* 当前态重断言应用;与常驻 dev:sync 发送器并发亦安全(eventId 幂等)。 */
export async function flushSync(albumId: string): Promise<void> {
const internalKey = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
const rawBody = JSON.stringify({ albumId });
const timestamp = Math.floor(Date.now() / 1000).toString();
const cnBase = (process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '');
const bootstrap = await fetch(`${cnBase}/v1/internal/sync/bootstrap`, {
method: 'POST',
headers: {
'content-type': 'application/json',
[SYNC_TIMESTAMP_HEADER]: timestamp,
[SYNC_SIGNATURE_HEADER]: signSyncBody(internalKey, rawBody),
},
body: rawBody,
});
if (!bootstrap.ok) {
throw new Error(
`flushSync bootstrap 失败(${bootstrap.status}):${(await bootstrap.text()).slice(0, 300)}`,
);
}
await flushOutbox();
}
/** 仅排空 outbox(P4b Task 4 embed e2e):slot.updated 事件不随相册 bootstrap
* 走(bootstrap 清单恒不含 slot-state),展示位创建/绑定的同步须单独排空。 */
export async function flushOutbox(): Promise<void> {
await deliverPendingOnce({
prisma,
ovApiUrl: process.env.OV_API_URL ?? 'http://127.0.0.1:8081',
internalKey: process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me',
});
}
// ── P4b Task 4 展示位种子(经 cn admin API 真实路径,非直插行)──
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
const seededSlotIds: string[] = [];
let adminTokenPromise: Promise<string> | null = null;
/** seeded admin 幂等 + 登录(journey.spec 同款;会话进程内复用)。 */
async function loginAdmin(): Promise<string> {
if (adminTokenPromise === null) {
adminTokenPromise = (async () => {
await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD);
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/auth/password`,
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD }),
},
);
if (!res.ok) {
throw new Error(`embed seed 登录失败(${res.status})`);
}
return ((await res.json()) as { sessionToken: string }).sessionToken;
})();
}
return adminTokenPromise;
}
/** 经 cn admin API 创建展示位(未绑定态;settings 缺省),登记待清理。 */
export async function createEmbedSlot(name: string): Promise<{ id: string; token: string }> {
const token = await loginAdmin();
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots`,
{
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({ name }),
},
);
if (!res.ok) {
throw new Error(`createEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`);
}
const body = (await res.json()) as { id: string; publicToken: string };
seededSlotIds.push(body.id);
return { id: body.id, token: body.publicToken };
}
/** 经 cn admin API 更新展示位(绑定/换绑相册、启停、设置——body 直传契约形状)。 */
export async function updateEmbedSlot(id: string, body: Record<string, unknown>): Promise<void> {
const token = await loginAdmin();
const res = await fetch(
`${(process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '')}/v1/admin/embed-slots/${id}`,
{
method: 'PATCH',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify(body),
},
);
if (!res.ok) {
throw new Error(`updateEmbedSlot 失败(${res.status}):${(await res.text()).slice(0, 300)}`);
}
}
/** 模拟已分享深链的照片下线(Task 8 e2e):软删(visibility=deleted +
* entityVersion 自增)——硬删会让 cn 行缺席、bootstrap 快照无从发墓碑,ov 副本
* 将永远留着旧发布行;软删 + flushSync 让 photo.deleted 事件走真实管线撤下。
* 变体行/MinIO 对象保留(公开读只看 photos 行;行清理归 cleanupSeededAlbums)。 */
export async function deleteSeededPhoto(photoId: string): Promise<void> {
await prisma.photos.update({
where: { id: photoId },
data: { visibility: 'deleted', entityVersion: { increment: 1 } },
});
}
/** 清理本文件种下的相册域行(扁平字符串外键、无 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 {
// 展示位种子(P4b):绑定态 slot.updated 行随相册圈定(albumId 在种子相册集)
// 已被下方 outbox 清理覆盖;未绑定创建行以 albumId='' 占位——仅清「未投递」
// 残留(已投递行 inert;未投递残留会污染后续 deliverPendingOnce 排空判定)。
if (seededSlotIds.length > 0) {
await prisma.outbox_events.deleteMany({
where: { type: 'slot.updated', albumId: '', deliveredAt: null },
});
await prisma.embed_slots.deleteMany({ where: { id: { in: seededSlotIds } } });
seededSlotIds.length = 0;
}
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 } } });
// flushSync 经 outbox 投递的快照事件行(已投递/未投递一并清——未投递
// 残留会污染后续 deliverPendingOnce 的排空判定轮次)。
await prisma.outbox_events.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();
}
}