158 lines
8.6 KiB
JavaScript
158 lines
8.6 KiB
JavaScript
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);
|
|
});
|