Files
e-ticket/tests/js/analytics.test.js
Serreau Jovann b1ec125bb9 Achieve 100% coverage on analytics.js
- Add test for navigator.language falsy branch
- Add test for retry getOrCreateVisitor failing on second attempt
- Mark unreachable defensive guards (encrypt/decrypt/send with null encKey)
  with c8 ignore since they cannot be triggered via public API

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 20:17:21 +02:00

367 lines
12 KiB
JavaScript

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)
})
})