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.
145 lines
7.7 KiB
JavaScript
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: 3000, publicOrigin: 'http://127.0.0.1:3000' });
|
|
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 }));
|
|
});
|