import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, existsSync, copyFileSync, rmSync, statSync, writeFileSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; const VISITOR = '12345678-1234-4123-8123-123456789abc'; const DOG = 'b'.repeat(64); function fixture() { const root = mkdtempSync(join(tmpdir(), 'arrival-ops-')); return { root, dbPath: join(root, 'private', 'counts.sqlite') }; } function cli(script, args, dbPath, env = {}) { return spawnSync(process.execPath, [resolve(`server/${script}.mjs`), ...args], { env: { ...process.env, DB_PATH: dbPath, ...env }, encoding: 'utf8' }); } async function setup(t) { const { openStore, migrateDatabase } = await import('../server/db.mjs'); const { default: Database } = await import('better-sqlite3'); const fixtureData = fixture(); let at = '2026-10-08T00:00:00.000Z'; const store = openStore({ dbPath: fixtureData.dbPath, now: () => new Date(at) }); t.after(() => { store.close(); rmSync(fixtureData.root, { recursive: true, force: true }); }); store.recordVisit(VISITOR); store.recordArrival(DOG); at = '2026-10-09T00:00:00.000Z'; store.recordVisit(VISITOR); store.recordArrival(DOG); return { ...fixtureData, store, openStore, migrateDatabase, Database }; } function snapshot(Database, path) { const db = new Database(path, { readonly: true, fileMustExist: true }); try { assert.equal(db.pragma('integrity_check', { simple: true }), 'ok'); return { visits: db.prepare('SELECT * FROM visitors_daily ORDER BY visitor_id,visit_date').all(), arrivals: db.prepare('SELECT * FROM arrivals ORDER BY dog_id').all(), version: db.pragma('user_version', { simple: true }) }; } finally { db.close(); } } test('operational CLI entrypoints exist without starting an HTTP listener', () => { for (const script of ['metrics', 'backup']) assert.ok(existsSync(resolve(`server/${script}.mjs`)), `missing ${script} CLI`); }); test('PM2 fork imports execute the server entrypoint instead of silently skipping startup', () => { const output = spawnSync(process.execPath, ['--input-type=module', '-e', "import { pathToFileURL } from 'node:url'; process.argv[1] = 'ProcessContainerFork.js'; await import(pathToFileURL(process.env.pm_exec_path).href);", ], { env: { ...process.env, DB_PATH: '', NODE_ENV: 'production', pm_exec_path: resolve('server/index.mjs') }, encoding: 'utf8', }); assert.equal(output.status, 1, 'the entrypoint must attempt startup and reject the missing database configuration'); assert.match(output.stderr, /Server startup failed/); }); test('metrics prints aggregate-only JSON from an existing read-only database', async t => { const { store, dbPath } = await setup(t); const output = cli('metrics', ['--month', '2026-10'], dbPath); assert.equal(output.status, 0, output.stderr); assert.deepEqual(JSON.parse(output.stdout), { month: '2026-10', monthlyVisitors: 1, arrivals: 1 }); assert.equal(output.stderr, ''); assert.doesNotMatch(output.stdout, /12345678|bbbbbbbbbb|visitor_id|dog_id/); assert.equal(store.arrivalCount(), 1); }); test('metrics invalid arguments, missing path and bad schema fail without creating/migrating a DB', async t => { const { dbPath, root, Database } = await setup(t); for (const args of [[], ['--month', '2026-13'], ['--month', '0000-01'], ['--month', '2026-1'], ['--month', '2026-10', '--extra'], ['--month', '2026-10', '--month', '2026-11']]) { const output = cli('metrics', args, dbPath); assert.notEqual(output.status, 0); assert.equal(output.stdout, ''); assert.doesNotMatch(output.stderr, /12345678|bbbbbbbbbb|arrival-ops-|SELECT|SQLITE/); } const missing = join(root, 'typo', 'missing.sqlite'); assert.notEqual(cli('metrics', ['--month', '2026-10'], missing).status, 0); assert.equal(existsSync(missing), false); assert.equal(existsSync(join(root, 'typo')), false); const malformed = join(root, 'bad.sqlite'); const db = new Database(malformed); db.exec('CREATE TABLE wrong(value TEXT)'); db.close(); assert.notEqual(cli('metrics', ['--month', '2026-10'], malformed).status, 0); const verify = new Database(malformed); assert.equal(verify.pragma('user_version', { simple: true }), 0); verify.close(); }); test('live SQLite backup restores both tables, first timestamps and metrics; restored writes work', async t => { const { dbPath, root, store, openStore, Database } = await setup(t); const backupPath = join(root, 'backups', 'snapshot.sqlite'); const before = snapshot(Database, dbPath); assert.ok(existsSync(`${dbPath}-wal`)); const output = cli('backup', ['--output', backupPath], dbPath); assert.equal(output.status, 0, output.stderr); assert.doesNotMatch(output.stdout + output.stderr, /12345678|bbbbbbbbbb|visitor_id|dog_id/); assert.deepEqual(snapshot(Database, backupPath), before); if (process.platform !== 'win32') assert.equal(statSync(backupPath).mode & 0o077, 0); store.recordArrival('c'.repeat(64)); assert.equal(store.arrivalCount(), 2); assert.equal(snapshot(Database, backupPath).arrivals.length, 1); store.close(); const restorePath = join(root, 'restore', 'counts.sqlite'); mkdirSync(join(root, 'restore'), { mode: 0o700 }); copyFileSync(backupPath, restorePath); assert.deepEqual(snapshot(Database, restorePath), before); assert.deepEqual(JSON.parse(cli('metrics', ['--month', '2026-10'], restorePath).stdout), { month: '2026-10', monthlyVisitors: 1, arrivals: 1 }); const restored = openStore({ dbPath: restorePath }); restored.recordArrival('d'.repeat(64)); assert.equal(restored.arrivalCount(), 2); restored.close(); assert.equal(snapshot(Database, restorePath).arrivals.length, 2); }); test('backup rejects overwrite, same source, invalid args, missing DB and destination failure', async t => { const { dbPath, root, store } = await setup(t); const existing = join(root, 'already.sqlite'); writeFileSync(existing, 'do not overwrite'); for (const args of [[], ['--output', dbPath], ['--output', existing], ['--output', 'relative.sqlite'], ['--output', join(root, 'new.sqlite'), '--extra']]) assert.notEqual(cli('backup', args, dbPath).status, 0); assert.equal(readFileSync(existing, 'utf8'), 'do not overwrite'); const missing = join(root, 'missing.sqlite'); assert.notEqual(cli('backup', ['--output', join(root, 'out.sqlite')], missing).status, 0); assert.equal(existsSync(missing), false); await assert.rejects(store.backup(dbPath)); await assert.rejects(store.backup(existing)); const blockingFile = join(root, 'file'); writeFileSync(blockingFile, 'x'); await assert.rejects(store.backup(join(blockingFile, 'out.sqlite'))); }); test('failed versioned migration rolls back DDL and user_version without losing prior data', async t => { const { root, migrateDatabase, Database } = await setup(t); const path = join(root, 'rollback.sqlite'); const db = new Database(path); db.exec('CREATE TABLE retained (value TEXT); INSERT INTO retained VALUES (\'kept\'); PRAGMA user_version = 1'); assert.throws(() => migrateDatabase(db, [{ version: 1, sql: '' }, { version: 2, sql: 'CREATE TABLE temporary_change(x TEXT); INVALID SQL STATEMENT;' }])); assert.equal(db.pragma('user_version', { simple: true }), 1); assert.equal(db.prepare('SELECT value FROM retained').get().value, 'kept'); assert.equal(db.prepare("SELECT COUNT(*) AS n FROM sqlite_master WHERE name = 'temporary_change'").get().n, 0); db.close(); }); test('default and backend builds explicitly select analytics disabled/enabled', () => { const pkg = JSON.parse(readFileSync(resolve('package.json'), 'utf8')); assert.match(pkg.scripts.build, /VITE_ARRIVAL_ANALYTICS=false/); assert.match(pkg.scripts['build:server'], /VITE_ARRIVAL_ANALYTICS=true/); }); test('backup never overwrites through raw or symlink-resolved whitespace filenames', async t => { const { dbPath, root, store, Database } = await setup(t); const { symlinkSync } = await import('node:fs'); const target = join(root, 'existing.sqlite'); await store.backup(target); store.recordArrival('e'.repeat(64)); const before = snapshot(Database, target); await assert.rejects(store.backup(`${target} `)); assert.deepEqual(snapshot(Database, target), before); assert.equal(existsSync(`${target} `), false); const alias = join(root, 'aliased.sqlite'); const spaced = join(root, 'canonical.sqlite '); writeFileSync(spaced, 'untouched'); symlinkSync(spaced, alias); await assert.rejects(store.backup(alias)); assert.equal(readFileSync(spaced, 'utf8'), 'untouched'); assert.notEqual(cli('backup', ['--output', `${target}\t`], dbPath).status, 0); });