From 1c559263a865c0a28eedebe18f2e94be3ebff883 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Wed, 1 Apr 2026 13:54:43 +0200 Subject: [PATCH] Add analytics.js test suite with 100% line/function coverage (16 tests) Tests cover: init with missing config, importKey failure, visitor creation, session reuse, sendBeacon usage, 403 retry flow, network errors, decrypt failures, setAuth with/without session/key, and authenticated user tracking. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/js/analytics.test.js | 316 +++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 tests/js/analytics.test.js diff --git a/tests/js/analytics.test.js b/tests/js/analytics.test.js new file mode 100644 index 0000000..72164fb --- /dev/null +++ b/tests/js/analytics.test.js @@ -0,0 +1,316 @@ +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('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) + }) +})