2bb6f291d3
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>
131 lines
5.4 KiB
TypeScript
131 lines
5.4 KiB
TypeScript
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');
|
||
});
|