[object Object]

← back to Costa Rica

costa-rica: export the app from server.js so inline routes are testable (Cody gate, cycle 16) — TK-10346

2d3cca4e9483374f56840a89b2546128c7e8135a · 2026-09-23 23:48:04 -0700 · Steve

The ~20 inline server.js routes (/api/places, /api/search, /api/provinces, /api/stats,
/api/leads, /r/:slug, /p/:slug, ...) had ZERO test coverage because server.js called
app.listen on import and never exported the app (surfaced by Cody in cycle 15).

Refactor: `module.exports = app` + guard preflight + app.listen behind
`if (require.main === module)`, and expose the module-scoped pg pool on
`app.locals.pool` so a test can close it. Prod boot is UNCHANGED — server.js is
started only via `node server.js` (pm2 fork mode + npm start), where
require.main === module is true, so preflight + listen run exactly as before.
Nothing in the repo imports ./server (grep-confirmed) except the new test.

Cody gate — prod boot verified BULLETPROOF, empirically (not just reasoned): Cody
read pm2 6.0.14 source + ran a live fork-mode probe confirming ProcessContainerFork
does `_load(script, null, /*isMain*/ true)`, so require.main === module is true the
whole time server.js runs — and the same holds even if someone later adds cluster
`instances` (ProcessContainer does the same _load). preflight ordering below the
export is behaviorally identical (a throw still crashes the pm2 child pre-listen ->
autorestart, unchanged).

Cody also caught a real latent TEST bug + fixed: server.js maintains its OWN pg pool
separate from lib/db.js's shared pool (the mounted sub-routers use lib/db's). Importing
server.js creates BOTH; the test's cleanup only closed app.locals.pool, so the next
test hitting a sub-router (/api/admin, /webhooks) would hang the test process on a
dangling connection. Now closes both. (Root cause — server.js duplicating lib/db's
"single shared" pool — is a follow-up ticket, not this diff.)

Tests (+5, suite 185 -> 190, new test/server-routes.test.js): closes the cycle-15
debt — the /api/places q>=2 floor now has a real route test (a 1-char q returns the
full unfiltered count; a >=2 term filters) — plus the basic-auth gate (401
unauthenticated), pagination Link header, limit clamp, and /health liveness. Run
against the real dev DB via the exported app on an ephemeral port.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic

Files touched

Diff

commit 2d3cca4e9483374f56840a89b2546128c7e8135a
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Sep 23 23:48:04 2026 -0700

    costa-rica: export the app from server.js so inline routes are testable (Cody gate, cycle 16) — TK-10346
    
    The ~20 inline server.js routes (/api/places, /api/search, /api/provinces, /api/stats,
    /api/leads, /r/:slug, /p/:slug, ...) had ZERO test coverage because server.js called
    app.listen on import and never exported the app (surfaced by Cody in cycle 15).
    
    Refactor: `module.exports = app` + guard preflight + app.listen behind
    `if (require.main === module)`, and expose the module-scoped pg pool on
    `app.locals.pool` so a test can close it. Prod boot is UNCHANGED — server.js is
    started only via `node server.js` (pm2 fork mode + npm start), where
    require.main === module is true, so preflight + listen run exactly as before.
    Nothing in the repo imports ./server (grep-confirmed) except the new test.
    
    Cody gate — prod boot verified BULLETPROOF, empirically (not just reasoned): Cody
    read pm2 6.0.14 source + ran a live fork-mode probe confirming ProcessContainerFork
    does `_load(script, null, /*isMain*/ true)`, so require.main === module is true the
    whole time server.js runs — and the same holds even if someone later adds cluster
    `instances` (ProcessContainer does the same _load). preflight ordering below the
    export is behaviorally identical (a throw still crashes the pm2 child pre-listen ->
    autorestart, unchanged).
    
    Cody also caught a real latent TEST bug + fixed: server.js maintains its OWN pg pool
    separate from lib/db.js's shared pool (the mounted sub-routers use lib/db's). Importing
    server.js creates BOTH; the test's cleanup only closed app.locals.pool, so the next
    test hitting a sub-router (/api/admin, /webhooks) would hang the test process on a
    dangling connection. Now closes both. (Root cause — server.js duplicating lib/db's
    "single shared" pool — is a follow-up ticket, not this diff.)
    
    Tests (+5, suite 185 -> 190, new test/server-routes.test.js): closes the cycle-15
    debt — the /api/places q>=2 floor now has a real route test (a 1-char q returns the
    full unfiltered count; a >=2 term filters) — plus the basic-auth gate (401
    unauthenticated), pagination Link header, limit clamp, and /health liveness. Run
    against the real dev DB via the exported app on an ephemeral port.
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01TouFkmUGKHtwqpZwgReVic
---
 server.js                  | 33 ++++++++++------
 test/server-routes.test.js | 96 ++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 118 insertions(+), 11 deletions(-)

