[object Object]

← back to Rentv 2026

test(adslots): cover ad-system + fix filtered inventory summary mismatch

e15f53982db596c9d9b9811e9dd2ac8ae40dfed5 · 2026-08-08 07:10:56 -0700 · Steve Abrams

/api/ad/inventory summarized a fresh unfiltered buildInventory() instead of the
filtered list it returns, so summary.total/by_source/sold/available disagreed with
count/items on any ?source= / ?status= request. Summarize the returned list
instead (also removes a redundant second inventory scan).

Adds test/adslots/inventory.test.mjs: 7 tests via a zero-dep fake-Express harness
over temp fixtures — suggestPrice per-source asks + plays bumps + $50 rounding,
summarize source/status counts, reserve 400/200 validation, and a regression guard
that fails against the old summarize(buildInventory()) call. Suite 70->77 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e15f53982db596c9d9b9811e9dd2ac8ae40dfed5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 8 07:10:56 2026 -0700

    test(adslots): cover ad-system + fix filtered inventory summary mismatch
    
    /api/ad/inventory summarized a fresh unfiltered buildInventory() instead of the
    filtered list it returns, so summary.total/by_source/sold/available disagreed with
    count/items on any ?source= / ?status= request. Summarize the returned list
    instead (also removes a redundant second inventory scan).
    
    Adds test/adslots/inventory.test.mjs: 7 tests via a zero-dep fake-Express harness
    over temp fixtures — suggestPrice per-source asks + plays bumps + $50 rounding,
    summarize source/status counts, reserve 400/200 validation, and a regression guard
    that fails against the old summarize(buildInventory()) call. Suite 70->77 green.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 package.json                    |   2 +-
 src/ad-system.cjs               |   5 +-
 test/adslots/inventory.test.mjs | 141 ++++++++++++++++++++++++++++++++++++++++
 3 files changed, 146 insertions(+), 2 deletions(-)

diff --git a/package.json b/package.json
index efcac1a5..e450d65a 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,7 @@
     "pr:migrate": "node -e \"require('./src/pr/db').runMigrations({log:console.log}).then(r=>console.log(JSON.stringify(r))).catch(e=>{console.error(e.message);process.exit(1)})\"",
     "pr:worker": "node src/pr/worker.js",
     "pr:seed:ca": "node src/pr/seed/ca-seed.js",
-    "test": "node --test test/pr/*.test.js test/deals/*.test.mjs",
+    "test": "node --test test/pr/*.test.js test/deals/*.test.mjs test/adslots/*.test.mjs",
     "pr:seed:az": "node src/pr/seed/az-seed.js",
     "pr:seed:national": "node src/pr/seed/national-media-seed.js",
     "pr:crawl:media": "node src/pr/tools/daily-media-crawl.js"
diff --git a/src/ad-system.cjs b/src/ad-system.cjs
index 51232472..db1a5853 100644
--- a/src/ad-system.cjs
+++ b/src/ad-system.cjs
@@ -204,7 +204,10 @@ module.exports = function mountAdSystem(app, opts) {
     if (q) items = items.filter(x => x.source === q);
     const status = (req.query.status || '').trim();
     if (status) items = items.filter(x => x.sponsorship.status === status);
-    r.json({ ok: true, summary: summarize(buildInventory()), count: items.length, items });
+    // summarize the SAME (filtered) list we return — not a fresh unfiltered build,
+    // or summary.total/by_source/sold/available would disagree with count/items on
+    // any ?source= / ?status= filtered request. (also drops a redundant 2nd scan.)
+    r.json({ ok: true, summary: summarize(items), count: items.length, items });
   });
 
   // Buy / reserve inquiry — records intent, NO charge (Steve-gated payment).
