Replace project with current server release and Windows setup

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.
This commit is contained in:
2026-10-09 16:43:35 +09:00
parent 71c8d3b7ff
commit e358e24436
642 changed files with 35202 additions and 17642 deletions
+203
View File
@@ -0,0 +1,203 @@
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();
});