Files
behind_glass/scripts/test-arrival-db.mjs
T

145 lines
7.7 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, statSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { openStore } from '../server/db.mjs';
import { readConfig } from '../server/config.mjs';
import Database from 'better-sqlite3';
const VISITOR_A = '12345678-1234-4123-8123-123456789abc';
const VISITOR_B = '87654321-4321-4321-a321-cba987654321';
const DOG = 'a'.repeat(64);
function fixture(t, close = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'arrival-db-'));
t.after(() => { close(); rmSync(root, { recursive: true, force: true }); });
return { root, dbPath: join(root, 'private', 'counts.sqlite') };
}
function inspect(path, fn) {
const db = new Database(path, { readonly: true, fileMustExist: true });
try { return fn(db); } finally { db.close(); }
}
test('real native SQLite uses exact schema, WAL, bounded busy timeout and private files', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
store = openStore({ dbPath });
assert.equal(store.arrivalCount(), 0);
inspect(dbPath, db => {
assert.match(db.prepare('SELECT sqlite_version() AS version').get().version, /^\d+\.\d+\.\d+$/);
assert.equal(db.pragma('journal_mode', { simple: true }), 'wal');
assert.equal(db.pragma('user_version', { simple: true }), 1);
assert.deepEqual(db.pragma('table_info(visitors_daily)').map(c => c.name), ['visitor_id', 'visit_date', 'first_seen_at', 'last_seen_at']);
assert.deepEqual(db.pragma('table_info(arrivals)').map(c => c.name), ['dog_id', 'first_arrived_at']);
assert.ok(db.pragma('index_list(visitors_daily)').some(i => db.pragma(`index_info(${i.name})`).map(c => c.name).join() === 'visit_date'));
});
if (process.platform !== 'win32') {
assert.equal(statSync(join(dbPath, '..')).mode & 0o777, 0o700);
for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) assert.equal(statSync(path).mode & 0o077, 0);
}
assert.throws(() => openStore({ dbPath, busyTimeoutMs: -1 }));
assert.throws(() => openStore({ dbPath, busyTimeoutMs: 10001 }));
});
test('daily upserts preserve first time, update last time and MAU is DISTINCT across days', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
let at = '2026-10-01T00:00:00.000Z';
store = openStore({ dbPath, now: () => new Date(at) });
store.recordVisit(VISITOR_A);
at = '2026-10-01T01:00:00.000Z';
store.recordVisit(VISITOR_A);
let rows = inspect(dbPath, db => db.prepare('SELECT * FROM visitors_daily').all());
assert.deepEqual(rows, [{ visitor_id: VISITOR_A, visit_date: '2026-10-01', first_seen_at: '2026-10-01T00:00:00.000Z', last_seen_at: at }]);
at = '2026-10-02T00:00:00.000Z';
store.recordVisit(VISITOR_A);
store.recordVisit(VISITOR_B);
assert.equal(store.monthlyVisitors('2026-10'), 2);
assert.equal(inspect(dbPath, db => db.prepare('SELECT COUNT(*) AS n FROM visitors_daily').get().n), 3);
assert.equal(store.monthlyVisitors('2026-09'), 0);
});
for (const [before, after, previousMonth, nextMonth] of [
['2026-10-31T14:59:59.000Z', '2026-10-31T15:00:00.000Z', '2026-10', '2026-11'],
['2026-12-31T14:59:59.000Z', '2026-12-31T15:00:00.000Z', '2026-12', '2027-01'],
]) test(`Seoul month boundary ${previousMonth} is independent of process timezone`, t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
let at = before;
store = openStore({ dbPath, now: () => new Date(at) });
store.recordVisit(VISITOR_A);
at = after;
store.recordVisit(VISITOR_B);
assert.equal(store.monthlyVisitors(previousMonth), 1);
assert.equal(store.monthlyVisitors(nextMonth), 1);
const dates = inspect(dbPath, db => db.prepare('SELECT visit_date FROM visitors_daily ORDER BY visit_date').all());
assert.equal(dates[1].visit_date, `${nextMonth}-01`);
});
test('arrivals are idempotent, first timestamp persists and migrations reopen without data loss', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
let at = '2026-10-08T00:00:00.000Z';
store = openStore({ dbPath, now: () => new Date(at) });
store.recordArrival(DOG);
at = '2026-10-09T00:00:00.000Z';
store.recordArrival(DOG);
assert.equal(store.arrivalCount(), 1);
store.close();
store.close();
store = openStore({ dbPath });
assert.equal(store.arrivalCount(), 1);
assert.deepEqual(inspect(dbPath, db => db.prepare('SELECT * FROM arrivals').all()), [{ dog_id: DOG, first_arrived_at: '2026-10-08T00:00:00.000Z' }]);
});
test('strict month validation handles year bounds and December rollover', t => {
let store;
const { dbPath } = fixture(t, () => store?.close());
store = openStore({ dbPath });
for (const month of [undefined, null, 202610, '', '2026-1', '2026-00', '2026-13', '0000-01', '10000-01', '2026-10-01', ' 2026-10', '2026-10\n']) assert.throws(() => store.monthlyVisitors(month));
for (const month of ['0001-01', '0099-12', '2026-12', '9999-12']) assert.equal(store.monthlyVisitors(month), 0);
});
test('configuration requires explicit absolute private DB path and strict origin/port', t => {
const { dbPath } = fixture(t);
const config = readConfig({ DB_PATH: dbPath });
assert.deepEqual(config, { dbPath, distRoot: resolve('dist'), host: '127.0.0.1', port: 3600, publicOrigin: 'http://127.0.0.1:3600' });
assert.equal(readConfig({ DB_PATH: dbPath, HOST: '0.0.0.0', PORT: '4010' }).host, '0.0.0.0');
assert.throws(() => readConfig({}));
assert.throws(() => readConfig({ DB_PATH: 'relative.sqlite' }));
assert.throws(() => readConfig({ DB_PATH: ':memory:' }));
for (const path of ['local.sqlite', 'src/db.sqlite', 'public/db.sqlite', 'dist/db.sqlite']) assert.throws(() => readConfig({ DB_PATH: resolve(path) }));
for (const value of ['0', '-1', '65536', '3e3', '3000x', '1.2', '']) assert.throws(() => readConfig({ DB_PATH: dbPath, PORT: value }));
assert.throws(() => readConfig({ DB_PATH: dbPath, NODE_ENV: 'production' }));
for (const origin of ['null', 'ftp://example.com', 'https://user:pass@example.com', 'https://example.com/path', 'https://example.com?x', 'https://example.com#x']) assert.throws(() => readConfig({ DB_PATH: dbPath, NODE_ENV: 'production', PUBLIC_ORIGIN: origin }));
assert.equal(readConfig({ DB_PATH: dbPath, NODE_ENV: 'production', PUBLIC_ORIGIN: 'https://example.com' }).publicOrigin, 'https://example.com');
});
test('canonical DB path validation catches existing and future paths through symlinks', t => {
const { root } = fixture(t);
symlinkSync(resolve('.'), join(root, 'source-link'), 'dir');
for (const path of [join(root, 'source-link', 'db.sqlite'), join(root, 'source-link', 'new', 'db.sqlite')]) {
assert.throws(() => readConfig({ DB_PATH: path }));
assert.throws(() => openStore({ dbPath: path }));
}
mkdirSync(join(root, 'private'), { mode: 0o700 });
symlinkSync(join(root, 'private'), join(root, 'safe-link'), 'dir');
const config = readConfig({ DB_PATH: join(root, 'safe-link', 'db.sqlite') });
assert.equal(config.dbPath, join(root, 'private', 'db.sqlite'));
});
test('DB paths reject raw and symlink-resolved whitespace before native filename trimming', async t => {
const { root, dbPath } = fixture(t);
const { writeFileSync } = await import('node:fs');
const { openReadOnlyStore } = await import('../server/db.mjs');
assert.throws(() => readConfig({ DB_PATH: `${dbPath} ` }));
assert.throws(() => openStore({ dbPath: `${dbPath}\t` }));
assert.throws(() => openReadOnlyStore(`${dbPath}\n`));
const spacedTarget = join(root, 'target.sqlite ');
writeFileSync(spacedTarget, '', { mode: 0o600 });
const alias = join(root, 'alias.sqlite');
symlinkSync(spacedTarget, alias);
assert.throws(() => readConfig({ DB_PATH: alias }));
assert.throws(() => openStore({ dbPath: alias }));
});