Files
photo-live/apps/viewer/e2e/journey.spec.ts
T

487 lines
22 KiB
TypeScript
Raw Normal View History

// 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);
});
});