import assert from 'node:assert/strict'; import { access } from 'node:fs/promises'; import { test } from 'node:test'; import { loadTypescript } from './support/load-typescript.mjs'; const moduleURL = new URL('../src/analytics/ArrivalTracker.ts', import.meta.url); assert.equal(await access(moduleURL).then(() => true, () => false), true, 'journey collector must exist'); const { createArrivalTracker } = await loadTypescript(moduleURL); const { createTransport } = await loadTypescript(new URL('../src/analytics/Transport.ts', import.meta.url)); const visitorId = '01234567-89ab-4cde-8f01-23456789abcd'; const dogId = 'a'.repeat(64), otherDogId = 'b'.repeat(64); const flush = () => new Promise(resolve => setImmediate(resolve)); const deferred = () => { let resolve, reject; const promise = new Promise((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }; function collector(overrides = {}) { const calls = [], counts = [], hashes = []; const tracker = createArrivalTracker({ visitorId, makeDogId: async (visitor, name) => { hashes.push({ visitor, name }); return dogId; }, transport: { request: async (path, body, signal) => { calls.push({ path, body, signal }); return body === undefined ? { ok: true, count: 7 } : { ok: true }; } }, onCount: count => counts.push(count), ...overrides, }); return { tracker, calls, counts, hashes }; } test('start is nonblocking and sends exactly one visit and initial count', async () => { const { tracker, calls, counts } = collector(); assert.equal(tracker.start(), undefined); tracker.start(); await flush(); assert.deepEqual(calls.map(({ path, body }) => ({ path, body })), [ { path: '/api/visits', body: { visitor_id: visitorId } }, { path: '/api/arrivals/count', body: undefined }, ]); assert.deepEqual(counts, [7]); tracker.dispose(); }); test('sample arrivals are excluded without suppressing their visit', async () => { const { tracker, calls, hashes } = collector(); tracker.start(); tracker.arrived(tracker.begin('sample', '호롱')); await flush(); assert.equal(calls.filter(call => call.path === '/api/visits').length, 1); assert.equal(calls.some(call => call.path === '/api/arrivals'), false); assert.deepEqual(hashes, []); tracker.dispose(); }); test('begin alone never registers an arrival and duplicate arrived calls send once', async () => { const { tracker, calls } = collector(); const token = tracker.begin('personal', '호롱'); await flush(); assert.deepEqual(calls, []); assert.equal(tracker.arrived(token), undefined); tracker.arrived(token); await flush(); tracker.arrived(token); await flush(); assert.deepEqual(calls.map(call => call.path), ['/api/arrivals', '/api/arrivals/count']); assert.deepEqual(calls[0].body, { dog_id: dogId }); tracker.dispose(); }); test('hash completion after photo replacement cannot register the old journey', async () => { const old = deferred(), next = deferred(); let hashCalls = 0; const { tracker, calls } = collector({ makeDogId: () => ++hashCalls === 1 ? old.promise : next.promise }); const stale = tracker.begin('personal', '호롱'); tracker.arrived(stale); const current = tracker.begin('personal', '보리'); assert.ok(current > stale); tracker.arrived(stale); tracker.arrived(current); old.resolve(dogId); await flush(); assert.deepEqual(calls, []); next.resolve(otherDogId); await flush(); assert.deepEqual(calls.filter(call => call.path === '/api/arrivals').map(call => call.body), [{ dog_id: otherDogId }]); tracker.dispose(); }); test('explicit invalidation makes delayed hashes and old tokens inert', async () => { const pending = deferred(); const { tracker, calls } = collector({ makeDogId: () => pending.promise }); const token = tracker.begin('personal', '호롱'); tracker.arrived(token); tracker.invalidate(); pending.resolve(dogId); tracker.arrived(token); await flush(); assert.deepEqual(calls, []); tracker.dispose(); }); test('dispose cancels visits, count reads and delayed hash continuations permanently', async () => { const pending = deferred(), calls = []; const { tracker, counts } = collector({ makeDogId: () => pending.promise, transport: { request: (path, body, signal) => { calls.push({ path, signal }); return pending.promise.then(() => body === undefined ? { ok: true, count: 99 } : { ok: true }); } }, }); tracker.start(); const token = tracker.begin('personal', '호롱'); tracker.arrived(token); tracker.dispose(); tracker.dispose(); tracker.start(); tracker.arrived(tracker.begin('personal', '보리')); assert.ok(calls.every(call => call.signal.aborted)); pending.resolve(dogId); await flush(); assert.deepEqual(counts, []); assert.deepEqual(calls.map(call => call.path), ['/api/visits', '/api/arrivals/count']); }); test('begin synchronously captures the original visitor and raw name', async () => { const pending = deferred(), captured = []; const options = { visitorId, makeDogId: (visitor, name) => { captured.push({ visitor, name }); return pending.promise; } }; const { tracker, calls } = collector(options); let rawName = ' 호롱 '; const token = tracker.begin('personal', rawName); rawName = '보리'; options.visitorId = 'replacement'; assert.deepEqual(captured, [{ visitor: visitorId, name: ' 호롱 ' }]); tracker.arrived(token); pending.resolve(dogId); await flush(); assert.deepEqual(calls[0].body, { dog_id: dogId }); tracker.dispose(); }); test('transport retries retain the journey ID after a name edit', async () => { const sent = [], delays = []; let name = '호롱'; const transport = createTransport({ fetch: async (path, init) => { if (path.endsWith('/count')) return { status: 200, json: async () => ({ count: 8 }) }; sent.push(init.body); name = '보리'; return { status: sent.length === 1 ? 503 : 204 }; }, sleep: async ms => { delays.push(ms); }, }); const { tracker, counts } = collector({ transport, makeDogId: async (_visitor, captured) => captured === '호롱' ? dogId : otherDogId }); tracker.arrived(tracker.begin('personal', name)); await flush(); assert.equal(name, '보리'); assert.deepEqual(sent, [JSON.stringify({ dog_id: dogId }), JSON.stringify({ dog_id: dogId })]); assert.deepEqual(delays, [500]); assert.deepEqual(counts, [8]); tracker.dispose(); }); test('invalidation during delayed retry cancels the next attempt', async t => { t.mock.timers.enable({ apis: ['setTimeout'] }); let calls = 0; const transport = createTransport({ fetch: async () => { calls++; return { status: 503 }; } }); const { tracker } = collector({ transport }); tracker.arrived(tracker.begin('personal', '호롱')); await flush(); assert.equal(calls, 1); tracker.invalidate(); t.mock.timers.tick(10000); await flush(); assert.equal(calls, 1); tracker.dispose(); }); test('successful arrival refreshes the count while failed registration does not', async () => { for (const ok of [true, false]) { const calls = []; const { tracker, counts } = collector({ transport: { request: async path => { calls.push(path); return path.endsWith('/count') ? { ok: true, count: 12 } : { ok }; } } }); tracker.arrived(tracker.begin('personal', '호롱')); await flush(); assert.deepEqual(calls, ok ? ['/api/arrivals', '/api/arrivals/count'] : ['/api/arrivals']); assert.deepEqual(counts, ok ? [12] : []); tracker.dispose(); } }); test('late initial count cannot overwrite a newer successful arrival refresh', async () => { const initial = deferred(), fresh = deferred(); let reads = 0; const { tracker, counts } = collector({ transport: { request: async path => path.endsWith('/count') ? (++reads === 1 ? initial.promise : fresh.promise) : { ok: true } } }); tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); await flush(); fresh.resolve({ ok: true, count: 11 }); await flush(); initial.resolve({ ok: true, count: 10 }); await flush(); assert.deepEqual(counts, [11]); tracker.dispose(); }); test('a failed newer count read preserves the last good count and permits an earlier valid read', async () => { const initial = deferred(), fresh = deferred(); let reads = 0; const { tracker, counts } = collector({ transport: { request: async path => path.endsWith('/count') ? (++reads === 1 ? initial.promise : fresh.promise) : { ok: true } } }); tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); await flush(); fresh.resolve({ ok: false }); await flush(); initial.resolve({ ok: true, count: 5 }); await flush(); assert.deepEqual(counts, [5]); tracker.dispose(); }); test('missing visitor disables identification events while aggregate reads remain available', async () => { const { tracker, calls, hashes, counts } = collector({ visitorId: undefined }); tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); await flush(); assert.deepEqual(calls.map(call => call.path), ['/api/arrivals/count']); assert.deepEqual(hashes, []); assert.deepEqual(counts, [7]); tracker.dispose(); }); test('throwing and rejected hash or transport failures never escape fire-and-forget methods', async () => { for (const makeDogId of [() => { throw Error('crypto'); }, async () => { throw Error('crypto'); }, async () => '', async () => 'invalid']) { const { tracker, calls } = collector({ makeDogId }); assert.doesNotThrow(() => tracker.arrived(tracker.begin('personal', '호롱'))); await flush(); assert.deepEqual(calls, []); tracker.dispose(); } for (const request of [() => { throw Error('network'); }, async () => { throw Error('network'); }]) { const { tracker, counts } = collector({ transport: { request } }); assert.doesNotThrow(() => { tracker.start(); tracker.arrived(tracker.begin('personal', '호롱')); }); await flush(); assert.deepEqual(counts, []); tracker.dispose(); } const { tracker } = collector({ onCount: () => { throw Error('removed DOM'); } }); tracker.start(); await flush(); tracker.dispose(); }); test('invalid count responses cannot reach presentation even through an injected transport', async () => { for (const count of [-1, 0.5, '3', Number.MAX_SAFE_INTEGER + 1, undefined]) { const { tracker, counts } = collector({ transport: { request: async () => ({ ok: true, count }) } }); tracker.start(); await flush(); assert.deepEqual(counts, []); tracker.dispose(); } }); test('invalidation cancels pending work but cannot undo an already accepted server write', async () => { const accepted = [], pending = deferred(); const { tracker, counts } = collector({ transport: { request: async (path, body) => { accepted.push({ path, body }); return pending.promise; } } }); tracker.arrived(tracker.begin('personal', '호롱')); await flush(); tracker.invalidate(); pending.resolve({ ok: true }); await flush(); assert.deepEqual(accepted, [{ path: '/api/arrivals', body: { dog_id: dogId } }]); assert.deepEqual(counts, []); tracker.dispose(); });