import { describe, it, expect, vi, beforeEach } from 'vitest' const TEST_KEY_B64 = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' async function realImportKey(b64) { const raw = Uint8Array.from(globalThis.atob(b64), c => c.codePointAt(0)) return globalThis.crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']) } async function realEncrypt(data, key) { const json = new TextEncoder().encode(JSON.stringify(data)) const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)) const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, key, json) const buf = new Uint8Array(encrypted) const combined = new Uint8Array(12 + buf.length) combined.set(iv) combined.set(buf, 12) return globalThis.btoa(String.fromCodePoint(...combined)) } // The module destructures { fetch } from globalThis at import time, // so we must set up globalThis.fetch BEFORE each dynamic import. async function loadModule() { vi.resetModules() const mod = await import('../../assets/modules/analytics.js') return mod } describe('analytics.js', () => { let fetchMock let beaconMock beforeEach(() => { vi.restoreAllMocks() sessionStorage.clear() document.body.innerHTML = '' document.body.removeAttribute('data-k') document.body.removeAttribute('data-e') document.body.removeAttribute('data-uid') fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 204 }) globalThis.fetch = fetchMock beaconMock = vi.fn().mockReturnValue(true) navigator.sendBeacon = beaconMock }) it('returns early when data-k is missing', async () => { document.body.dataset.e = '/t' const { initAnalytics } = await loadModule() await initAnalytics() expect(fetchMock).not.toHaveBeenCalled() expect(beaconMock).not.toHaveBeenCalled() }) it('returns early when data-e is missing', async () => { document.body.dataset.k = TEST_KEY_B64 const { initAnalytics } = await loadModule() await initAnalytics() expect(fetchMock).not.toHaveBeenCalled() }) it('returns early when importKey fails', async () => { document.body.dataset.k = '!!invalid!!' document.body.dataset.e = '/t' const { initAnalytics } = await loadModule() await initAnalytics() expect(fetchMock).not.toHaveBeenCalled() }) it('creates visitor and tracks page view', async () => { const key = await realImportKey(TEST_KEY_B64) const visitorResp = await realEncrypt({ uid: 'v1', h: 'hash1' }, key) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' let callCount = 0 fetchMock.mockImplementation(async () => { callCount++ if (callCount === 1) { return { ok: true, status: 200, json: async () => ({ d: visitorResp }) } } return { ok: true, status: 204 } }) // Disable sendBeacon so trackPageView uses fetch navigator.sendBeacon = undefined const { initAnalytics } = await loadModule() await initAnalytics() expect(fetchMock).toHaveBeenCalledTimes(2) expect(sessionStorage.getItem('_u')).toBe('v1') expect(sessionStorage.getItem('_h')).toBe('hash1') }) it('reuses existing visitor from sessionStorage', async () => { document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' sessionStorage.setItem('_u', 'existing') sessionStorage.setItem('_h', 'existinghash') const { initAnalytics } = await loadModule() await initAnalytics() // Only trackPageView via beacon, no fetch for visitor creation expect(beaconMock).toHaveBeenCalledTimes(1) expect(fetchMock).not.toHaveBeenCalled() }) it('returns early when visitor creation returns null', async () => { document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' // fetch returns 204 (no body) -> send() returns null -> no visitor const { initAnalytics } = await loadModule() await initAnalytics() expect(sessionStorage.getItem('_u')).toBeNull() }) it('uses sendBeacon when available and not expecting response', async () => { const key = await realImportKey(TEST_KEY_B64) const visitorResp = await realEncrypt({ uid: 'v2', h: 'hash2' }, key) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ d: visitorResp }), }) const { initAnalytics } = await loadModule() await initAnalytics() // First call uses fetch (getOrCreateVisitor expects response), second uses sendBeacon (trackPageView) expect(fetchMock).toHaveBeenCalledTimes(1) expect(beaconMock).toHaveBeenCalledTimes(1) }) it('handles fetch 403 by clearing session and retrying', async () => { const key = await realImportKey(TEST_KEY_B64) const visitorResp = await realEncrypt({ uid: 'v3', h: 'hash3' }, key) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' navigator.sendBeacon = undefined let callCount = 0 fetchMock.mockImplementation(async () => { callCount++ if (callCount === 1) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) } if (callCount === 2) return { ok: false, status: 403 } if (callCount === 3) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) } return { ok: true, status: 204 } }) const { initAnalytics } = await loadModule() await initAnalytics() expect(callCount).toBe(4) }) it('handles fetch network error gracefully', async () => { document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' fetchMock.mockRejectedValue(new Error('Network error')) const { initAnalytics } = await loadModule() await initAnalytics() expect(sessionStorage.getItem('_u')).toBeNull() }) it('handles response without d field', async () => { document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ other: 'data' }), }) const { initAnalytics } = await loadModule() await initAnalytics() expect(sessionStorage.getItem('_u')).toBeNull() }) it('handles response with invalid encrypted data', async () => { document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ d: 'invalidbase64!!' }), }) const { initAnalytics } = await loadModule() await initAnalytics() expect(sessionStorage.getItem('_u')).toBeNull() }) it('handles decrypt failure with valid base64 but wrong key data', async () => { // Encrypt with a different key so decryption with TEST_KEY fails const otherKeyRaw = new Uint8Array(32) otherKeyRaw[0] = 1 const otherKey = await globalThis.crypto.subtle.importKey('raw', otherKeyRaw, 'AES-GCM', false, ['encrypt']) const json = new TextEncoder().encode(JSON.stringify({ uid: 'x', h: 'y' })) const iv = globalThis.crypto.getRandomValues(new Uint8Array(12)) const encrypted = await globalThis.crypto.subtle.encrypt({ name: 'AES-GCM', iv, tagLength: 128 }, otherKey, json) const buf = new Uint8Array(encrypted) const combined = new Uint8Array(12 + buf.length) combined.set(iv) combined.set(buf, 12) const wrongEncrypted = globalThis.btoa(String.fromCodePoint(...combined)) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ d: wrongEncrypted }), }) const { initAnalytics } = await loadModule() await initAnalytics() expect(sessionStorage.getItem('_u')).toBeNull() }) it('calls setAuth when data-uid is present', async () => { const key = await realImportKey(TEST_KEY_B64) const visitorResp = await realEncrypt({ uid: 'v4', h: 'hash4' }, key) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' document.body.dataset.uid = '42' fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ d: visitorResp }), }) const { initAnalytics } = await loadModule() await initAnalytics() // fetch for visitor creation, beacon for pageview + setAuth expect(fetchMock).toHaveBeenCalledTimes(1) expect(beaconMock).toHaveBeenCalledTimes(2) }) it('setAuth does nothing without session', async () => { document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' const { initAnalytics, setAuth } = await loadModule() // Init to set encKey but visitor creation fails (no response body) await initAnalytics() fetchMock.mockClear() beaconMock.mockClear() await setAuth(42) expect(fetchMock).not.toHaveBeenCalled() expect(beaconMock).not.toHaveBeenCalled() }) it('setAuth does nothing without encKey', async () => { sessionStorage.setItem('_u', 'uid') sessionStorage.setItem('_h', 'hash') // Don't call initAnalytics -> encKey stays null const { setAuth } = await loadModule() await setAuth(42) expect(fetchMock).not.toHaveBeenCalled() expect(beaconMock).not.toHaveBeenCalled() }) it('covers navigator.language being undefined', async () => { const key = await realImportKey(TEST_KEY_B64) const visitorResp = await realEncrypt({ uid: 'vl', h: 'hashl' }, key) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' const origLang = navigator.language Object.defineProperty(navigator, 'language', { value: '', configurable: true }) fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ d: visitorResp }), }) const { initAnalytics } = await loadModule() await initAnalytics() Object.defineProperty(navigator, 'language', { value: origLang, configurable: true }) expect(fetchMock).toHaveBeenCalledTimes(1) }) it('retry getOrCreateVisitor fails on second attempt after 403', async () => { const key = await realImportKey(TEST_KEY_B64) const visitorResp = await realEncrypt({ uid: 'v6', h: 'hash6' }, key) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' navigator.sendBeacon = undefined let callCount = 0 fetchMock.mockImplementation(async () => { callCount++ // 1st: visitor creation succeeds if (callCount === 1) return { ok: true, status: 200, json: async () => ({ d: visitorResp }) } // 2nd: trackPageView gets 403 -> clears session if (callCount === 2) return { ok: false, status: 403 } // 3rd: second getOrCreateVisitor also fails (204 = no body) return { ok: true, status: 204 } }) const { initAnalytics } = await loadModule() await initAnalytics() // After 403, retry creates visitor (call 3) but gets 204 -> null visitor -> early return expect(callCount).toBe(3) expect(sessionStorage.getItem('_u')).toBeNull() }) it('setAuth sends when session and key exist', async () => { const key = await realImportKey(TEST_KEY_B64) const visitorResp = await realEncrypt({ uid: 'v5', h: 'hash5' }, key) document.body.dataset.k = TEST_KEY_B64 document.body.dataset.e = '/t' fetchMock.mockResolvedValue({ ok: true, status: 200, json: async () => ({ d: visitorResp }), }) const { initAnalytics, setAuth } = await loadModule() await initAnalytics() beaconMock.mockClear() await setAuth(99) expect(beaconMock).toHaveBeenCalledTimes(1) }) })