Files
photo-live/apps/viewer/e2e/download.spec.ts
T
浅念 2bb6f291d3 feat(ov,viewer): two-tier download with wechat guidance
P4a Task 4(T12 收口):ov 域公开照片下载端点 + viewer 下载 UI。

- interactions 增 downloads:GET /public/albums/{token}/photos/{photoId}
  /download?variant=browse|original。两级开关(A18):browse 需
  downloadEnabled;original 需叠加 originalDownloadEnabled(级联,browse 关
  则两者均 403 DOWNLOAD_DISABLED)。水印相册 browse 现签 watermark pattern
  key(browse-key 解析抽取 shared/ov-browse-key 与 public-ov 复用);原图
  现签 originalObjectKey(SIGNED_URL_TTL)。来源 IP 固定窗限速
  (DOWNLOAD_WINDOW_REQUESTS/DOWNLOAD_WINDOW_SECONDS,env 可调);零浏览
  计数写入(排除规则)。STORAGE_PORT_OV 经新 StorageOvModule 与 public-ov
  共享同一实例。
- 契约微调登记:PublicAlbum 增 readOnly 必返 downloadEnabled/
  originalDownloadEnabled(cn+ov 同形返回;public e2e 精确键断言同步)。
- viewer:lightbox 下载入口随开关渲染(无/单按钮/分体菜单两选项)、过期
  凭证静默重取一次、微信内置浏览器(MicroMessenger UA)出保存图片指引
  浮层(复制深链 + 反馈)替代直接下载;i18n zh/en 词条。
- tests:api e2e +7(开关矩阵 + URL/字节级断言、水印键一致、隐藏 404、
  限速 429、零计数行);viewer vitest +9(UA/矩阵/菜单/重取/失败/指引/
  复制);viewer Playwright +3(入口矩阵、微信 UA 浮层、真实端点 200 +
  签名 URL 模式)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 18:32:34 +08:00

131 lines
5.4 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.
import { expect, test, type Page } from '@playwright/test';
import {
cleanupSeededAlbums,
flushSync,
seedAlbum,
seedPhoto,
} from './helpers';
// P4a Task 4 下载 Playwright(真实后端 + 真实签名管线):
// · 入口可见性随相册两级开关(off/off 无按钮 / on/off 单按钮 / on/on 分体
// 菜单两选项);
// · 微信内置浏览器 UA mock(addInitScript 覆写 navigator.userAgent)→ 点击
// 下载出引导浮层(不触端点、不 window.open),「知道了」收起;
// · 真实下载点击:download 端点 200 + window.open 收到签名 URL(对象键模式
// 断言;实际文件保存不在浏览器 e2e 域——由 api e2e 字节级覆盖)。
/** 种「下载相册」:1 张已发布照片,开关组合由参数给定。 */
async function seedDownloadAlbum(
alias: string,
switches: { downloadEnabled?: boolean; originalDownloadEnabled?: boolean },
): Promise<string> {
const album = await seedAlbum({
alias,
translations: [{ locale: 'zh-CN', title: `下载测试相册 ${alias}` }],
...switches,
});
await seedPhoto(album.id, `${alias}-p0`, {
width: 480,
height: 300,
publishedAt: new Date(Date.now() - 60_000),
});
await flushSync(album.id);
return album.publicToken;
}
async function openLightbox(page: Page, token: string, init?: () => void): Promise<void> {
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
if (init) await page.addInitScript(init);
await page.goto(`/a/${token}`);
await expect(page.locator('.cell').first()).toBeVisible();
await page.locator('.cell').first().click();
await expect(page.locator('.lightbox')).toBeVisible();
}
test.afterAll(async () => {
await cleanupSeededAlbums();
});
test('download entry visibility follows album two-tier switches', async ({ page }) => {
test.setTimeout(120_000);
// off/off:无下载入口。
const offOff = await seedDownloadAlbum('dl-offoff', {
downloadEnabled: false,
originalDownloadEnabled: false,
});
await openLightbox(page, offOff);
await expect(page.locator('.download-group')).toHaveCount(0);
// on/off:单按钮(无 caret/菜单)。
const onOff = await seedDownloadAlbum('dl-onoff', {});
await openLightbox(page, onOff);
await expect(page.locator('.control.download')).toBeVisible();
await expect(page.locator('.control.download-caret')).toHaveCount(0);
// on/on:分体按钮,菜单两选项(下载图片 / 下载原图)。
const onOn = await seedDownloadAlbum('dl-onon', { originalDownloadEnabled: true });
await openLightbox(page, onOn);
await expect(page.locator('.control.download-caret')).toBeVisible();
await page.locator('.control.download-caret').click();
await expect(page.locator('.download-menu button')).toHaveText(['下载图片', '下载原图']);
});
test('WeChat webview UA shows guidance overlay instead of downloading', async ({ page }) => {
test.setTimeout(120_000);
const token = await seedDownloadAlbum('dl-wechat', {});
// UA 覆写先于应用代码(addInitScript);MicroMessenger 触发引导浮层面。
await openLightbox(page, token, () => {
const ua =
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 MicroMessenger/8.0.49(0x18003133) NetType/WIFI Language/zh_CN';
Object.defineProperty(window.navigator, 'userAgent', { get: () => ua, configurable: true });
});
let endpointHit = false;
page.on('request', (request) => {
if (request.url().includes('/download?')) endpointHit = true;
});
await page.locator('.control.download').click();
await expect(page.locator('.wechat-guide[role="dialog"]')).toBeVisible();
await expect(page.locator('.wechat-guide h2')).toHaveText('保存图片指引');
await expect(page.locator('.guide-copy')).toBeVisible();
// 「知道了」收起浮层;期间 download 端点零命中(引导替代直接下载)。
await page.locator('.guide-dismiss').click();
await expect(page.locator('.wechat-guide')).toHaveCount(0);
expect(endpointHit).toBe(false);
});
test('real click downloads browse via signed url from the endpoint', async ({ page }) => {
test.setTimeout(120_000);
const token = await seedDownloadAlbum('dl-real', {});
// 捕获 window.open(避免真实弹出标签页):记录 URL 供对象键断言。
await openLightbox(page, token, () => {
(window as unknown as { __openedUrls: string[] }).__openedUrls = [];
window.open = ((url: string | URL) => {
(window as unknown as { __openedUrls: string[] }).__openedUrls.push(String(url));
return {} as Window;
}) as typeof window.open;
});
const endpoint = page.waitForResponse(
(response) =>
response.url().includes('/download?variant=browse') && response.status() === 200,
);
await page.locator('.control.download').click();
const response = await endpoint;
expect((await response.json()).variant).toBe('browse');
await expect
.poll(async () =>
page.evaluate(() => (window as unknown as { __openedUrls: string[] }).__openedUrls[0] ?? ''),
)
.toContain('/variants/');
const opened = await page.evaluate(
() => (window as unknown as { __openedUrls: string[] }).__openedUrls[0] ?? '',
);
// 短期签名凭证(SigV4 查询参数面)+ browse 对象键(无水印相册)。
expect(opened).toContain('X-Amz-Signature');
expect(opened).toContain('/browse.jpg');
});