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.
147 lines
7.9 KiB
JavaScript
147 lines
7.9 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('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);
|
|
});
|