Files
photo-live/apps/viewer/src/__tests__/download.spec.ts
T

325 lines
14 KiB
TypeScript
Raw Normal View History

import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
import { defineComponent, nextTick, ref } from 'vue';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
ApiError,
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/currentRef(ref 经代理解包)+ download 控制器。 */
interface HostVm {
albumRef: PublicAlbum | null;
currentRef: string;
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, currentRef: 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('.download-main').exists()).toBe(true);
expect(browseOnly.find('.download-caret').exists()).toBe(false);
// on/on:分体按钮(caret 出现,菜单默认收起)。
const split = mountLightbox(makeAlbum({ originalDownloadEnabled: true }));
await flushPromises();
expect(split.find('.download-caret').exists()).toBe(true);
expect(split.find('.download-menu').exists()).toBe(false);
// 主按钮 aria 展开 = false。
expect(split.find('.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('.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('.download-main').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('.download-main').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('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
expect(openSpy).not.toHaveBeenCalled();
});
it('attributes an async failure to the photo that triggered the download, not the current one', async () => {
// 在途请求挂起期间切图:错误归属触发时的 photoId(UI-02 §5.4)。
let rejectDownload!: (error: Error) => void;
mockedGetPhotoDownload.mockReset().mockImplementation(
() =>
new Promise((_resolve, reject) => {
rejectDownload = reject;
}),
);
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.download-main').trigger('click'); // p1 触发
wrapper.vm.currentRef = 'p2'; // 请求在途时切到 p2
rejectDownload(new Error('boom'));
await flushPromises();
expect(wrapper.vm.download.failedFor).toBe('p1');
expect(wrapper.find('.download-failed').exists()).toBe(false); // p2 不背锅
wrapper.vm.currentRef = 'p1'; // 切回 p1:错误提示可见
await nextTick();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('下载失败');
});
it('DOWNLOAD_DISABLED surfaces the dedicated localized notice (T15 漏译修复)', async () => {
// 相册中途关闭下载开关(载荷仍开但端点 403)→ 专属文案而非通用失败。
mockedGetPhotoDownload.mockReset().mockRejectedValue(
new ApiError('DOWNLOAD_DISABLED', 'downloads disabled', 403, null),
);
const wrapper = mountLightbox(makeAlbum());
await flushPromises();
await wrapper.find('.download-main').trigger('click');
await flushPromises();
expect(wrapper.find('.download-failed[role="alert"]').text()).toContain('该相册未开放下载');
expect(wrapper.find('.download-failed').text()).not.toContain('下载失败');
});
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('.download-main').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('.download-main').trigger('click');
await wrapper.find('.guide-copy').trigger('click');
await flushPromises();
expect(writeText).toHaveBeenCalledWith(window.location.href);
expect(wrapper.find('.copy-status').text()).toBe('已复制链接');
});
});