Import the 2026-10-09 source release, Fastify/SQLite backend, restored source assets, and analytics-enabled server build. Replace files from the previous release while retaining the existing repository history. Make builds portable across Windows and Linux, configure local startup, normalize Windows static asset paths, and fix test fixture cleanup and file URL handling. Exclude local environment settings, databases, dependencies, validation output, and TypeScript build cache from version control. Validation: npm ci, static and server builds, 106 arrival tests, and actual local HTTP checks passed. The full suite passed 467 of 470 tests; three tests still require QA fixtures omitted from the supplied release, documented in README-서버실행.md.
204 lines
11 KiB
JavaScript
204 lines
11 KiB
JavaScript
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();
|
|
});
|