← 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
A lib/orphan-recordings.jsM package.jsonM routes/admin.jsA test/orphan-recordings.test.js
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 →