0fcf18cc5c
P2 Task 6(ov 公开读 API + viewer 切换):
- ov 装配 PublicOvModule(仅 app-ov.module):/public/albums/{token}、
/photos、/photos/{id} 三端点同契约读 ov 副本——translations/categories
自 JSON 列解析(防御性过滤),banner 经 ov 侧 STORAGE_PORT_OV 预签名
(同本地 MinIO;只读属约定,真实 OSS 只读凭证 P5),viewCount 取
album_stats 权威累计(无行默认 0;计数端点 Task 8 接线)
- 排序:latest=发布时间 keyset(shared/cursor.ts 同 cn);hot=likeTotal
desc + photoId desc 决胜,顺序按 `${albumId}|${category}` 短期快照
(内存 TTL 默认 30s,HOT_SORT_TTL_MS 惰性读取)——计数逐请求现读、
顺序冻结窗口内一致,偏移游标(base64url 整数)在快照内严格稳定;
非法 sort 400 VALIDATION_FAILED(cn 不感知该参数)
- 契约:viewCount 放宽为 integer|null(cn null、ov 权威值);
listPublicPhotos 增 sort 枚举(latest|hot,缺省 latest);
PhotoPublishedPayload 增 categoryId——补上 Tasks 3/4 管线缺口
(payloads/publish.service/sync-inbox 投影 + 关联测试随改)
- e2e(services/api/test/public-ov.e2e-spec.ts,7 用例):双入口同进程
起 cn+ov,cn API 种子→真实发送管线投递;断言相册/列表/单图与 cn
深等(仅签名 URL 剔除)、banner 真字节、参数矩阵 400/404、hot 排序
TTL 内快照(计数新/顺序旧)+TTL 过期翻转+偏移翻页、隐藏/下线/紧急
停用逐态撤下
- viewer 切 ov:API 基址 VITE_API_BASE(缺省 http://127.0.0.1:8081,
空串=同源网关;Vite 只读 apps/viewer/.env*,根 .env 对前端无效),
移除 /v1 dev 代理(CORS_ORIGINS 增 127.0.0.1:5175 直连两形);
playwright webServer 增 dev:ov;e2e 夹具 flushSync(albumId)=cn
bootstrap 快照 + deliverPendingOnce(sender 新导出)经真实管线投影
种子,deleteSeededPhoto 改软删让墓碑可同步;14 个 Playwright 全绿
(journey 管理/staffB 腿仍走 cn 8080)
Co-Authored-By: Claude Code <noreply@anthropic.com>
140 lines
5.8 KiB
TypeScript
140 lines
5.8 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
||
import {
|
||
cleanupSeededAlbums,
|
||
flushSync,
|
||
makePublicToken,
|
||
seedAlbum,
|
||
seedBannerAsset,
|
||
seedCategory,
|
||
} from './helpers';
|
||
|
||
// Task 6 活动页 Playwright(真实后端:cn API 8080 + ov API 8081 由
|
||
// playwright.config webServer 拉起或复用已在跑的实例;种子走 Prisma/MinIO 直种
|
||
// cn 侧,再 flushSync 经真实管线投影到 ov——viewer 已切 ov 读,见 helpers.ts)。
|
||
// 语言前置:无头 Chromium 默认 en-US——用 addInitScript 预置 localStorage,避免
|
||
// 依赖浏览器语言;zh 断言由此确定性地落在 zh-CN UI。
|
||
|
||
test.afterAll(async () => {
|
||
await cleanupSeededAlbums();
|
||
});
|
||
|
||
test('renders album info in zh with timezone-adjusted time, chips and empty state', async ({ page }) => {
|
||
const album = await seedAlbum({
|
||
alias: 'main',
|
||
translations: [
|
||
{
|
||
locale: 'zh-CN',
|
||
title: '2026 公司年会',
|
||
description: '年度盛会',
|
||
venue: '大宴会厅',
|
||
address: '上海市浦东新区世纪大道 100 号',
|
||
},
|
||
{ locale: 'en', title: 'Annual Party 2026', description: 'Yearly gala' },
|
||
],
|
||
});
|
||
await seedCategory(album.id, 0, [
|
||
{ locale: 'zh-CN', name: '签到墙' },
|
||
{ locale: 'en', name: 'Check-in Wall' },
|
||
]);
|
||
await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
|
||
await flushSync(album.id);
|
||
|
||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||
await page.goto(`/a/${album.publicToken}`);
|
||
|
||
await expect(page.locator('h1.title')).toHaveText('2026 公司年会');
|
||
await expect(page.locator('.description')).toHaveText('年度盛会');
|
||
// Asia/Shanghai:UTC 10-01 02:00/12:00 → 本地 10-01(不含 9/30 越日错误)。
|
||
const time = page.locator('.meta');
|
||
await expect(time).toContainText('10月1日');
|
||
await expect(time).not.toContainText('9月30');
|
||
await expect(page.locator('.meta')).toContainText('大宴会厅 · 上海市浦东新区世纪大道 100 号');
|
||
|
||
const chips = page.locator('.chip');
|
||
await expect(chips).toHaveCount(3); // Task 7 起:全部 + 各分类(可点选)
|
||
await expect(chips.first()).toBeEnabled();
|
||
await expect(chips).toHaveText(['全部', '签到墙', '开场']);
|
||
|
||
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
||
// 该相册无 banner 资产 → 不渲染图。
|
||
await expect(page.locator('img.banner')).toHaveCount(0);
|
||
await expect(page).toHaveTitle('2026 公司年会');
|
||
});
|
||
|
||
test('switching locale re-fetches and shows the English translation', async ({ page }) => {
|
||
const album = await seedAlbum({
|
||
alias: 'switch',
|
||
translations: [
|
||
{ locale: 'zh-CN', title: '双语测试相册', description: '中文介绍' },
|
||
{ locale: 'en', title: 'Bilingual Test Album', description: 'English intro', venue: 'Main Hall' },
|
||
],
|
||
});
|
||
await flushSync(album.id);
|
||
|
||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||
await page.goto(`/a/${album.publicToken}`);
|
||
await expect(page.locator('h1.title')).toHaveText('双语测试相册');
|
||
|
||
await page.locator('button.locale-switcher').click();
|
||
await expect(page.locator('h1.title')).toHaveText('Bilingual Test Album');
|
||
await expect(page.locator('.description')).toHaveText('English intro');
|
||
await expect(page.locator('.meta')).toContainText('Main Hall');
|
||
await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon');
|
||
await expect(page).toHaveTitle('Bilingual Test Album');
|
||
});
|
||
|
||
test('en UI with a zh-only album falls back to zh content while chrome stays en', async ({ page }) => {
|
||
const album = await seedAlbum({
|
||
alias: 'fallback',
|
||
translations: [{ locale: 'zh-CN', title: '只有中文的相册', venue: '大宴会厅' }],
|
||
});
|
||
await flushSync(album.id);
|
||
|
||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'en'));
|
||
await page.goto(`/a/${album.publicToken}`);
|
||
|
||
// 内容回退:resolvedLocale=zh-CN(服务端返回 zh 翻译)。
|
||
await expect(page.locator('h1.title')).toHaveText('只有中文的相册');
|
||
await expect(page.locator('.meta')).toContainText('大宴会厅');
|
||
// UI 语言仍为用户选择(en)。
|
||
await expect(page.locator('.empty-notice')).toHaveText('Photos coming soon');
|
||
});
|
||
|
||
test('banner signed URL loads a real image', async ({ page }) => {
|
||
const album = await seedAlbum({
|
||
alias: 'banner',
|
||
translations: [{ locale: 'zh-CN', title: '有封面的相册' }],
|
||
});
|
||
await seedBannerAsset(album.id);
|
||
await flushSync(album.id);
|
||
|
||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||
await page.goto(`/a/${album.publicToken}`);
|
||
|
||
const banner = page.locator('img.banner');
|
||
await expect(banner).toBeVisible();
|
||
// 签名 URL 可取回真实字节(加载完成 naturalWidth > 0;失败则保持 0)。
|
||
await expect
|
||
.poll(async () => banner.evaluate((img) => (img as HTMLImageElement).naturalWidth))
|
||
.toBeGreaterThan(0);
|
||
});
|
||
|
||
test('offline and ghost tokens show the localized unavailable error', async ({ page }) => {
|
||
const offline = await seedAlbum({
|
||
alias: 'offline',
|
||
status: 'offline',
|
||
translations: [{ locale: 'zh-CN', title: '已下线相册' }],
|
||
});
|
||
// 下线态也经真实管线同步:ov 侧 status=offline → isAlbumVisibleOv=false → 404。
|
||
await flushSync(offline.id);
|
||
|
||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||
await page.goto(`/a/${offline.publicToken}`);
|
||
await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线');
|
||
await expect(page.locator('h1.title')).toHaveCount(0);
|
||
|
||
// 幽灵 token:同码同文案(防枚举)。
|
||
await page.goto(`/a/${makePublicToken()}`);
|
||
await expect(page.locator('.error[role="alert"]')).toContainText('相册不存在或已下线');
|
||
});
|