Files
photo-live/apps/viewer/e2e/journey.spec.ts
T
2026-09-21 23:10:58 +08:00

487 lines
22 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/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<string, unknown> }> {
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<string, unknown>;
return { status: res.status, json };
}
/** 测试内生成真实 JPEG(sharp;不同尺寸兼作管理列表行锚点)。 */
async function makeJpeg(
width: number,
height: number,
rgb: { r: number; g: number; b: number },
): Promise<Buffer> {
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<void> {
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<string, string>;
};
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);
});
});