[object Object]

← back to Butlr

orphan-recordings: extract pure scanOrphans() + unit test (8/8)

23419309de3531041a2835cfbc1a273d318e2434 · 2026-05-13 05:55:21 -0700 · SteveStudio2

- lib/orphan-recordings.js: scanOrphans({dir, knownIds}) extracted from
  routes/admin.js. No Express deps so it can be unit-tested in isolation.
  Accepts optional knownIds injection for tests; defaults to live calls.json.
- routes/admin.js: delegates to scanOrphans(), 20 lines lighter.
- test/orphan-recordings.test.js: 8 assertions in a temp dir —
  returns-exactly-N, mtime-desc-sort, known-ID exclusion, .pcm rejection,
  empty/nonexistent dirs. npm test exits 0 on full pass.
- package.json: wire "npm test" script.

Tick 5 validation findings:
- validate-twiml.js: 11/11 green on UozqdFJg + JsAqO2by (two different
  prod call IDs); confirms inline-Gather/no-Redirect/inbound_track holds
  across calls, not just US33rQWN.

YOLO tick 5 — all reversible, local-only.

Files touched

Diff

commit 23419309de3531041a2835cfbc1a273d318e2434
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 05:55:21 2026 -0700

    orphan-recordings: extract pure scanOrphans() + unit test (8/8)
    
    - lib/orphan-recordings.js: scanOrphans({dir, knownIds}) extracted from
      routes/admin.js. No Express deps so it can be unit-tested in isolation.
      Accepts optional knownIds injection for tests; defaults to live calls.json.
    - routes/admin.js: delegates to scanOrphans(), 20 lines lighter.
    - test/orphan-recordings.test.js: 8 assertions in a temp dir —
      returns-exactly-N, mtime-desc-sort, known-ID exclusion, .pcm rejection,
      empty/nonexistent dirs. npm test exits 0 on full pass.
    - package.json: wire "npm test" script.
    
    Tick 5 validation findings:
    - validate-twiml.js: 11/11 green on UozqdFJg + JsAqO2by (two different
      prod call IDs); confirms inline-Gather/no-Redirect/inbound_track holds
      across calls, not just US33rQWN.
    
    YOLO tick 5 — all reversible, local-only.
---
 lib/orphan-recordings.js       | 32 ++++++++++++++++
 package.json                   |  3 +-
 routes/admin.js                | 26 ++-----------
 test/orphan-recordings.test.js | 87 ++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 125 insertions(+), 23 deletions(-)

