// 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 { 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 { 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 { 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 { 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 { 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 { 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 | null = null; /** seeded admin 幂等 + 登录(journey.spec 同款;会话进程内复用)。 */ async function loginAdmin(): Promise { 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): Promise { 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 { 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 { 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(); } }