// apps/viewer/e2e/journey.spec.ts —— Task 11 跨端联调冒烟(Playwright 全流程, // 真实后端 + 真实 MinIO/MySQL/Redis compose)。ONE 剧本串起三端: // ① admin UI(5176):seeded admin 登录 → 建相册 A(zh+en)→ banner 真实 // jpeg 走 file chooser(init→presign PUT→complete→预览,R09)→ 建分类 // (zh+en)→ 账号前缀搜索邀 staffB → 登出; // ② staffB 经 API(UI 上传属 P3):登录 → init×3(其一入分类)→ presign PUT // 实写 → complete×3 → worker PublishService.processPhoto×3(services/api // full-chain 同款引导;BullMQ 队列消费路径由 api e2e 覆盖,此处直调 worker // 发布事务)→ 公开列表恰 3 张(verify ready); // ③ viewer UI(5175):/a/{token} banner 渲染 → zh 信息 → 切 en 内容随切 → // 瀑布流 3 张 → 分类 chip 过滤 1 张 → 大图开/缩放/关 → 深链直达; // ④ admin UI 再登录:照片管理 3 行(就绪/已发布)→ 隐藏 1 张 → viewer 重载 // 剩 2 张 → 被隐深链 404 本地化提示; // ⑤ 空相册 B(admin API 建,无照片):viewer 空态提示「照片即将上线」。 // P2 Task 6 起 viewer 观众腿读 ov 副本(VITE_API_BASE 直连 8081)——本剧本的 // cn 侧变更(发布/隐藏/建 B 相册)在各 viewer 腿前 flushSync(bootstrap 快照 + // 真实发送管线排空)投影到 ov;admin/staffB 腿仍走 cn 8080(代理/直连,不变)。 // 数据清理:afterAll 按 viewer helpers 同序直删(含 album_members/upload_sessions/ // photo_jobs/outbox_events 等 API 链路落库行)+ staffB 账号域行。 import { randomBytes } from 'node:crypto'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { config } from 'dotenv'; import sharp from 'sharp'; import { expect, test, type Page } from '@playwright/test'; import { PrismaClient } from '@photo/db-cn'; import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage'; import { hashPassword } from '../../../services/api/src/auth/password'; import { seedAdmin } from '../../../services/api/src/scripts/seed-admin'; import { PublishService } from '../../../services/worker/src/publish.service'; import { flushSync } from './helpers'; // 仓库根 .env 唯一来源(同 helpers.ts 约定):PrismaClient/createStorageFromEnv // 构造前先加载。API 腿直连 8080(服务端到服务端,无 CORS 面)。 const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)); config({ path: join(repoRoot, '.env') }); const ADMIN_BASE = 'http://127.0.0.1:5176'; const API_BASE = 'http://127.0.0.1:8080/v1'; const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin'; const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me'; const STAFF_ACCOUNT = 'journey-staff'; const STAFF_PASSWORD = 'journey-staff-secret-1'; const prisma = new PrismaClient(); const storage: MinioStorageAdapter = createStorageFromEnv(); /** 本轮创建的相册 id(UI 建 A + API 建 B),afterAll 直删。 */ const journeyAlbumIds: string[] = []; /** JSON API 便捷封装(staffB/admin 腿)。 */ async function apiFetch( path: string, init: { method?: string; token?: string; body?: unknown } = {}, ): Promise<{ status: number; json: Record }> { const res = await fetch(`${API_BASE}${path}`, { method: init.method ?? 'GET', headers: { ...(init.body !== undefined ? { 'content-type': 'application/json' } : {}), ...(init.token ? { Authorization: `Bearer ${init.token}` } : {}), }, body: init.body !== undefined ? JSON.stringify(init.body) : undefined, }); const json = (await res.json().catch(() => ({}))) as Record; return { status: res.status, json }; } /** 测试内生成真实 JPEG(sharp;不同尺寸兼作管理列表行锚点)。 */ async function makeJpeg( width: number, height: number, rgb: { r: number; g: number; b: number }, ): Promise { return sharp({ create: { width, height, channels: 3, background: rgb }, }) .jpeg({ quality: 85 }) .toBuffer(); } /** admin UI 登录(镜像 apps/admin e2e loginAsAdmin;跨端绝对 baseURL)。 */ async function loginAdminUi(page: Page): Promise { await page.goto(`${ADMIN_BASE}/#/auth/login`); await page.getByPlaceholder('请输入账号').fill(ADMIN_ACCOUNT); await page.getByPlaceholder('请输入密码', { exact: false }).fill(ADMIN_PASSWORD); await page.getByRole('button', { name: '登录' }).click(); await expect(page).toHaveURL(/\/albums\/manage/, { timeout: 20_000 }); } // admin UI 选择器一律 data-test(同 apps/admin e2e 约定);viewer 侧走 CSS 类名。 test.use({ testIdAttribute: 'data-test' }); test.describe.serial('cross-frontend journey (admin → api/worker → viewer)', () => { let staffBId: string; let staffBToken: string; let adminToken: string; let albumId: string; let publicToken: string; let emptyAlbumId: string; let emptyToken: string; let categoryId: string; const photoIds: string[] = []; test.beforeAll(async () => { // seeded admin 幂等(admin e2e global-setup 同款;viewer 侧自足)。 await seedAdmin(prisma, ADMIN_ACCOUNT, ADMIN_PASSWORD); // staffB 可登录账号:先清历史残余(重跑确定性),再直种(argon2 真哈希)。 const existing = await prisma.users.findUnique({ where: { account: STAFF_ACCOUNT }, select: { id: true }, }); if (existing) { await prisma.sessions.deleteMany({ where: { userId: existing.id } }); await prisma.album_members.deleteMany({ where: { userId: existing.id } }); await prisma.users.delete({ where: { id: existing.id } }); } staffBId = ( await prisma.users.create({ data: { account: STAFF_ACCOUNT, passwordHash: await hashPassword(STAFF_PASSWORD), displayName: '旅程协作者', role: 'staff', credentialStatus: 'active', }, select: { id: true }, }) ).id; }); test.afterAll(async () => { // 相册域直删(扁平字符串外键、无 DB 级联——子表在前;含 API 链路落库的 // upload_sessions/photo_jobs/outbox_events 行)。MinIO 对象随 dev 前缀 // 清理约定不在此删(同 helpers.ts)。 try { if (journeyAlbumIds.length > 0) { const photos = await prisma.photos.findMany({ where: { albumId: { in: journeyAlbumIds } }, select: { id: true }, }); const photoIdList = photos.map((p) => p.id); if (photoIdList.length > 0) { await prisma.photo_variants.deleteMany({ where: { photoId: { in: photoIdList } }, }); await prisma.photo_jobs.deleteMany({ where: { photoId: { in: photoIdList } }, }); } await prisma.photos.deleteMany({ where: { albumId: { in: journeyAlbumIds } }, }); await prisma.upload_sessions.deleteMany({ where: { albumId: { in: journeyAlbumIds } }, }); await prisma.outbox_events.deleteMany({ where: { albumId: { in: journeyAlbumIds } }, }); const categories = await prisma.categories.findMany({ where: { albumId: { in: journeyAlbumIds } }, select: { id: true }, }); if (categories.length > 0) { await prisma.category_translations.deleteMany({ where: { categoryId: { in: categories.map((c) => c.id) } }, }); } await prisma.categories.deleteMany({ where: { albumId: { in: journeyAlbumIds } }, }); await prisma.album_assets.deleteMany({ where: { albumId: { in: journeyAlbumIds } }, }); await prisma.album_members.deleteMany({ where: { albumId: { in: journeyAlbumIds } }, }); await prisma.album_translations.deleteMany({ where: { albumId: { in: journeyAlbumIds } }, }); await prisma.albums.deleteMany({ where: { id: { in: journeyAlbumIds } } }); } await prisma.sessions.deleteMany({ where: { userId: staffBId } }); await prisma.album_members.deleteMany({ where: { userId: staffBId } }); await prisma.users.deleteMany({ where: { id: staffBId } }); } finally { await prisma.$disconnect(); } }); test('admin builds the album, staff publishes, viewers see it, admin hides', async ({ page }) => { test.setTimeout(300_000); const suffix = `${Date.now().toString(36)}${randomBytes(3).toString('hex')}`; const zhTitle = `联调相册 ${suffix}`; const enTitle = `Journey Album ${suffix}`; const zhCategory = `主舞台 ${suffix}`; const enCategory = `Main Stage ${suffix}`; // ── ① admin UI:登录 → 建相册 A(zh+en)→ banner → 分类 → 邀 staffB → 登出 ── await loginAdminUi(page); await page.getByTestId('album-create-btn').click(); await expect(page.getByTestId('album-form-dialog')).toBeVisible(); await page.getByTestId('album-form-title-zh-CN').fill(zhTitle); await page.getByRole('tab', { name: '英文' }).click(); await page.getByTestId('album-form-title-en').fill(enTitle); await page.getByTestId('album-form-start-at').locator('input').fill('2026-10-01 10:00:00'); await page.keyboard.press('Enter'); await page.getByTestId('album-form-submit').click(); await expect(page.getByTestId('album-form-dialog')).toBeHidden(); const row = page .getByTestId('album-list-table') .locator('tr', { hasText: zhTitle }); await expect(row).toBeVisible(); await row.getByTestId('album-detail-btn').click(); await expect(page.getByTestId('album-detail-card')).toBeVisible(); albumId = /\/albums\/detail\/([^/?#]+)/.exec(page.url())?.[1] ?? ''; expect(albumId).not.toBe(''); journeyAlbumIds.push(albumId); publicToken = (await page.getByTestId('album-detail-token').textContent()) ?? ''; expect(publicToken).toMatch(/^[0-9a-z]{20,32}$/); // banner:真实 jpeg(sharp 生成)走 file chooser → init/PUT/complete → 预览。 const bannerJpeg = await makeJpeg(1200, 300, { r: 150, g: 100, b: 60 }); const fileChooserPromise = page.waitForEvent('filechooser'); await page.getByTestId('album-banner-pick').click(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles({ name: 'banner-journey.jpg', mimeType: 'image/jpeg', buffer: bannerJpeg, }); await expect(page.getByTestId('album-banner-status-done')).toBeVisible({ timeout: 20_000, }); await expect(page.getByTestId('album-banner-preview-img')).toBeVisible(); // 成员:账号前缀搜索 staffB(UI 走 lookupUsersByAccount)→ 邀请。 await page.getByTestId('album-members-tab').click(); const searchSelect = page.getByTestId('member-search-select'); await searchSelect.click(); await searchSelect.locator('input').fill(STAFF_ACCOUNT); await page.getByTestId(`member-option-${STAFF_ACCOUNT}`).click(); await page.getByTestId('member-invite-btn').click(); await expect( page.getByTestId('member-list-table').getByText(STAFF_ACCOUNT), ).toBeVisible(); // 分类:zh/en 名称。 await page.getByTestId('album-categories-tab').click(); await page.getByTestId('category-create-btn').click(); await page.getByTestId('category-form-name-zh').fill(zhCategory); await page.getByTestId('category-form-name-en').fill(enCategory); await page.getByTestId('category-form-submit').click(); await expect(page.getByTestId('category-form-dialog')).toBeHidden(); await expect( page.getByTestId('category-list-table').getByText(zhCategory), ).toBeVisible(); categoryId = ( await prisma.categories.findFirstOrThrow({ where: { albumId }, orderBy: { sortOrder: 'asc' }, select: { id: true }, }) ).id; // 登出:头像 hover → 退出登录 → 确认 → 回登录页。 await page.getByAltText('avatar').hover(); await page.getByText('退出登录').click(); await page.getByRole('button', { name: '确定' }).click(); await expect(page).toHaveURL(/\/auth\/login/, { timeout: 20_000 }); // ── ② admin API:登录 + 建空相册 B(空态冒烟用)── const adminLogin = await apiFetch('/auth/password', { method: 'POST', body: { account: ADMIN_ACCOUNT, password: ADMIN_PASSWORD }, }); expect(adminLogin.status).toBe(200); adminToken = adminLogin.json.sessionToken as string; const emptyAlbum = await apiFetch('/albums', { method: 'POST', token: adminToken, body: { translations: [ { locale: 'zh-CN', title: `空相册 ${suffix}` }, { locale: 'en', title: `Empty Album ${suffix}` }, ], defaultLocale: 'zh-CN', startAt: '2026-10-01T10:00:00.000Z', }, }); expect(emptyAlbum.status).toBe(201); emptyAlbumId = emptyAlbum.json.id as string; journeyAlbumIds.push(emptyAlbumId); emptyToken = emptyAlbum.json.publicToken as string; // ── ② staffB API:登录 → init×3 → presign PUT 实写 → complete×3 ── const staffLogin = await apiFetch('/auth/password', { method: 'POST', body: { account: STAFF_ACCOUNT, password: STAFF_PASSWORD }, }); expect(staffLogin.status).toBe(200); staffBToken = staffLogin.json.sessionToken as string; const shapes = [ { w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 }, withCategory: true }, { w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 }, withCategory: false }, { w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 }, withCategory: false }, ] as const; const runSuffix = Date.now().toString(36); for (let i = 0; i < shapes.length; i++) { const shape = shapes[i]!; const jpeg = await makeJpeg(shape.w, shape.h, shape.rgb); const init = await apiFetch('/uploads', { method: 'POST', token: staffBToken, body: { albumId, clientTaskId: `journey-${runSuffix}-${i}`, filename: `journey-${i}.jpg`, bytes: jpeg.length, contentType: 'image/jpeg', ...(shape.withCategory ? { categoryId } : {}), }, }); expect(init.status).toBe(201); const credential = init.json.credential as { url: string; method: string; headers: Record; }; const photoId = init.json.photoId as string; photoIds.push(photoId); const put = await fetch(credential.url, { method: credential.method, headers: credential.headers, body: new Uint8Array(jpeg), }); expect(put.ok).toBe(true); const complete = await apiFetch( `/uploads/${init.json.uploadId as string}/complete`, { method: 'POST', token: staffBToken }, ); expect(complete.status).toBe(200); expect(complete.json).toEqual({ photoId, processingState: 'uploaded', }); } expect(photoIds).toHaveLength(3); // ── ② worker 发布事务 ×3(services/api full-chain 同款引导)→ verify ready ── const publish = new PublishService(prisma, storage); for (const photoId of photoIds) { expect(await publish.processPhoto(photoId)).toBe('published'); } for (const photoId of photoIds) { const photo = await prisma.photos.findUniqueOrThrow({ where: { id: photoId }, }); expect(photo).toMatchObject({ processingState: 'ready', visibility: 'published', }); } // 公开列表恰 3 张(API 侧 verify ready 的外部一致性)。 const list = await apiFetch(`/public/albums/${publicToken}/photos`); expect(list.status).toBe(200); const items = list.json.items as Array<{ photoId: string }>; expect(items).toHaveLength(3); expect(new Set(items.map((p) => p.photoId))).toEqual(new Set(photoIds)); // cn→ov:发布事实经真实管线投影(album/banner/分类/3 张照片快照入 outbox // 并排空投递)——viewer 腿读 ov 副本,此后才可见。 await flushSync(albumId); // ── ③ viewer UI:zh 打开 → banner 渲染 → 切 en 内容随切 → 回 zh ── await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN')); await page.goto(`/a/${publicToken}`); const banner = page.locator('img.banner'); await expect(banner).toBeVisible(); await expect .poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth)) .toBeGreaterThan(0); await expect(page.locator('h1.title')).toHaveText(zhTitle); await expect(page.locator('.categories .chip')).toHaveCount(2); // 全部 + 主舞台 await page.locator('.locale-trigger').click(); await page.locator('.locale-option', { hasText: 'English' }).click(); await expect(page.locator('h1.title')).toHaveText(enTitle); await page.locator('.locale-trigger').click(); // 切回 zh,后续断言用中文 await page.locator('.locale-option', { hasText: '中文' }).click(); await expect(page.locator('h1.title')).toHaveText(zhTitle); // 瀑布流 3 张 → 分类 chip 过滤 1 张 → 全部恢复 3 张。 await expect(page.locator('.cell')).toHaveCount(3); await page.getByRole('button', { name: zhCategory }).click(); await expect(page.locator('.cell')).toHaveCount(1); await page.getByRole('button', { name: '全部' }).click(); await expect(page.locator('.cell')).toHaveCount(3); // Task 10 互动化:浏览计数行(首访上报后权威回填)+ 排序 chips 在位。 await expect(page.locator('.view-count')).toHaveText('1 次浏览', { timeout: 15_000 }); await expect(page.locator('.sort-tabs .sort-tab')).toHaveText(['最新', '热门']); // 大图:点击开图 → 真实字节 → 滚轮缩放 → 双击复位 → 关闭回列表。 const lightboxPhoto = photoIds[1]!; // 800×500(后续 admin 腿按尺寸锚定隐藏) await page.locator('.cell').nth(1).click(); await expect(page.locator('.photo-viewer')).toBeVisible(); await expect( page.locator('img.photo'), ).toHaveAttribute('src', new RegExp(lightboxPhoto)); // Task 10:大图内真实点赞 1 次 → 防抖冲批 → 权威计数 1。 await page.locator('.action-bar .like-slot .action-btn').click(); await expect(page.locator('.like-count')).toHaveText('1'); await expect .poll(async () => page.locator('img.photo').evaluate((img) => (img as HTMLImageElement).naturalWidth), ) .toBeGreaterThan(0); expect(page.url()).toContain(`/a/${publicToken}/p/${lightboxPhoto}`); const box = await page.locator('.photo-viewer').boundingBox(); expect(box).not.toBeNull(); await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2); await page.mouse.wheel(0, -360); await expect .poll(async () => page .locator('img.photo') .evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a), ) .toBeGreaterThan(1.2); await page.locator('img.photo').dblclick(); await expect .poll(async () => page .locator('img.photo') .evaluate((img) => new DOMMatrixReadOnly(getComputedStyle(img).transform).a), ) .toBeCloseTo(1, 1); await page.locator('.viewer-close').click(); await expect(page.locator('.photo-viewer')).toHaveCount(0); // 深链直达:/a/{token}/p/{photoId} 直接开在大图上。 await page.goto(`/a/${publicToken}/p/${lightboxPhoto}`); await expect(page.locator('.photo-viewer')).toBeVisible(); await expect(page.locator('img.photo')).toHaveAttribute( 'src', new RegExp(lightboxPhoto), ); // ── ④ admin UI 再登录:照片管理 3 行(就绪/已发布)→ 隐藏 800×500 ── await loginAdminUi(page); await page.goto(`${ADMIN_BASE}/#/albums/detail/${albumId}`); await expect(page.getByTestId('album-detail-card')).toBeVisible(); await page.getByTestId('album-photos-tab').click(); const table = page.getByTestId('photo-list-table'); await expect(table).toBeVisible(); await expect( table.locator('tr').filter({ has: page.getByTestId('photo-state-tag') }), ).toHaveCount(3); const targetRow = table.locator('tr', { hasText: '800×500' }); await expect(targetRow.getByTestId('photo-state-tag')).toHaveText('就绪'); await expect(targetRow.getByTestId('photo-visibility-tag')).toHaveText('已发布'); await targetRow.getByTestId('photo-hide-btn').click(); await page.getByRole('button', { name: '确定' }).click(); await expect(targetRow.getByTestId('photo-visibility-tag')).toHaveText('已隐藏'); // 隐藏事实同步到 ov(viewer 下一腿的重载/深链 404 断言读 ov 副本)。 await flushSync(albumId); // ── ④ viewer:重载列表变 2 → 被隐深链 404 本地化提示 ── await page.goto(`/a/${publicToken}`); await expect(page.locator('.cell')).toHaveCount(2); await page.goto(`/a/${publicToken}/p/${lightboxPhoto}`); const notice = page.locator('.deep-link-notice'); await expect(notice).toBeVisible(); await expect(notice).toContainText('照片不存在或已下线'); await expect(page.locator('.photo-viewer')).toHaveCount(0); // ── ⑤ 空相册 B:viewer 空态提示(B 经 API 建于 cn,先同步到 ov)── await flushSync(emptyAlbumId); await page.goto(`/a/${emptyToken}`); await expect(page.locator('.empty-notice')).toHaveText('照片即将上线'); await expect(page.locator('.cell')).toHaveCount(0); }); });