diff --git a/test/adslots/inventory.test.mjs b/test/adslots/inventory.test.mjs
new file mode 100644
index 00000000..6c2a4735
--- /dev/null
+++ b/test/adslots/inventory.test.mjs
@@ -0,0 +1,141 @@
+// ─────────────────────────────────────────────────────────────────────────────
+// test/adslots/inventory.test.mjs — coverage for the 2026 ad-slots module
+// (src/ad-system.cjs), the marquee feature of the feature/2026-overhaul-adslots
+// branch, which shipped with ZERO tests. Cody-gate finding, TK-10360 loop cycle 1.
+//
+// The module's pure helpers (suggestPrice, summarize, buildInventory) are closure-
+// scoped inside the mount fn, so we exercise them THROUGH the real endpoints via a
+// tiny dependency-free fake-Express harness (no express/supertest, matching the
+// repo's zero-dep test style). Fixtures live in a throwaway temp DATA dir.
+//
+// Guards, in particular, the summarize(items) bug: the inventory endpoint used to
+// call summarize(buildInventory()) — a fresh UNFILTERED build — so summary.total
+// disagreed with count on any filtered request. The `?source=` test below fails
+// against that old code and passes against the fix.
+// ─────────────────────────────────────────────────────────────────────────────
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { createRequire } from 'node:module';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+const require = createRequire(import.meta.url);
+const mountAdSystem = require('../../src/ad-system.cjs');
+
+// ── throwaway DATA dir with video-source fixtures ────────────────────────────
+function fixtureDir() {
+  const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'adslots-'));
+  const w = (f, o) => fs.writeFileSync(path.join(dir, f), JSON.stringify(o));
+  // 2 YouTube "The REview" items — a1 has heavy plays (>500), a2 has none
+  w('videos.json', { fetched_at: '2026-08-01', items: [
+    { id: 'a1', yt: 'YT1', title: 'Review A', views: 600 },
+    { id: 'a2', yt: 'YT2', title: 'Review B', views: 0 },
+  ] });
+  // 1 RENTV Original (brand spot)
+  w('originals.json', { items: [{ id: 'o1', title: 'Brand Spot', mp4: '/o1.mp4' }] });
+  // Vimeo: one full CRE Talk episode (>100 plays) + one clip (>25 plays)
+  w('vimeo-library.json', {
+    episodes: [{ id: 'v1', kind: 'episode', title: 'CRE Talk Ep', plays: 120 }],
+    clips: [{ id: 'v2', title: 'CRE Clip', plays: 30 }],
+  });
+  return dir;
+}
+
+// ── minimal fake Express app: captures the last handler per "METHOD path" ─────
+function mount(DATA) {
+  const routes = {};
+  const reg = m => (p, ...h) => { routes[`${m} ${p}`] = h[h.length - 1]; };
+  const app = { get: reg('GET'), post: reg('POST'), put: reg('PUT'), use() {} };
+  // admin gate open for tests (public endpoints under test don't use it anyway)
+  mountAdSystem(app, { DATA, PUB: path.join(DATA, '__nopub__'), adminOnly: (_q, _r, n) => n && n() });
+  return routes;
+}
+
+// synchronously invoke a captured handler with a fake req/res, return {status, body}
+function hit(routes, key, { query = {}, body = {} } = {}) {
+  const req = { query, body, params: {}, headers: {}, ip: '127.0.0.1' };
+  let out = { status: 200, body: null };
+  const res = {
+    set() { return this; },
+    status(c) { out.status = c; return this; },
+    json(o) { out.body = o; return this; },
+  };
+  routes[key](req, res);
+  return out;
+}
+
+// ── suggestPrice: source base + plays bump + $50 rounding (via suggested_price) ─
+test('suggestPrice: reach-weighted asks per source, exercised through inventory', () => {
+  const routes = mount(fixtureDir());
+  const { body } = hit(routes, 'GET /api/ad/inventory');
+  const price = Object.fromEntries(body.items.map(i => [i.uid, i.suggested_price]));
+  assert.equal(price['yt-a1'], 1000, 'youtube base 600 + >500-plays bump 400');
+  assert.equal(price['yt-a2'], 600, 'youtube base 600 + no bump');
+  assert.equal(price['orig-o1'], 750, 'original base 750 + no bump');
+  assert.equal(price['vimeo-v1'], 1100, 'vimeo EPISODE base 900 + >100-plays bump 200');
+  assert.equal(price['vimeo-v2'], 550, 'vimeo CLIP base 450 + >25-plays bump 100');
+  // every ask is a clean multiple of $50
+  for (const p of Object.values(price)) assert.equal(p % 50, 0, `${p} rounds to $50`);
+});
+
+// ── summarize: counts by source + sold/available over the returned list ───────
+test('inventory (unfiltered): summary counts every source, all available by default', () => {
+  const routes = mount(fixtureDir());
+  const { status, body } = hit(routes, 'GET /api/ad/inventory');
+  assert.equal(status, 200);
+  assert.equal(body.count, 5);
+  assert.equal(body.items.length, 5);
+  assert.equal(body.summary.total, 5);
+  assert.deepEqual(body.summary.by_source, { youtube: 2, original: 1, vimeo: 2 });
+  assert.equal(body.summary.available, 5);
+  assert.equal(body.summary.sold, 0);
+});
+
+// ── THE BUG REGRESSION GUARD: filtered summary must match filtered count ──────
+test('inventory ?source=youtube: summary reflects the FILTERED list (not a fresh build)', () => {
+  const routes = mount(fixtureDir());
+  const { body } = hit(routes, 'GET /api/ad/inventory', { query: { source: 'youtube' } });
+  assert.equal(body.count, 2);
+  assert.ok(body.items.every(i => i.source === 'youtube'), 'only youtube items returned');
+  // pre-fix this was 5 (summarize(buildInventory()) ignored the filter) → mismatch
+  assert.equal(body.summary.total, body.count, 'summary.total must equal count on a filtered call');
+  assert.deepEqual(body.summary.by_source, { youtube: 2 });
+});
+
+test('inventory ?status=available vs ?status=sold: status filter is consistent', () => {
+  const routes = mount(fixtureDir());
+  const avail = hit(routes, 'GET /api/ad/inventory', { query: { status: 'available' } }).body;
+  assert.equal(avail.count, 5);
+  assert.equal(avail.summary.total, 5);
+  const sold = hit(routes, 'GET /api/ad/inventory', { query: { status: 'sold' } }).body;
+  assert.equal(sold.count, 0);
+  assert.equal(sold.summary.total, 0);
+});
+
+// ── reserve: input validation (400s) + happy path (200 appends jsonl) ─────────
+test('reserve: rejects a missing/invalid email with 400', () => {
+  const routes = mount(fixtureDir());
+  assert.equal(hit(routes, 'POST /api/ad/reserve', { body: { target_id: 'yt-a1' } }).status, 400);
+  assert.equal(hit(routes, 'POST /api/ad/reserve', { body: { email: 'not-an-email', target_id: 'yt-a1' } }).status, 400);
+});
+
+test('reserve: rejects a missing target_id with 400', () => {
+  const routes = mount(fixtureDir());
+  const { status } = hit(routes, 'POST /api/ad/reserve', { body: { email: 'buyer@example.com' } });
+  assert.equal(status, 400);
+});
+
+test('reserve: valid inquiry returns 200 and appends to ad-reservations.jsonl (no charge)', () => {
+  const DATA = fixtureDir();
+  const routes = mount(DATA);
+  const { status, body } = hit(routes, 'POST /api/ad/reserve', {
+    body: { email: 'buyer@example.com', target_id: 'yt-a1', kind: 'video', advertiser: 'ACME' },
+  });
+  assert.equal(status, 200);
+  assert.equal(body.ok, true);
+  assert.match(body.message, /No charge/i);
+  const line = fs.readFileSync(path.join(DATA, 'ad-reservations.jsonl'), 'utf8').trim();
+  assert.ok(line.length > 0, 'a reservation row was appended');
+  assert.equal(JSON.parse(line).email, 'buyer@example.com');
+});

← 4295511a chore: v0.24.0 (session close — View-all toggle wired + prev  ·  back to Rentv 2026  ·  test(adslots): rate-card data contract + rate-card/slot endp adcaafca →