diff --git a/lib/orphan-recordings.js b/lib/orphan-recordings.js
new file mode 100644
index 0000000..e4fd720
--- /dev/null
+++ b/lib/orphan-recordings.js
@@ -0,0 +1,32 @@
+// Find MP3s in data/recordings/ with no matching call_id in calls.json.
+// Pure function — no Express, no req/res — so unit-testable.
+
+const fs = require('fs');
+const path = require('path');
+const data = require('./data');
+const transcribe = require('./transcribe');
+
+function scanOrphans({ dir = transcribe.RECORDINGS_DIR, knownIds = null } = {}) {
+  if (!fs.existsSync(dir)) return [];
+  const ids = knownIds || new Set(data.listCallsUnscoped().map(c => c.id));
+  const files = fs.readdirSync(dir).filter(f => f.endsWith('.mp3'));
+  const orphans = [];
+  for (const f of files) {
+    const base = f.replace(/\.mp3$/, '');
+    if (ids.has(base)) continue;
+    const fp = path.join(dir, f);
+    let st;
+    try { st = fs.statSync(fp); } catch { continue; }
+    orphans.push({
+      id: base,
+      filename: f,
+      size_bytes: st.size,
+      size_kb: (st.size / 1024).toFixed(1),
+      mtime: st.mtime.toISOString().replace('T', ' ').slice(0, 19),
+    });
+  }
+  orphans.sort((a, b) => b.mtime.localeCompare(a.mtime));
+  return orphans;
+}
+
+module.exports = { scanOrphans };
diff --git a/package.json b/package.json
index 3f38481..9e30cd0 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
   "main": "server.js",
   "scripts": {
     "start": "node server.js",
-    "dev": "node server.js"
+    "dev": "node server.js",
+    "test": "node test/orphan-recordings.test.js"
   },
   "dependencies": {
     "bcryptjs": "^3.0.3",
diff --git a/routes/admin.js b/routes/admin.js
index 6c22d9f..e31f191 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -15,6 +15,7 @@ const fs = require('fs');
 const path = require('path');
 const data = require('../lib/data');
 const transcribe = require('../lib/transcribe');
+const { scanOrphans } = require('../lib/orphan-recordings');
 
 const router = express.Router();
 
@@ -26,28 +27,9 @@ function requireAdmin(req, res, next) {
 }
 
 router.get('/admin/orphan-recordings', requireAdmin, (req, res) => {
-  const dir = transcribe.RECORDINGS_DIR;
-  let orphans = [];
-  try {
-    const calls = data.listCallsUnscoped();
-    const knownIds = new Set(calls.map(c => c.id));
-    const files = fs.readdirSync(dir).filter(f => f.endsWith('.mp3'));
-    for (const f of files) {
-      const base = f.replace(/\.mp3$/, '');
-      if (knownIds.has(base)) continue;
-      const fp = path.join(dir, f);
-      const st = fs.statSync(fp);
-      orphans.push({
-        id: base,
-        filename: f,
-        size_kb: (st.size / 1024).toFixed(1),
-        mtime: st.mtime.toISOString().replace('T', ' ').slice(0, 19),
-      });
-    }
-    orphans.sort((a, b) => b.mtime.localeCompare(a.mtime));
-  } catch (e) {
-    return res.status(500).send(`orphan scan error: ${e.message}`);
-  }
+  let orphans;
+  try { orphans = scanOrphans(); }
+  catch (e) { return res.status(500).send(`orphan scan error: ${e.message}`); }
 
   const rows = orphans.map(o => `
     <tr>
diff --git a/test/orphan-recordings.test.js b/test/orphan-recordings.test.js
new file mode 100644
index 0000000..b1d09a3
--- /dev/null
+++ b/test/orphan-recordings.test.js
@@ -0,0 +1,87 @@
+#!/usr/bin/env node
+// Unit test for lib/orphan-recordings.scanOrphans.
+//
+// Builds a temp recordings dir with 3 known MP3s + 2 orphans + 1 stray
+// .pcm (should be ignored), passes a fake knownIds set, asserts the
+// returned list has exactly the 2 expected orphans in mtime-desc order.
+//
+// Pure-function test — no Express, no real data/calls.json, no network.
+// Run with: node test/orphan-recordings.test.js
+
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+const assert = require('assert');
+
+const { scanOrphans } = require('../lib/orphan-recordings');
+
+function ok(name, fn) {
+  try { fn(); console.log(`  ✓ ${name}`); return 1; }
+  catch (e) { console.error(`  ✗ ${name}`); console.error('     ', e.message); return 0; }
+}
+
+let pass = 0, total = 0;
+
+// ── setup temp recordings dir ───────────────────────────────────────
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-orphan-test-'));
+console.log(`tmp: ${tmp}`);
+const known = ['AAAAAAAA', 'BBBBBBBB', 'CCCCCCCC'];
+const orphans = ['ORPHAN01', 'ORPHAN02'];
+const now = Date.now();
+for (const id of [...known, ...orphans]) {
+  fs.writeFileSync(path.join(tmp, `${id}.mp3`), 'fakemp3');
+}
+fs.writeFileSync(path.join(tmp, 'STRAYPCM.pcm'), 'fakepcm'); // should be ignored
+// Touch orphans so their mtime ordering is deterministic
+fs.utimesSync(path.join(tmp, 'ORPHAN01.mp3'), now / 1000 - 60, now / 1000 - 60);
+fs.utimesSync(path.join(tmp, 'ORPHAN02.mp3'), now / 1000, now / 1000);
+
+// ── run ─────────────────────────────────────────────────────────────
+const knownSet = new Set(known);
+const result = scanOrphans({ dir: tmp, knownIds: knownSet });
+
+console.log('\nresult:');
+for (const o of result) console.log(`  ${o.id}  ${o.size_kb} KB  ${o.mtime}`);
+console.log();
+
+// ── assertions ──────────────────────────────────────────────────────
+total++; pass += ok('returns exactly 2 orphans', () => assert.strictEqual(result.length, 2));
+total++; pass += ok('orphans are ORPHAN01 + ORPHAN02', () => {
+  const ids = result.map(o => o.id).sort();
+  assert.deepStrictEqual(ids, ['ORPHAN01', 'ORPHAN02']);
+});
+total++; pass += ok('ORPHAN02 sorted before ORPHAN01 (newer first)', () => {
+  assert.strictEqual(result[0].id, 'ORPHAN02');
+  assert.strictEqual(result[1].id, 'ORPHAN01');
+});
+total++; pass += ok('does NOT include known IDs', () => {
+  for (const o of result) assert.ok(!knownSet.has(o.id), `${o.id} should not be returned`);
+});
+total++; pass += ok('does NOT include .pcm stray', () => {
+  for (const o of result) assert.ok(!o.filename.endsWith('.pcm'), 'pcm leaked');
+});
+total++; pass += ok('each orphan has filename/size_bytes/size_kb/mtime', () => {
+  for (const o of result) {
+    assert.ok(o.filename); assert.ok(typeof o.size_bytes === 'number');
+    assert.ok(o.size_kb); assert.ok(o.mtime);
+  }
+});
+
+// ── empty case ──────────────────────────────────────────────────────
+total++; pass += ok('empty dir returns []', () => {
+  const empty = fs.mkdtempSync(path.join(os.tmpdir(), 'butlr-empty-'));
+  const r = scanOrphans({ dir: empty, knownIds: new Set() });
+  assert.deepStrictEqual(r, []);
+  fs.rmSync(empty, { recursive: true });
+});
+
+// ── nonexistent dir ─────────────────────────────────────────────────
+total++; pass += ok('nonexistent dir returns []', () => {
+  const r = scanOrphans({ dir: '/no/such/path/nowhere', knownIds: new Set() });
+  assert.deepStrictEqual(r, []);
+});
+
+// ── cleanup + report ────────────────────────────────────────────────
+fs.rmSync(tmp, { recursive: true });
+console.log(`\n${pass}/${total} passed`);
+process.exit(pass === total ? 0 : 1);

← 2196d1f admin: /admin/orphan-recordings route + UA regex fix for /st  ·  back to Butlr  ·  admin-gate + has-transcript: unit tests + 5 routes/listen.js f977833 →