127 lines
4.9 KiB
TypeScript
127 lines
4.9 KiB
TypeScript
|
|
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
|||
|
|
import { defineComponent, ref } from 'vue';
|
|||
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|||
|
|
import { ApiError, postAlbumVisit } from '../api/client';
|
|||
|
|
import { useVisitReport } from '../stats/visitor';
|
|||
|
|
import { VISITOR_STORAGE_KEY } from '../stats/visitor';
|
|||
|
|
|
|||
|
|
// useVisitReport(Task 10 浏览上报编排):加载成功且页面可见才报(隐藏时等
|
|||
|
|
// 首次可见)/ 单相册一次 / 网络失败同 requestId 退避重试 / 4xx 静默放弃 /
|
|||
|
|
// requestId 持久 sessionStorage 直到成功响应。visitorId 预置 localStorage
|
|||
|
|
// 走快路径(跨标签协调单独在 visitor-id.spec 覆盖)。
|
|||
|
|
|
|||
|
|
vi.mock('../api/client', async (importOriginal) => {
|
|||
|
|
const actual = await importOriginal<typeof import('../api/client')>();
|
|||
|
|
return { ...actual, postAlbumVisit: vi.fn() };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const mockedPostAlbumVisit = vi.mocked(postAlbumVisit);
|
|||
|
|
|
|||
|
|
const mounted: VueWrapper[] = [];
|
|||
|
|
|
|||
|
|
function mountReport(token: string): ReturnType<typeof useVisitReport> {
|
|||
|
|
let report!: ReturnType<typeof useVisitReport>;
|
|||
|
|
const Host = defineComponent({
|
|||
|
|
setup() {
|
|||
|
|
report = useVisitReport({
|
|||
|
|
token: ref(token),
|
|||
|
|
onCounted: (count) => counted.push(count),
|
|||
|
|
});
|
|||
|
|
return () => null;
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
mounted.push(mount(Host));
|
|||
|
|
return report;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const counted: number[] = [];
|
|||
|
|
|
|||
|
|
/** jsdom document.hidden 只读:defineProperty 覆写(用例后还原)。 */
|
|||
|
|
function setHidden(hidden: boolean): void {
|
|||
|
|
Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
describe('visit report gating (Task 10)', () => {
|
|||
|
|
beforeEach(() => {
|
|||
|
|
localStorage.clear();
|
|||
|
|
sessionStorage.clear();
|
|||
|
|
localStorage.setItem(VISITOR_STORAGE_KEY, 'v'.repeat(32));
|
|||
|
|
counted.length = 0;
|
|||
|
|
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 1, counted: true });
|
|||
|
|
setHidden(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
afterEach(() => {
|
|||
|
|
while (mounted.length > 0) mounted.pop()?.unmount();
|
|||
|
|
vi.useRealTimers();
|
|||
|
|
vi.restoreAllMocks();
|
|||
|
|
// 收尾后在途上报微任务可能仍落到已还原的替身(返回 undefined → .then 崩):
|
|||
|
|
// 恢复后立即补挂兜底实现。
|
|||
|
|
mockedPostAlbumVisit.mockReset().mockResolvedValue({ viewCount: 0, counted: false });
|
|||
|
|
setHidden(false);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('reports once after album load while visible, then clears the persisted requestId', async () => {
|
|||
|
|
const report = mountReport('tok-visit');
|
|||
|
|
report.notifyAlbumLoaded();
|
|||
|
|
await flushPromises();
|
|||
|
|
|
|||
|
|
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
|||
|
|
const [token, body] = mockedPostAlbumVisit.mock.calls[0]!;
|
|||
|
|
expect(token).toBe('tok-visit');
|
|||
|
|
expect(body.visitorId).toBe('v'.repeat(32));
|
|||
|
|
expect(body.requestId).toMatch(/^[0-9a-f-]{8,}$/);
|
|||
|
|
expect(counted).toEqual([1]);
|
|||
|
|
expect(sessionStorage.getItem('viewer.visitRequest:tok-visit')).toBeNull(); // 确认后清除
|
|||
|
|
|
|||
|
|
report.notifyAlbumLoaded(); // 幂等:同相册只报一次
|
|||
|
|
await flushPromises();
|
|||
|
|
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('defers the report until the page becomes visible', async () => {
|
|||
|
|
setHidden(true);
|
|||
|
|
const report = mountReport('tok-hidden');
|
|||
|
|
report.notifyAlbumLoaded();
|
|||
|
|
await flushPromises();
|
|||
|
|
expect(mockedPostAlbumVisit).not.toHaveBeenCalled();
|
|||
|
|
|
|||
|
|
setHidden(false);
|
|||
|
|
document.dispatchEvent(new Event('visibilitychange'));
|
|||
|
|
await flushPromises();
|
|||
|
|
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('retries network failure with the same persisted requestId and succeeds', async () => {
|
|||
|
|
mockedPostAlbumVisit
|
|||
|
|
.mockRejectedValueOnce(new ApiError('NETWORK_ERROR', 'network request failed', 0, null))
|
|||
|
|
.mockResolvedValue({ viewCount: 6, counted: true });
|
|||
|
|
vi.useFakeTimers();
|
|||
|
|
const report = mountReport('tok-retry');
|
|||
|
|
report.notifyAlbumLoaded();
|
|||
|
|
await vi.advanceTimersByTimeAsync(0);
|
|||
|
|
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
|||
|
|
const requestId = mockedPostAlbumVisit.mock.calls[0]![1].requestId;
|
|||
|
|
// 失败期间 requestId 保持持久(刷新/重试幂等键不换)。
|
|||
|
|
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBe(requestId);
|
|||
|
|
|
|||
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|||
|
|
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(2);
|
|||
|
|
expect(mockedPostAlbumVisit.mock.calls[1]![1].requestId).toBe(requestId);
|
|||
|
|
expect(counted).toEqual([6]);
|
|||
|
|
expect(sessionStorage.getItem('viewer.visitRequest:tok-retry')).toBeNull();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
it('gives up silently on 4xx without retrying', async () => {
|
|||
|
|
mockedPostAlbumVisit.mockRejectedValue(new ApiError('RATE_LIMITED', 'too many', 429, null));
|
|||
|
|
vi.useFakeTimers();
|
|||
|
|
const report = mountReport('tok-4xx');
|
|||
|
|
report.notifyAlbumLoaded();
|
|||
|
|
await vi.advanceTimersByTimeAsync(0);
|
|||
|
|
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1);
|
|||
|
|
|
|||
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|||
|
|
expect(mockedPostAlbumVisit).toHaveBeenCalledTimes(1); // 无重试
|
|||
|
|
});
|
|||
|
|
});
|