diff --git a/search/test/converter.test.mjs b/search/test/converter.test.mjs new file mode 100644 index 0000000..5646eb8 --- /dev/null +++ b/search/test/converter.test.mjs @@ -0,0 +1,125 @@ +#!/usr/bin/env node +/* + * converter.test.mjs — the D-05 JSON→SQLite converter's laws. + * ----------------------------------------------------------- + * Backend #2 (the Malaysia-only WASM geocoder) must stay a FORMAT change, + * not a rewrite (D-05): the converter reads the SAME committed dataset the + * Worker serves from and emits a dataset with the SAME id set, today. + * + * CV1 the converter preserves the id set — same members, same count + * CV2 the converter reads the committed dataset (kl-gazetteer.json) + * CV3 the converter is not a runtime dependency — nothing under + * bot/src/ or web/src/ mentions sqlite or the converter + * + * Run: npm run search:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { execFileSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { test } from "./harness.mjs"; + +const SEARCH_DIR = path.resolve(import.meta.dirname, ".."); +const CONVERTER = path.join(SEARCH_DIR, "scripts", "json-to-sqlite.mjs"); +const GAZETTEER = path.join(SEARCH_DIR, "src", "data", "kl-gazetteer.json"); +const SRC_TREES = [ + path.join(SEARCH_DIR, "..", "bot", "src"), + path.join(SEARCH_DIR, "..", "web", "src"), +]; + +function scriptSource() { + return readFileSync(CONVERTER, "utf8"); +} + +function gazetteerIds() { + const gazetteer = JSON.parse(readFileSync(GAZETTEER, "utf8")); + return gazetteer.entries.map((e) => e.id).sort(); +} + +/** Recursively list every file under dir (source-introspection walk). */ +function walkFiles(dir) { + const out = []; + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkFiles(full)); + else out.push(full); + } + return out; +} + +/** Run the converter into a fresh temp dir; returns { dir, files }. */ +function runConverter() { + const dir = mkdtempSync(path.join(tmpdir(), "araharah-conv-")); + execFileSync(process.execPath, [CONVERTER, "--out", dir], { encoding: "utf8" }); + return { dir, files: readdirSync(dir) }; +} + +/** Read the id set back out of whatever artifact the converter emitted + * (a real .sqlite via node:sqlite, or the documented .sql dump). */ +function readIdsFrom(dir, files) { + const sqliteFile = files.find((f) => f.endsWith(".sqlite")); + if (sqliteFile) { + const db = new DatabaseSync(path.join(dir, sqliteFile), { readOnly: true }); + const rows = db.prepare("SELECT id FROM places ORDER BY id").all(); + db.close(); + return rows.map((r) => r.id); + } + const dumpFile = files.find((f) => f.endsWith(".sql")); + assert.ok(dumpFile, `converter emitted no readable artifact: ${files.join(", ")}`); + const dump = readFileSync(path.join(dir, dumpFile), "utf8"); + const ids = []; + for (const match of dump.matchAll(/INSERT INTO places[\s\S]*?\(\s*'([^']+)'/g)) { + ids.push(match[1]); + } + return ids.sort(); +} + +/* ————— CV1 — the id set is preserved exactly ————— */ + +test("CV1 the converter preserves the gazetteer's id set exactly", () => { + const { dir, files } = runConverter(); + try { + assert.ok(files.length > 0, "converter emitted nothing"); + const emitted = readIdsFrom(dir, files); + assert.deepEqual( + emitted, + gazetteerIds(), + "backend #2's dataset format must carry the SAME id set as the committed JSON (D-05)", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/* ————— CV2 — it reads the committed dataset ————— */ + +test("CV2 the converter reads the committed dataset", () => { + const src = scriptSource(); + assert.match( + src, + /kl-gazetteer\.json/, + "the converter must read the same committed JSON the Worker serves from (D-07 single source)", + ); +}); + +/* ————— CV3 — never a runtime dependency ————— */ + +test("CV3 the converter is not a runtime dependency", () => { + const offenders = []; + for (const tree of SRC_TREES) { + for (const file of walkFiles(tree)) { + const src = readFileSync(file, "utf8"); + if (/sqlite|json-to-sqlite/i.test(src)) offenders.push(path.relative(process.cwd(), file)); + } + } + assert.equal( + offenders.length, + 0, + `D-05 violation: sqlite/json-to-sqlite appears under bot/src/ or web/src/ — ` + + `the converter is a repo tool (the Worker's canonical format is typed JSON, D-07): ` + + `${offenders.join(", ")}`, + ); +}); diff --git a/search/test/topup.test.mjs b/search/test/topup.test.mjs new file mode 100644 index 0000000..923b8a4 --- /dev/null +++ b/search/test/topup.test.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/* + * topup.test.mjs — the D-06 Overpass top-up tool's hard laws. + * ----------------------------------------------------------- + * The top-up script is a MAINTAINER-TIME REPO TOOL, never a runtime + * dependency (D-06). Its six laws are pinned here: + * + * TP1 it is not imported by the Worker — zero `overpass` occurrences + * under bot/src/ or web/src/ (RESEARCH Pitfall 6) + * TP2 it sends an identifying User-Agent — a request without one gets + * HTTP 406 (live-verified 2026-10-23) + * TP3 the query is bbox-bounded — an unbounded Overpass query times out + * and invites rate-limiting + * TP4 a busy instance is a NORMAL outcome: a fixture that is not an + * Overpass response exits 0 printing "Overpass instance busy" + * TP5 the committed fixture produces a valid-shaped PROPOSAL (no live + * network in the test suite) + * TP6 it never writes the curated dataset — the source never even names it + * TP7 exactly ONE request per run — the Overpass and Nominatim paths + * share a single fetch helper, so the source has exactly one fetch + * call site regardless of the selected source + * + * Run: npm run search:test (exit 0 = all green) + */ +import { strict as assert } from "node:assert"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "./harness.mjs"; + +const SEARCH_DIR = path.resolve(import.meta.dirname, ".."); +const SCRIPT = path.join(SEARCH_DIR, "scripts", "overpass-topup.mjs"); +const FIXTURE = path.join(SEARCH_DIR, "test", "fixtures", "overpass-response.json"); +const SRC_TREES = [ + path.join(SEARCH_DIR, "..", "bot", "src"), + path.join(SEARCH_DIR, "..", "web", "src"), +]; + +function scriptSource() { + return readFileSync(SCRIPT, "utf8"); +} + +/** Recursively list every file under dir (source-introspection walk). */ +function walkFiles(dir) { + const out = []; + if (!existsSync(dir)) return out; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) out.push(...walkFiles(full)); + else out.push(full); + } + return out; +} + +function runScript(args) { + return spawnSync(process.execPath, [path.relative(SEARCH_DIR, SCRIPT), ...args], { + cwd: SEARCH_DIR, + encoding: "utf8", + }); +} + +/* ————— TP1 — never a runtime dependency ————— */ + +test("TP1 the top-up script is not imported by the Worker", () => { + const offenders = []; + for (const tree of SRC_TREES) { + for (const file of walkFiles(tree)) { + const src = readFileSync(file, "utf8"); + if (/overpass/i.test(src)) offenders.push(path.relative(process.cwd(), file)); + } + } + assert.equal( + offenders.length, + 0, + `D-06 violation: 'overpass' appears under bot/src/ or web/src/ — the top-up ` + + `is a repo tool and the Worker only ever reads the committed dataset: ${offenders.join(", ")}`, + ); +}); + +/* ————— TP2 — the User-Agent is load-bearing ————— */ + +test("TP2 the script sends an identifying User-Agent", () => { + const src = scriptSource(); + assert.ok( + src.includes("User-Agent: araharah-gazetteer-topup/1.0"), + "a request without this User-Agent gets HTTP 406 (live-verified) — the header is load-bearing", + ); +}); + +/* ————— TP3 — bbox-bounded query ————— */ + +test("TP3 the query is bbox-bounded", () => { + const src = scriptSource(); + assert.match( + src, + /\(\d+\.\d+,\s*\d+\.\d+,\s*\d+\.\d+,\s*\d+\.\d+\)/, + "the Overpass query must carry a literal (s,w,n,e) bbox tuple — an unbounded query times out", + ); +}); + +/* ————— TP4 — a busy instance exits 0 ————— */ + +test("TP4 the script exits 0 on a busy instance", () => { + const dir = mkdtempSync(path.join(tmpdir(), "araharah-topup-busy-")); + try { + const busyFixture = path.join(dir, "busy.json"); + writeFileSync(busyFixture, JSON.stringify({ error: "busy" })); + const res = runScript(["--source=fixture", `--fixture=${busyFixture}`]); + assert.equal( + res.status, + 0, + `a busy/unusable Overpass instance is a NORMAL retryable outcome — the script must exit 0 ` + + `(got ${res.status}; stderr: ${res.stderr})`, + ); + assert.match( + res.stdout, + /Overpass instance busy — retry later/, + "the busy path must print exactly the soft-exit line", + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +/* ————— TP5 — the committed fixture yields a PROPOSAL ————— */ + +test("TP5 the script produces a valid-shaped proposal from the committed fixture", () => { + const res = runScript(["--source=fixture", `--fixture=${path.relative(SEARCH_DIR, FIXTURE)}`]); + assert.equal(res.status, 0, `fixture run must exit 0 (stderr: ${res.stderr})`); + assert.match(res.stdout, /PROPOSAL/, "output must carry the PROPOSAL header"); + assert.match(res.stdout, /name=/, "output must list at least one name= candidate line"); + assert.match(res.stdout, /NEVER writes/, "the proposal header must repeat the never-writes law"); +}); + +/* ————— TP6 — it cannot write the curated file ————— */ + +test("TP6 the script never writes the curated file", () => { + const src = scriptSource(); + assert.doesNotMatch( + src, + /kl-gazetteer\.json/, + "the script's source must never name the curated dataset file — it cannot write what it cannot name", + ); + assert.match(src, /--out/, "the only permitted write is an explicit --out proposal path"); +}); + +/* ————— TP7 — exactly one request per run ————— */ + +test("TP7 exactly one request per run — one shared fetch helper", () => { + const src = scriptSource(); + const callSites = (src.match(/fetch\(/g) ?? []).length; + assert.equal( + callSites, + 1, + `the Overpass and Nominatim live paths must share ONE fetch helper — ` + + `found ${callSites} fetch( call sites`, + ); +});