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>
Files touched
M package.jsonM src/ad-system.cjsA test/adslots/inventory.test.mjs
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');
+});