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>
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
||||
import { defineComponent, ref } from 'vue';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
getPhotoDownload,
|
||||
getPublicPhotos,
|
||||
type PublicAlbum,
|
||||
type PublicPhoto,
|
||||
type PublicPhotoDownload,
|
||||
type PublicPhotoList,
|
||||
} from '../api/client';
|
||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||
import PhotoLightbox from '../components/PhotoLightbox.vue';
|
||||
import { usePhotoSequence } from '../feed/sequence';
|
||||
import {
|
||||
isExpired,
|
||||
isWechatWebview,
|
||||
useDownload,
|
||||
type DownloadController,
|
||||
} from '../download/useDownload';
|
||||
import { makeAlbum } from './fixtures';
|
||||
|
||||
// P4a Task 4 下载(T12 收口):开关→入口可见性矩阵、分体菜单选项、微信 UA
|
||||
// 判定与引导浮层、过期凭证重取一次、失败提示、复制深链。
|
||||
|
||||
vi.mock('../api/client', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../api/client')>();
|
||||
return {
|
||||
...actual,
|
||||
getPublicPhotos: vi.fn(),
|
||||
getPublicPhoto: vi.fn(),
|
||||
getPhotoDownload: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||
const mockedGetPhotoDownload = vi.mocked(getPhotoDownload);
|
||||
|
||||
const WECHAT_IOS_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';
|
||||
const WECHAT_ANDROID_UA =
|
||||
'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/122.0.0.0 Mobile Safari/537.36 MicroMessenger/8.0.49.2500(0x28004935)';
|
||||
const WECHAT_WINDOWS_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x670c0123) WindowsWechat(0x63090a13)';
|
||||
const PLAIN_IOS_SAFARI_UA =
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1';
|
||||
const DESKTOP_CHROME_UA =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36';
|
||||
|
||||
function makePhoto(id: string): PublicPhoto {
|
||||
return {
|
||||
photoId: id,
|
||||
width: 800,
|
||||
height: 500,
|
||||
thumb: { url: `http://img.test/${id}-thumb.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
browse: { url: `http://img.test/${id}-browse.jpg?s=1`, expiresAt: '2026-10-01T00:00:00.000Z' },
|
||||
likeCount: 0,
|
||||
publishedAt: '2026-10-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
|
||||
function makeList(items: PublicPhoto[]): PublicPhotoList {
|
||||
return { items, nextCursor: null, hasMore: false };
|
||||
}
|
||||
|
||||
function credential(url: string, expiresAt: string, variant: 'browse' | 'original'): PublicPhotoDownload {
|
||||
return { url, expiresAt, variant };
|
||||
}
|
||||
|
||||
const FUTURE = new Date(Date.now() + 14 * 60_000).toISOString();
|
||||
const PAST = new Date(Date.now() - 60_000).toISOString();
|
||||
|
||||
function stubUserAgent(ua: string): void {
|
||||
Object.defineProperty(window.navigator, 'userAgent', { value: ua, configurable: true });
|
||||
}
|
||||
|
||||
function restoreUserAgent(): void {
|
||||
// 删除实例自有属性,回落原型 getter(jsdom 原始 UA)。
|
||||
delete (window.navigator as { userAgent?: string }).userAgent;
|
||||
}
|
||||
|
||||
let i18n: ViewerI18n;
|
||||
const mounted: VueWrapper[] = [];
|
||||
|
||||
/** window.open 桩(记录调用即可):经工厂推导 spy 类型,绕开 spyOn 泛型约束。 */
|
||||
function spyWindowOpen() {
|
||||
return vi.spyOn(window, 'open').mockReturnValue({} as Window);
|
||||
}
|
||||
let openSpy: ReturnType<typeof spyWindowOpen>;
|
||||
|
||||
/** 宿主实例形态:setup 暴露 albumRef(ref 经代理解包)+ download 控制器。 */
|
||||
interface HostVm {
|
||||
albumRef: PublicAlbum | null;
|
||||
download: DownloadController;
|
||||
}
|
||||
|
||||
/** 宿主:sequence + 相册 ref + 下载控制器 + lightbox(镜像 ActivityPage 组装)。 */
|
||||
function mountLightbox(album: PublicAlbum): VueWrapper & { vm: HostVm } {
|
||||
const Host = defineComponent({
|
||||
components: { PhotoLightbox },
|
||||
setup() {
|
||||
const sequence = usePhotoSequence({
|
||||
token: ref('tok-dl'),
|
||||
locale: ref('zh-CN'),
|
||||
category: ref(null),
|
||||
});
|
||||
const albumRef = ref<PublicAlbum | null>(album);
|
||||
const current = ref('p1');
|
||||
const download = useDownload({
|
||||
token: ref('tok-dl'),
|
||||
album: () => albumRef.value,
|
||||
photoId: () => current.value,
|
||||
});
|
||||
return { sequence, photoId: current, download, albumRef };
|
||||
},
|
||||
template: `<PhotoLightbox v-model:photo-id="photoId" :sequence="sequence" :download="download" />`,
|
||||
});
|
||||
const wrapper = mount(Host, { global: { plugins: [i18n] } });
|
||||
mounted.push(wrapper);
|
||||
return wrapper as VueWrapper & { vm: HostVm };
|
||||
}
|
||||
|
||||
describe('photo download (P4a Task 4)', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
i18n = createViewerI18n('zh-CN');
|
||||
mockedGetPublicPhotos.mockReset().mockResolvedValue(makeList([makePhoto('p1'), makePhoto('p2')]));
|
||||
mockedGetPhotoDownload.mockReset();
|
||||
openSpy = spyWindowOpen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (mounted.length > 0) {
|
||||
mounted.pop()?.unmount();
|
||||
}
|
||||
restoreUserAgent();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('detects WeChat webview UAs (iOS/Android/Windows) and passes others', () => {
|
||||
for (const ua of [WECHAT_IOS_UA, WECHAT_ANDROID_UA, WECHAT_WINDOWS_UA]) {
|
||||
expect(isWechatWebview(ua)).toBe(true);
|
||||
}
|
||||
for (const ua of [PLAIN_IOS_SAFARI_UA, DESKTOP_CHROME_UA, '']) {
|
||||
expect(isWechatWebview(ua)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('treats expiresAt at-or-before now as expired', () => {
|
||||
expect(isExpired(PAST)).toBe(true);
|
||||
expect(isExpired(new Date().toISOString())).toBe(true);
|
||||
expect(isExpired(FUTURE)).toBe(false);
|
||||
expect(isExpired('not-a-date')).toBe(false); // 非法时间戳不误判过期
|
||||
});
|
||||
|
||||
it('controller model: switch matrix incl. off/on cascade (original implies browse)', async () => {
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
const controller = wrapper.vm.download;
|
||||
|
||||
// on/off(夹具缺省)。
|
||||
expect(controller.canDownload).toBe(true);
|
||||
expect(controller.canDownloadOriginal).toBe(false);
|
||||
|
||||
const cases: Array<[boolean, boolean, boolean, boolean]> = [
|
||||
// [downloadEnabled, originalDownloadEnabled, canDownload, canDownloadOriginal]
|
||||
[true, true, true, true],
|
||||
[true, false, true, false],
|
||||
[false, false, false, false],
|
||||
[false, true, false, false], // 级联:browse 关则 original 项不出现
|
||||
];
|
||||
for (const [downloadEnabled, originalDownloadEnabled, can, canOriginal] of cases) {
|
||||
wrapper.vm.albumRef = makeAlbum({ downloadEnabled, originalDownloadEnabled });
|
||||
await flushPromises();
|
||||
expect(controller.canDownload).toBe(can);
|
||||
expect(controller.canDownloadOriginal).toBe(canOriginal);
|
||||
}
|
||||
});
|
||||
|
||||
it('toolbar visibility: off/on → no entry; on/off → single button; on/on → split', async () => {
|
||||
// off/off:无下载入口。
|
||||
const off = mountLightbox(makeAlbum({ downloadEnabled: false, originalDownloadEnabled: false }));
|
||||
await flushPromises();
|
||||
expect(off.find('.download-group').exists()).toBe(false);
|
||||
|
||||
// on/off:单按钮,无 caret/菜单。
|
||||
const browseOnly = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
expect(browseOnly.find('.control.download').exists()).toBe(true);
|
||||
expect(browseOnly.find('.control.download-caret').exists()).toBe(false);
|
||||
|
||||
// on/on:分体按钮(caret 出现,菜单默认收起)。
|
||||
const split = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
|
||||
await flushPromises();
|
||||
expect(split.find('.control.download-caret').exists()).toBe(true);
|
||||
expect(split.find('.download-menu').exists()).toBe(false);
|
||||
// 主按钮 aria 展开 = false。
|
||||
expect(split.find('.control.download-caret').attributes('aria-expanded')).toBe('false');
|
||||
});
|
||||
|
||||
it('split menu offers both variants; picking original requests the original endpoint', async () => {
|
||||
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-original.jpg', FUTURE, 'original'));
|
||||
const wrapper = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download-caret').trigger('click');
|
||||
const items = wrapper.findAll('.download-menu button');
|
||||
expect(items.map((item) => item.text())).toEqual(['下载图片', '下载原图']);
|
||||
|
||||
await items[1]!.trigger('click');
|
||||
await flushPromises();
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'original');
|
||||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-original.jpg', '_blank', 'noopener,noreferrer');
|
||||
// 选择后菜单收起。
|
||||
expect(wrapper.find('.download-menu').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('main button downloads browse; expired credential is re-requested exactly once', async () => {
|
||||
// 首响应已过期(缓存/时钟漂移)→ 重取一次 → 打开第二个 URL。
|
||||
mockedGetPhotoDownload
|
||||
.mockResolvedValueOnce(credential('http://img.test/p1-browse-stale.jpg', PAST, 'browse'))
|
||||
.mockResolvedValueOnce(credential('http://img.test/p1-browse-fresh.jpg', FUTURE, 'browse'));
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(2);
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledWith('tok-dl', 'p1', 'browse');
|
||||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse-fresh.jpg', '_blank', 'noopener,noreferrer');
|
||||
});
|
||||
|
||||
it('fresh credential opens directly without a second request; failure shows a retryable notice', async () => {
|
||||
mockedGetPhotoDownload.mockResolvedValue(credential('http://img.test/p1-browse.jpg', FUTURE, 'browse'));
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(mockedGetPhotoDownload).toHaveBeenCalledTimes(1);
|
||||
expect(openSpy).toHaveBeenCalledWith('http://img.test/p1-browse.jpg', '_blank', 'noopener,noreferrer');
|
||||
expect(wrapper.find('.download-failed').exists()).toBe(false);
|
||||
|
||||
// 端点失败(网络/403 等)→ 失败提示可再见(再次点击重试)。
|
||||
openSpy.mockClear();
|
||||
mockedGetPhotoDownload.mockReset().mockRejectedValue(new Error('boom'));
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('WeChat webview: click opens the guidance overlay instead of hitting the endpoint', async () => {
|
||||
stubUserAgent(WECHAT_IOS_UA);
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await flushPromises();
|
||||
expect(wrapper.find('.wechat-guide[role="dialog"]').exists()).toBe(true);
|
||||
expect(wrapper.find('.wechat-guide h2').text()).toBe('保存图片指引');
|
||||
expect(mockedGetPhotoDownload).not.toHaveBeenCalled();
|
||||
expect(openSpy).not.toHaveBeenCalled();
|
||||
|
||||
// 知道了:浮层收起(再点击可重新触发)。
|
||||
await wrapper.find('.guide-dismiss').trigger('click');
|
||||
expect(wrapper.find('.wechat-guide').exists()).toBe(false);
|
||||
});
|
||||
|
||||
it('guidance copy link writes the current photo deep link with feedback', async () => {
|
||||
stubUserAgent(WECHAT_IOS_UA);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(window.navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
const wrapper = mountLightbox(makeAlbum());
|
||||
await flushPromises();
|
||||
|
||||
await wrapper.find('.control.download').trigger('click');
|
||||
await wrapper.find('.guide-copy').trigger('click');
|
||||
await flushPromises();
|
||||
expect(writeText).toHaveBeenCalledWith(window.location.href);
|
||||
expect(wrapper.find('.copy-status').text()).toBe('已复制链接');
|
||||
});
|
||||
});
|
||||
@@ -27,6 +27,9 @@ export function makeAlbum(
|
||||
],
|
||||
banner: null,
|
||||
viewCount: null,
|
||||
// P4a Task 4:下载两级开关(契约 PublicAlbum 必返字段;缺省 on/off)。
|
||||
downloadEnabled: true,
|
||||
originalDownloadEnabled: false,
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user