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(); return { ...actual, postAlbumVisit: vi.fn() }; }); const mockedPostAlbumVisit = vi.mocked(postAlbumVisit); const mounted: VueWrapper[] = []; function mountReport(token: string): ReturnType { let report!: ReturnType; 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); // 无重试 }); });