diff --git a/apps/viewer/e2e/journey.spec.ts b/apps/viewer/e2e/journey.spec.ts new file mode 100644 index 0000000..58889ad --- /dev/null +++ b/apps/viewer/e2e/journey.spec.ts @@ -0,0 +1,463 @@ +// 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 空态提示「照片即将上线」。 +// 数据清理: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'; + +// 仓库根 .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 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); + journeyAlbumIds.push(emptyAlbum.json.id as string); + 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)); + + // ── ③ 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('.chip')).toHaveCount(2); // 全部 + 主舞台 + + await page.locator('button.locale-switcher').click(); + await expect(page.locator('h1.title')).toHaveText(enTitle); + await page.locator('button.locale-switcher').click(); // 切回 zh,后续断言用中文 + 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); + + // 大图:点击开图 → 真实字节 → 滚轮缩放 → 双击复位 → 关闭回列表。 + const lightboxPhoto = photoIds[1]!; // 800×500(后续 admin 腿按尺寸锚定隐藏) + await page.locator('.cell').nth(1).click(); + await expect(page.locator('.lightbox')).toBeVisible(); + await expect( + page.locator('img.photo'), + ).toHaveAttribute('src', new RegExp(lightboxPhoto)); + 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('.lightbox').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('.control.close').click(); + await expect(page.locator('.lightbox')).toHaveCount(0); + + // 深链直达:/a/{token}/p/{photoId} 直接开在大图上。 + await page.goto(`/a/${publicToken}/p/${lightboxPhoto}`); + await expect(page.locator('.lightbox')).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('已隐藏'); + + // ── ④ 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('.lightbox')).toHaveCount(0); + + // ── ⑤ 空相册 B:viewer 空态提示 ── + await page.goto(`/a/${emptyToken}`); + await expect(page.locator('.empty-notice')).toHaveText('照片即将上线'); + await expect(page.locator('.cell')).toHaveCount(0); + }); +}); diff --git a/apps/viewer/playwright.config.ts b/apps/viewer/playwright.config.ts index c0f6647..02e3a5d 100644 --- a/apps/viewer/playwright.config.ts +++ b/apps/viewer/playwright.config.ts @@ -3,6 +3,8 @@ import { defineConfig, devices } from '@playwright/test'; // Task 6 起 e2e 打真实后端:webServer 先等 API(8080,仓库根 `pnpm dev:api` // 构建+启动;已在跑的实例直接复用),再等 viewer dev(5175,/v1 代理 → 8080)。 // compose 基础设施(MySQL/MinIO/Redis)需预先 `docker compose up -d`(计划约定)。 +// Task 11 起跨端联调:第三个 webServer 一并等 admin dev(5176,/v1 代理 → 8080, +// 同样复用已在跑的实例)——journey.spec.ts 以绝对 baseURL 驱动 admin 腿。 export default defineConfig({ testDir: './e2e', timeout: 30_000, @@ -27,5 +29,12 @@ export default defineConfig({ reuseExistingServer: true, timeout: 60_000, }, + { + command: 'pnpm run dev', + cwd: '../admin', + url: 'http://127.0.0.1:5176', + reuseExistingServer: true, + timeout: 120_000, // 首次含模板依赖预构建 + }, ], });