← back to Rentv
test+a11y: deal-registry regression suite (env-injectable path) + fleet-wide img-alt fix
29208e0fbaf3e6b143e23a5e2ebbfb3e004802d6 · 2026-08-08 08:35:06 -0700 · Steve Abrams
/yoloforever cycle 2 (DTD verdict B, unanimous; Steve also chose B+C).
B — scripts/lib/deal-registry.mjs: registry path is now resolved at call time via
DEALS_REGISTRY_PATH (default unchanged, so pull-deals.mjs/server.js are untouched) so
tests isolate to a temp file. New test/deals/registry.test.mjs (9 cases) locks in the
add/dedup/change-history/never-drop contract feeding the deals feed + map.
C — added alt= to the only 2 JS-injected <img> tags missing it fleet-wide
(admin/pr-intelligence/content-studio.html); zero a11y gaps remain.
Cody gate = FIX FIRST -> all 3 findings applied: corrected the module header that
overclaimed 'serialized' (it's atomic-rename + last-write-wins, not cross-process
serialized); try/finally in test afterEach so a failed unlink can't leak the env
override; guarded the picker alt so an empty credit can't render 'credit ' dangling.
Verified: npm test 88 pass / 0 fail; real data/deals-registry.json untouched by tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/admin/pr-intelligence/content-studio.htmlM scripts/lib/deal-registry.mjsA test/deals/registry.test.mjs
Diff
commit 29208e0fbaf3e6b143e23a5e2ebbfb3e004802d6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 8 08:35:06 2026 -0700
test+a11y: deal-registry regression suite (env-injectable path) + fleet-wide img-alt fix
/yoloforever cycle 2 (DTD verdict B, unanimous; Steve also chose B+C).
B — scripts/lib/deal-registry.mjs: registry path is now resolved at call time via
DEALS_REGISTRY_PATH (default unchanged, so pull-deals.mjs/server.js are untouched) so
tests isolate to a temp file. New test/deals/registry.test.mjs (9 cases) locks in the
add/dedup/change-history/never-drop contract feeding the deals feed + map.
C — added alt= to the only 2 JS-injected <img> tags missing it fleet-wide
(admin/pr-intelligence/content-studio.html); zero a11y gaps remain.
Cody gate = FIX FIRST -> all 3 findings applied: corrected the module header that
overclaimed 'serialized' (it's atomic-rename + last-write-wins, not cross-process
serialized); try/finally in test afterEach so a failed unlink can't leak the env
override; guarded the picker alt so an empty credit can't render 'credit ' dangling.
Verified: npm test 88 pass / 0 fail; real data/deals-registry.json untouched by tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/admin/pr-intelligence/content-studio.html | 4 +-
scripts/lib/deal-registry.mjs | 19 +++-
test/deals/registry.test.mjs | 119 +++++++++++++++++++++++
3 files changed, 135 insertions(+), 7 deletions(-)
diff --git a/public/admin/pr-intelligence/content-studio.html b/public/admin/pr-intelligence/content-studio.html
index c83e6584..46616578 100644
--- a/public/admin/pr-intelligence/content-studio.html
+++ b/public/admin/pr-intelligence/content-studio.html
@@ -91,7 +91,7 @@
listEl.innerHTML = rows.length ? rows.map(a => {
const img = a.image ? (a.image.startsWith('http') ? a.image : location.origin + a.image) : '';
return `<div class="cs-art${selected && String(selected.id) === String(a.id) ? ' on' : ''}" data-id="${PR.esc(a.id)}" title="${PR.esc(a.first_seen || a.date || '')}">
- ${img ? `<img src="${PR.esc(img)}" onerror="this.style.visibility='hidden'">` : '<div style="width:60px"></div>'}
+ ${img ? `<img src="${PR.esc(img)}" alt="Article lead image preview" onerror="this.style.visibility='hidden'">` : '<div style="width:60px"></div>'}
<div><div class="t">${PR.esc(a.title || '')}</div>
<div class="m"><span class="cat">${PR.esc(a.cat || 'RENTV')}</span><span>📰 ${PR.esc(a.source || 'RENTV')}</span><span>🕓 ${PR.esc(dayLabel(a))}</span>${a.related_count ? `<span style="color:#0a66c2;font-weight:600" title="Also covered by: ${PR.esc((a.related||[]).map(r=>r.source).join(', '))}">+${a.related_count} more source${a.related_count>1?'s':''}</span>` : ''}</div></div></div>`;
}).join('') : '<div class="empty">No matches</div>';
@@ -263,7 +263,7 @@
$('imgcands').innerHTML = '';
const j = await PR.api('/../content/find-image', { method: 'POST', body: { article_id: selected.id, title: selected.title, body: selected._body || '', source: selected.source || '' } });
$('imgmeta').innerHTML = `<span class="cs-cost">${PR.esc(j.cost || '')}</span> · parties: ${PR.esc((j.entities || []).join(', ') || '—')} · ${(j.candidates || []).length} photo${j.candidates && j.candidates.length !== 1 ? 's' : ''} found`;
- const cands = (j.candidates || []).map((c, i) => `<a href="#" data-cand="${i}" title="Credit: ${PR.esc(c.credit)}\n${PR.esc(c.source_page)}" style="display:block;width:120px"><img src="${PR.esc(c.image_url)}" style="width:120px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line,#ddd)" onerror="this.closest('a').style.display='none'"><span style="font-size:10px;color:var(--muted,#5b636b);display:block">📷 ${PR.esc(c.credit)}</span></a>`).join('');
+ const cands = (j.candidates || []).map((c, i) => `<a href="#" data-cand="${i}" title="Credit: ${PR.esc(c.credit)}\n${PR.esc(c.source_page)}" style="display:block;width:120px"><img src="${PR.esc(c.image_url)}" alt="Suggested image${c.credit ? ' — credit ' + PR.esc(c.credit) : ''}" style="width:120px;height:80px;object-fit:cover;border-radius:6px;border:1px solid var(--line,#ddd)" onerror="this.closest('a').style.display='none'"><span style="font-size:10px;color:var(--muted,#5b636b);display:block">📷 ${PR.esc(c.credit)}</span></a>`).join('');
const links = (j.pages || []).map(p => `<a href="${PR.esc(p.url)}" target="_blank" rel="noopener noreferrer" class="cs-srclink" data-credit="${PR.esc(p.credit)}" style="display:block;font-size:12px;margin:2px 0">↗ ${PR.esc(p.title || p.url.slice(0, 60))} <span style="color:var(--muted,#5b636b)">— credit ${PR.esc(p.credit)}</span></a>`).join('');
$('imgcands').innerHTML =
(cands ? `<div style="display:flex;gap:8px;flex-wrap:wrap;width:100%">${cands}</div>` : '')
diff --git a/scripts/lib/deal-registry.mjs b/scripts/lib/deal-registry.mjs
index 35d55379..f702a7c4 100644
--- a/scripts/lib/deal-registry.mjs
+++ b/scripts/lib/deal-registry.mjs
@@ -10,15 +10,23 @@
//
// Store: data/deals-registry.json — keyed by deal id. Each entry keeps the CURRENT deal,
// first/last-seen timestamps, a seen counter, and a history[] of created/changed events.
-// Atomic write (tmp + rename), serialized so concurrent cron pulls can't interleave a
-// half-written file. Pure JSON — matches the codebase idiom (deals.json/news.json).
+// Write is ATOMIC (tmp + rename) so a reader never sees a half-written/torn file. Note this is
+// NOT cross-process serialization: recordDeals() is fully synchronous, so two calls in ONE
+// process can't interleave, but two overlapping PROCESSES (e.g. cron firing twice) are
+// last-write-wins — the single cron caller (pull-deals.mjs) makes that rare, not impossible.
+// Pure JSON — matches the codebase idiom (deals.json/news.json).
import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const HERE = dirname(fileURLToPath(import.meta.url));
const DATA = join(HERE, '..', '..', 'data');
-const REGISTRY = join(DATA, 'deals-registry.json');
+// Registry file path, resolved at CALL time (not import time) so tests can point it at a temp
+// file via DEALS_REGISTRY_PATH without polluting the real data/deals-registry.json. Unset in
+// production → the canonical path, so existing callers (pull-deals.mjs, server.js) are unchanged.
+function registryPath() {
+ return process.env.DEALS_REGISTRY_PATH || join(DATA, 'deals-registry.json');
+}
// Fields that constitute a MEANINGFUL change (a new revision). Volatile/derived fields not
// in this list (e.g. a re-localized image path) don't spam the history on their own.
@@ -27,14 +35,15 @@ const TRACKED = ['title', 'url', 'txn_type', 'property_type', 'city', 'state', '
function load() {
try {
- const j = JSON.parse(readFileSync(REGISTRY, 'utf8'));
+ const j = JSON.parse(readFileSync(registryPath(), 'utf8'));
if (j && typeof j === 'object' && j.deals && typeof j.deals === 'object') return j;
} catch { /* first run or corrupt → fresh */ }
return { updated_at: null, total: 0, deals: {} };
}
function saveAtomic(reg) {
- mkdirSync(DATA, { recursive: true });
+ const REGISTRY = registryPath();
+ mkdirSync(dirname(REGISTRY), { recursive: true });
const tmp = REGISTRY + '.tmp';
writeFileSync(tmp, JSON.stringify(reg, null, 1));
renameSync(tmp, REGISTRY);
diff --git a/test/deals/registry.test.mjs b/test/deals/registry.test.mjs
new file mode 100644
index 00000000..5cbfc6d4
--- /dev/null
+++ b/test/deals/registry.test.mjs
@@ -0,0 +1,119 @@
+// Regression suite for the persistent deal REGISTRY (scripts/lib/deal-registry.mjs) — the
+// append-only master ledger that feeds the whole deals feed + map (TK-10246). Locks in the
+// add / dedup / change-history / never-drop contract so a future edit can't silently break it.
+// Isolated via DEALS_REGISTRY_PATH (the cycle-2 refactor) → each test writes to its own temp
+// file, never the real data/deals-registry.json.
+// Run: `node --test test/deals/registry.test.mjs` (or `npm test`).
+import { test, beforeEach, afterEach } from 'node:test';
+import assert from 'node:assert/strict';
+import { readFileSync, existsSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { recordDeals, readRegistry, listDeals, dealHistory } from '../../scripts/lib/deal-registry.mjs';
+
+let tmpPath;
+beforeEach(() => {
+ // Unique per test (no Date.now/random import needed — process.hrtime is monotonic + allowed).
+ tmpPath = join(tmpdir(), `rentv-registry-test-${process.pid}-${process.hrtime.bigint()}.json`);
+ process.env.DEALS_REGISTRY_PATH = tmpPath;
+});
+afterEach(() => {
+ // finally-guard: guarantee the env override is cleared even if a temp-file unlink throws, so a
+ // failed cleanup can never leak DEALS_REGISTRY_PATH into another test file.
+ try {
+ for (const p of [tmpPath, tmpPath + '.tmp']) if (p && existsSync(p)) rmSync(p, { force: true });
+ } finally {
+ delete process.env.DEALS_REGISTRY_PATH;
+ }
+});
+
+const deal = (over = {}) => ({ id: 'd1', title: 'MG Buys Portland Res', amount: 100, city: 'Portland', state: 'OR', ...over });
+
+test('new deal → added, total=1, one "created" history event', () => {
+ const r = recordDeals([deal()], '2026-01-01T00:00:00Z');
+ assert.deepEqual({ added: r.added, changed: r.changed, unchanged: r.unchanged, total: r.total }, { added: 1, changed: 0, unchanged: 0, total: 1 });
+ const h = dealHistory('d1');
+ assert.equal(h.revisions, 1);
+ assert.equal(h.history.length, 1);
+ assert.equal(h.history[0].event, 'created');
+ assert.equal(h.first_seen, '2026-01-01T00:00:00Z');
+});
+
+test('same deal unchanged → unchanged++, seen_count bumps, NO new history event', () => {
+ recordDeals([deal()], '2026-01-01T00:00:00Z');
+ const r = recordDeals([deal()], '2026-01-02T00:00:00Z');
+ assert.equal(r.unchanged, 1);
+ assert.equal(r.changed, 0);
+ const h = dealHistory('d1');
+ assert.equal(h.seen_count, 2);
+ assert.equal(h.revisions, 1);
+ assert.equal(h.history.length, 1, 'no history spam on an unchanged re-sighting');
+ assert.equal(h.last_seen, '2026-01-02T00:00:00Z');
+});
+
+test('tracked field change (amount) → changed++, "changed" event with a field diff, revisions=2', () => {
+ recordDeals([deal({ amount: 100 })], '2026-01-01T00:00:00Z');
+ const r = recordDeals([deal({ amount: 175 })], '2026-01-03T00:00:00Z');
+ assert.equal(r.changed, 1);
+ const h = dealHistory('d1');
+ assert.equal(h.revisions, 2);
+ assert.equal(h.history.length, 2);
+ const evt = h.history[1];
+ assert.equal(evt.event, 'changed');
+ const amountChange = evt.changes.find(c => c.field === 'amount');
+ assert.ok(amountChange, 'the amount change is recorded');
+ assert.equal(amountChange.from, 100);
+ assert.equal(amountChange.to, 175);
+ assert.equal(h.current.amount, 175, 'current reflects the new value');
+});
+
+test('non-tracked field change only (image) → unchanged, no history event, but current merges', () => {
+ recordDeals([deal({ image: '/a.jpg' })], '2026-01-01T00:00:00Z');
+ const r = recordDeals([deal({ image: '/b.jpg' })], '2026-01-04T00:00:00Z');
+ assert.equal(r.unchanged, 1);
+ assert.equal(r.changed, 0);
+ const h = dealHistory('d1');
+ assert.equal(h.history.length, 1, 'image is not a TRACKED field → no revision');
+ assert.equal(h.current.image, '/b.jpg', 'current still merges the fresh non-tracked value');
+});
+
+test('empty-string vs missing tracked field is normalized (no phantom change)', () => {
+ recordDeals([deal({ address: '' })], '2026-01-01T00:00:00Z');
+ const r = recordDeals([deal({ address: undefined })], '2026-01-02T00:00:00Z');
+ assert.equal(r.changed, 0, "'' and undefined must read as equal → no phantom revision");
+});
+
+test('deals without an id are skipped', () => {
+ const r = recordDeals([{ title: 'no id' }, { id: null, title: 'null id' }, deal()], '2026-01-01T00:00:00Z');
+ assert.equal(r.added, 1);
+ assert.equal(r.total, 1);
+});
+
+test('registry is append-only — a deal absent from a later batch is NOT removed', () => {
+ recordDeals([deal({ id: 'd1' }), deal({ id: 'd2', title: 'Second' })], '2026-01-01T00:00:00Z');
+ recordDeals([deal({ id: 'd1' })], '2026-01-05T00:00:00Z'); // d2 absent this run
+ const reg = readRegistry();
+ assert.equal(reg.total, 2, 'd2 survives even though it dropped out of the batch');
+ assert.ok(reg.deals.d2, 'd2 still present');
+});
+
+test('listDeals → newest-first by last_seen with a changes count', () => {
+ recordDeals([deal({ id: 'old' })], '2026-01-01T00:00:00Z');
+ recordDeals([deal({ id: 'new' })], '2026-02-01T00:00:00Z');
+ recordDeals([deal({ id: 'old', amount: 999 })], '2026-03-01T00:00:00Z'); // old now most-recent + 1 change
+ const list = listDeals();
+ assert.equal(list[0].id, 'old', 'most recently seen sorts first');
+ assert.equal(list[0].changes, 1, 'one tracked change recorded');
+ assert.equal(list.find(d => d.id === 'new').changes, 0);
+});
+
+test('dealHistory(unknown) → null; atomic write leaves no .tmp behind', () => {
+ recordDeals([deal()], '2026-01-01T00:00:00Z');
+ assert.equal(dealHistory('nope'), null);
+ assert.ok(existsSync(tmpPath), 'registry file was written');
+ assert.ok(!existsSync(tmpPath + '.tmp'), 'the atomic temp file was renamed away, not left behind');
+ // sanity: the file on disk is valid JSON with the deal
+ const onDisk = JSON.parse(readFileSync(tmpPath, 'utf8'));
+ assert.equal(onDisk.total, 1);
+ assert.ok(onDisk.deals.d1);
+});
← 0d30a387 rentv: top-up corpus (4466->4470, +4 new articles to id 3429
·
back to Rentv
·
test(pr): harden throwaway-DB reset against lingering connec 2382432b →