diff --git a/server.js b/server.js
index 34036e4..e7da0fa 100644
--- a/server.js
+++ b/server.js
@@ -728,14 +728,25 @@ app.use((err, req, res, next) => {
   serverError(res, err, `${req.method} ${req.path}`);
 });
 
-// TK-10346 — fail-closed boot guard: refuse to serve in production if a payment/WhatsApp
-// integration is LIVE without its webhook secret (would silently reject every real webhook
-// -> payments succeed but bookings never confirm). Inert while everything is sandbox.
-require('./lib/preflight').runPreflight({
-  getProvider: require('./lib/payments').getProvider,
-  whatsapp: require('./lib/whatsapp'),
-});
-
-app.listen(PORT, '0.0.0.0', () => {
-  console.log(`[${SITE_NAME}] listening on :${PORT} — gated as ${BA_USER || 'OPEN'} — domain ${SITE_DOMAIN}`);
-});
+// Export the fully-wired app so route tests can import it (supertest-style) WITHOUT
+// binding a port or running the boot guard. Only the real entrypoint (`node server.js`,
+// via pm2 / `npm start`) has require.main === module, so preflight + listen run in
+// prod exactly as before — importing this module (in a test) does neither.
+// Expose the DB pool on app.locals so a test can end() it (this module-scoped pool
+// is otherwise unreachable, and an open pool would keep the test process alive).
+app.locals.pool = pool;
+module.exports = app;
+
+if (require.main === module) {
+  // TK-10346 — fail-closed boot guard: refuse to serve in production if a payment/WhatsApp
+  // integration is LIVE without its webhook secret (would silently reject every real webhook
+  // -> payments succeed but bookings never confirm). Inert while everything is sandbox.
+  require('./lib/preflight').runPreflight({
+    getProvider: require('./lib/payments').getProvider,
+    whatsapp: require('./lib/whatsapp'),
+  });
+
+  app.listen(PORT, '0.0.0.0', () => {
+    console.log(`[${SITE_NAME}] listening on :${PORT} — gated as ${BA_USER || 'OPEN'} — domain ${SITE_DOMAIN}`);
+  });
+}
diff --git a/test/server-routes.test.js b/test/server-routes.test.js
new file mode 100644
index 0000000..29a8900
--- /dev/null
+++ b/test/server-routes.test.js
@@ -0,0 +1,96 @@
+'use strict';
+// Route tests for the INLINE server.js routes — previously untestable because
+// server.js called app.listen on import and never exported the app (Cody gate,
+// cycle 15/16). Now server.js exports the wired app (listen guarded by
+// require.main === module), so we mount it here and drive it over HTTP.
+//
+// These run against the REAL dev DB (the inline routes use server.js's own pool;
+// GET routes are read-only, safe). Closes the cycle-15 debt: the /api/places q>=2
+// floor shipped verified-by-EXPLAIN but with no route unit test.
+
+// Pin the site basic-auth gate to known creds BEFORE requiring server.js — its
+// dotenv.config() won't override an already-set var, so this wins over .env and the
+// requests below authenticate deterministically (the whole site is gated).
+process.env.BASIC_AUTH_USER = 'testuser';
+process.env.BASIC_AUTH_PASS = 'testpass';
+
+const { test, before, after } = require('node:test');
+const assert = require('node:assert');
+const http = require('node:http');
+
+const app = require('../server'); // exported app, does NOT listen on import
+
+const AUTH = 'Basic ' + Buffer.from('testuser:testpass').toString('base64');
+
+let server, base;
+before(async () => {
+  await new Promise(r => { server = app.listen(0, r); });
+  base = `http://127.0.0.1:${server.address().port}`;
+});
+after(async () => {
+  server && server.close();
+  // TWO pools exist: server.js has its own (app.locals.pool, used by the inline
+  // routes) AND lib/db.js has a separate shared pool (used by the mounted
+  // sub-routers /api/app, /api/admin, /api/build, /webhooks). Importing server.js
+  // creates both; close BOTH or a future test that hits a sub-router hangs the test
+  // process on a dangling connection. (Cody gate, cycle 16 — root cause: server.js
+  // duplicating lib/db's pool; follow-up ticket to consolidate.)
+  try { await app.locals.pool.end(); } catch { /* already closed */ }
+  try { await require('../lib/db').pool.end(); } catch { /* already closed */ }
+});
+
+function get(path) {
+  return new Promise((resolve, reject) => {
+    http.get(base + path, { headers: { authorization: AUTH } }, res => {
+      let b = '';
+      res.on('data', c => b += c);
+      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, json: JSON.parse(b || '{}') }));
+    }).on('error', reject);
+  });
+}
+
+test('site basic-auth gate: an UNAUTHENTICATED request is rejected (401) — the gate is active', async () => {
+  const r = await new Promise((resolve, reject) => {
+    http.get(base + '/health', res => { let b = ''; res.on('data', c => b += c); res.on('end', () => resolve({ status: res.statusCode })); }).on('error', reject);
+  });
+  assert.equal(r.status, 401, 'no credentials -> 401 (whole-site gate)');
+});
+
+test('/api/places q>=2 floor: a 1-char q is IGNORED (returns the full unfiltered listing, no scan)', async () => {
+  const all = await get('/api/places?limit=1');
+  assert.equal(all.status, 200);
+  const totalAll = all.json.total;
+  assert.ok(totalAll > 100, `expected the dev DB to have a large active-places count, got ${totalAll}`);
+
+  // 1-char q: the guard skips the LIKE filter -> same total as no q.
+  const oneChar = await get('/api/places?q=a&limit=1');
+  assert.equal(oneChar.status, 200);
+  assert.equal(oneChar.json.total, totalAll, 'a 1-char q must not filter (would be a full-directory scan); total == unfiltered');
+
+  // A real >=2 nonsense term: the filter IS applied -> a strictly smaller total.
+  const filtered = await get('/api/places?q=zzqxbyw&limit=1');
+  assert.equal(filtered.status, 200);
+  assert.ok(filtered.json.total < totalAll, `a >=2-char filter must reduce the count (${filtered.json.total} < ${totalAll})`);
+});
+
+test('/api/places pagination: Link header carries rel="next" and the page respects limit', async () => {
+  const r = await get('/api/places?limit=5&offset=0');
+  assert.equal(r.status, 200);
+  assert.ok(Array.isArray(r.json.places) && r.json.places.length <= 5, 'page respects the limit');
+  assert.equal(r.json.limit, 5);
+  assert.equal(r.json.offset, 0);
+  assert.ok(/rel="canonical"/.test(r.headers.link || ''), 'Link header has a canonical rel');
+  if (r.json.total > 5) assert.ok(/rel="next"/.test(r.headers.link || ''), 'a next page exists -> rel="next" present');
+});
+
+test('/api/places limit is clamped to a sane maximum (no unbounded page)', async () => {
+  const r = await get('/api/places?limit=99999');
+  assert.equal(r.status, 200);
+  assert.ok(r.json.limit <= 250, `limit clamped to <= 250, got ${r.json.limit}`);
+});
+
+test('/health responds ok (basic liveness of the exported app)', async () => {
+  const r = await get('/health');
+  assert.equal(r.status, 200);
+  assert.equal(r.json.ok, true);
+});

← f899e1c cycle 15 docs: YOLO_NOTES ledger + GO-LIVE pg_trgm prerequis  ·  back to Costa Rica  ·  cycle 16 docs: YOLO_NOTES ledger — server.js app export + in 1d24359 →