← back to Lifestyle Asset Intel

tests/smoke.test.js

545 lines

// smoke.test.js — boots the app on an ephemeral port and hits the v0 contract endpoints.
//
// Requires the database to be created/seeded:
//   createdb lifestyle_asset_intel
//   PGDATABASE=lifestyle_asset_intel psql -f db/schema.sql
//   PGDATABASE=lifestyle_asset_intel psql -f db/seed.sql

'use strict';

const { test, after } = require('node:test');
const assert = require('node:assert/strict');

process.env.NODE_ENV = process.env.NODE_ENV || 'test';
process.env.PGDATABASE = process.env.PGDATABASE || 'lifestyle_asset_intel';

const app = require('../server');
const { pool } = require('../lib/db');
const { METHODOLOGY_VERSION } = require('../lib/valuation');

let server;
let baseUrl;

function ready() {
  return new Promise((resolve) => {
    server = app.listen(0, '127.0.0.1', () => {
      const { port } = server.address();
      baseUrl = `http://127.0.0.1:${port}`;
      resolve();
    });
  });
}

async function get(path) {
  const r = await fetch(baseUrl + path);
  const text = await r.text();
  let json = null;
  try { json = JSON.parse(text); } catch (e) { /* not json */ }
  return { status: r.status, json, text };
}

test('boots and returns 200 on /api/health with db_ok true', async () => {
  await ready();
  const r = await get('/api/health');
  assert.equal(r.status, 200);
  assert.equal(r.json.ok, true);
  assert.equal(r.json.db_ok, true, 'db connection check');
  assert.equal(r.json.version, '0.0.1');
});

test('Cache-Control headers match the per-route discipline', async () => {
  // Health: no-cache (must-validate)
  let r = await fetch(baseUrl + '/api/health');
  assert.match(r.headers.get('cache-control') || '', /no-cache/);

  // Assets list: short public cache
  r = await fetch(baseUrl + '/api/assets');
  assert.match(r.headers.get('cache-control') || '', /public.*max-age=60/);

  // Valuation: NEVER cached (audit requirement)
  r = await fetch(baseUrl + '/api/valuation/birkin-30-togo-gold-ghw-us');
  assert.match(r.headers.get('cache-control') || '', /no-store/);

  // OpenAPI spec: longer cache (changes only on deploy)
  r = await fetch(baseUrl + '/api/openapi.json');
  assert.match(r.headers.get('cache-control') || '', /public.*max-age=3600/);

  // Portfolio: per-user state, never cached
  r = await fetch(baseUrl + '/api/portfolio?owner=cache-probe%40example.com');
  assert.match(r.headers.get('cache-control') || '', /no-store/);
});

test('GET /api/assets?q=birkin returns only Birkin family rows', async () => {
  const r = await get('/api/assets?q=birkin');
  assert.equal(r.status, 200);
  assert.ok(Array.isArray(r.json.assets));
  assert.ok(r.json.assets.length > 0, 'at least one Birkin');
  for (const a of r.json.assets) {
    assert.equal(a.family_name, 'Birkin', `${a.slug} should be a Birkin`);
  }
  assert.equal(r.json.q, 'birkin');
});

test('GET /api/assets?q=clemence filters by material', async () => {
  const r = await get('/api/assets?q=clemence');
  assert.equal(r.status, 200);
  for (const a of r.json.assets) {
    assert.match(
      `${a.material || ''} ${a.slug}`.toLowerCase(),
      /clemence/,
      `${a.slug} should match clemence`
    );
  }
});

test('GET /api/assets?sort=value-desc orders by q50 descending', async () => {
  const r = await get('/api/assets?sort=value-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.sort, 'value-desc');
  assert.ok(r.json.assets.length >= 2);
  const q50s = r.json.assets.map((a) => a.q50).filter((v) => v != null);
  for (let i = 1; i < q50s.length; i++) {
    assert.ok(Number(q50s[i - 1]) >= Number(q50s[i]),
      `value-desc broken at idx ${i}: ${q50s[i - 1]} < ${q50s[i]}`);
  }
});

test('GET /api/assets?sort=slug orders alphabetically by slug', async () => {
  const r = await get('/api/assets?sort=slug');
  assert.equal(r.status, 200);
  const slugs = r.json.assets.map((a) => a.slug);
  for (let i = 1; i < slugs.length; i++) {
    assert.ok(slugs[i - 1].localeCompare(slugs[i]) <= 0,
      `slug sort broken at idx ${i}: ${slugs[i - 1]} > ${slugs[i]}`);
  }
});

test('GET /api/assets?sort=value-desc&q=birkin sorts within filtered set', async () => {
  const r = await get('/api/assets?sort=value-desc&q=birkin');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'birkin');
  assert.equal(r.json.sort, 'value-desc');
  for (const a of r.json.assets) {
    assert.equal(a.family_name, 'Birkin');
  }
  const q50s = r.json.assets.map((a) => a.q50).filter((v) => v != null);
  for (let i = 1; i < q50s.length; i++) {
    assert.ok(Number(q50s[i - 1]) >= Number(q50s[i]));
  }
});

test('GET /api/assets?sort=bogus falls back to default ordering (no error)', async () => {
  const r = await get('/api/assets?sort=not-a-real-sort');
  assert.equal(r.status, 200);
  assert.ok(r.json.assets.length > 0);
});

test('GET /api/openapi.json is valid OpenAPI 3.1 with all current routes', async () => {
  const r = await get('/api/openapi.json');
  assert.equal(r.status, 200);
  assert.equal(r.json.openapi, '3.1.0');
  assert.ok(r.json.info && r.json.info.title);
  // Spot-check that every documented endpoint actually exists in code:
  const documented = Object.keys(r.json.paths);
  for (const p of [
    '/api/health', '/api/version', '/api/sources', '/api/assets',
    '/api/valuation/{slug}', '/api/index', '/api/index/{slug}',
    '/api/portfolio', '/api/portfolio/{id}', '/api/audit/recent'
  ]) {
    assert.ok(documented.includes(p), `OpenAPI missing path ${p}`);
  }
  // ValuationResponse schema must surface MSRP + premium_to_msrp (added v0.2)
  const vr = r.json.components.schemas.ValuationResponse;
  assert.ok(vr && vr.properties.msrp, 'msrp documented');
  assert.ok(vr.properties.premium_to_msrp, 'premium_to_msrp documented');
});

test('GET /api/version returns build + schema + data introspection', async () => {
  const r = await get('/api/version');
  assert.equal(r.status, 200);
  assert.equal(r.json.service, 'lifestyle-asset-intel');
  assert.equal(r.json.methodology_version, METHODOLOGY_VERSION);
  assert.ok(r.json.runtime && r.json.runtime.node, 'runtime.node present');
  assert.ok(typeof r.json.runtime.uptime_seconds === 'number');
  assert.ok(Array.isArray(r.json.schema.migrations));
  assert.ok(r.json.schema.migrations_applied >= 3, 'at least 3 migrations');
  assert.ok(r.json.data.assets >= 1);
  assert.ok(r.json.data.snapshots >= 1);
});

test('GET /api/sources returns the seeded source registry', async () => {
  const r = await get('/api/sources');
  assert.equal(r.status, 200);
  assert.ok(Array.isArray(r.json.sources));
  assert.ok(r.json.sources.length >= 4, 'at least four sources seeded');
  assert.equal(typeof r.json.count, 'number');
  assert.equal(r.json.count, r.json.sources.length);
  assert.equal(r.json.sort, null, 'default response has null sort');
  const slugs = r.json.sources.map((s) => s.slug);
  assert.ok(slugs.includes('sothebys'));
  assert.ok(slugs.includes('fashionphile'));
});

test('GET /api/sources?sort=weight-desc returns highest-weight source first', async () => {
  const r = await get('/api/sources?sort=weight-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.sort, 'weight-desc');
  const weights = r.json.sources.map((s) => parseFloat(s.weight));
  for (let i = 1; i < weights.length; i++) {
    assert.ok(weights[i - 1] >= weights[i],
      `weights must be non-increasing — got ${weights.join(',')}`);
  }
});

test('GET /api/sources?sort=name returns alphabetical by name', async () => {
  const r = await get('/api/sources?sort=name');
  assert.equal(r.status, 200);
  assert.equal(r.json.sort, 'name');
  const names = r.json.sources.map((s) => s.name);
  // Postgres default collation is locale-aware (e.g. 'eBay' < 'FASHIONPHILE')
  // — use localeCompare so the JS reference matches PG's ORDER BY name.
  const sorted = names.slice().sort((a, b) => a.localeCompare(b));
  assert.deepEqual(names, sorted, 'names returned in alphabetical order');
});

test('GET /api/sources?q=auction filters by kind/notes (case-insensitive)', async () => {
  const r = await get('/api/sources?q=AUCTION');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'AUCTION');
  assert.ok(r.json.sources.length >= 2, 'sothebys + christies seeded as kind=auction');
  for (const s of r.json.sources) {
    const hay = `${s.slug} ${s.name} ${s.kind} ${s.notes || ''}`.toLowerCase();
    assert.ok(hay.includes('auction'), `${s.slug} should match auction in slug/name/kind/notes`);
  }
});

test('GET /api/sources?q=sothebys&sort=weight-desc composes filter + sort', async () => {
  const r = await get('/api/sources?q=sothebys&sort=weight-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'sothebys');
  assert.equal(r.json.sort, 'weight-desc');
  assert.ok(r.json.sources.length >= 1);
  for (const s of r.json.sources) {
    const hay = `${s.slug} ${s.name} ${s.kind} ${s.notes || ''}`.toLowerCase();
    assert.ok(hay.includes('sothebys') || hay.includes("sotheby's"));
  }
});

test('GET /api/sources?q=zzznomatch returns empty list (no error)', async () => {
  const r = await get('/api/sources?q=zzznomatch');
  assert.equal(r.status, 200);
  assert.equal(r.json.count, 0);
  assert.deepEqual(r.json.sources, []);
});

test('GET /api/sources?sort=garbage falls back to default ordering', async () => {
  const r = await get('/api/sources?sort=garbage');
  assert.equal(r.status, 200);
  // Default = tier ASC, name ASC. With seed data, tier-1 (Christie's,
  // Sotheby's) appear before tier-2/3, sorted by name within tier.
  assert.equal(r.json.sources[0].tier, 1);
  // Unknown sort values still echo back the requested string for parity
  // with the other list endpoints (caller sees what they asked for).
  assert.equal(r.json.sort, 'garbage');
});

test('GET /api/valuation/birkin-30-togo-gold-ghw-us returns blueprint contract shape', async () => {
  const r = await get('/api/valuation/birkin-30-togo-gold-ghw-us');
  assert.equal(r.status, 200);
  assert.ok(r.json.asset, 'asset block present');
  assert.equal(r.json.asset.brand, 'Hermès');
  assert.equal(r.json.asset.family, 'Birkin');
  assert.equal(r.json.asset.size, '30');
  assert.equal(r.json.asset.material, 'Togo');
  assert.ok(r.json.latest_snapshot, 'latest_snapshot present');
  assert.ok(r.json.latest_snapshot.q10 > 0);
  assert.ok(r.json.latest_snapshot.q50 > r.json.latest_snapshot.q10);
  assert.ok(r.json.latest_snapshot.q90 >= r.json.latest_snapshot.q50);
  assert.ok(Array.isArray(r.json.comps));
  assert.ok(r.json.comps.length >= 4, 'at least four blueprint comps seeded');
  assert.ok(r.json.confidence_breakdown);
  assert.ok('source_quality_weight' in r.json.confidence_breakdown);
  assert.ok('authenticity_risk_penalty' in r.json.confidence_breakdown);
  // MSRP block — the seeded Birkin 30 Togo gets BLUEPRINT.md MSRP series.
  assert.ok(r.json.msrp, 'msrp present for seeded B30 Togo');
  assert.ok(r.json.msrp.msrp_usd > 0);
  assert.equal(r.json.msrp.source, 'auction_house_commentary');
  assert.ok(typeof r.json.premium_to_msrp === 'number');
  assert.ok(r.json.premium_to_msrp > 1.0, 'premium > 1x retail');
});

test('GET /api/valuation/<unknown-slug> returns 404 with shape', async () => {
  const r = await get('/api/valuation/does-not-exist');
  assert.equal(r.status, 404);
  assert.equal(r.json.error, 'asset_not_found');
});

test('GET /api/family/birkin returns cohort summary + assets', async () => {
  const r = await get('/api/family/birkin');
  assert.equal(r.status, 200);
  assert.equal(r.json.family.slug, 'birkin');
  assert.equal(r.json.brand.slug, 'hermes');
  assert.ok(r.json.summary.asset_count >= 6, 'multiple Birkin canonical assets');
  assert.ok(typeof r.json.summary.avg_q50 === 'number');
  assert.ok(Array.isArray(r.json.assets));
  for (const a of r.json.assets) {
    assert.equal(a.family_slug, 'birkin');
  }
  assert.ok(Array.isArray(r.json.indices));
});

test('GET /api/family lists every model family with cohort summary', async () => {
  const r = await get('/api/family');
  assert.equal(r.status, 200);
  assert.ok(typeof r.json.count === 'number');
  assert.ok(Array.isArray(r.json.families));
  assert.ok(r.json.families.length >= 2, 'at least Birkin + Kelly seeded');
  const slugs = r.json.families.map((f) => f.slug);
  assert.ok(slugs.includes('birkin'), 'birkin family present');
  assert.ok(slugs.includes('kelly'), 'kelly family present');
  const birkin = r.json.families.find((f) => f.slug === 'birkin');
  assert.equal(birkin.brand.slug, 'hermes');
  assert.equal(birkin.brand.name, 'Hermès');
  assert.ok(birkin.asset_count >= 6);
  assert.ok(typeof birkin.total_comps_24m === 'number');
});

test('GET /api/family/<unknown> returns 404', async () => {
  const r = await get('/api/family/nonexistent');
  assert.equal(r.status, 404);
  assert.equal(r.json.error, 'family_not_found');
});

test('GET /api/family?sort=assets-desc orders by asset_count descending', async () => {
  const r = await get('/api/family?sort=assets-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.sort, 'assets-desc');
  const counts = r.json.families.map((f) => f.asset_count || 0);
  for (let i = 1; i < counts.length; i++) {
    assert.ok(counts[i] <= counts[i - 1],
      `assets-desc sort broken at idx ${i}: ${counts[i - 1]} < ${counts[i]}`);
  }
});

test('GET /api/family?sort=value-desc orders by avg_q50 descending (nulls last)', async () => {
  const r = await get('/api/family?sort=value-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.sort, 'value-desc');
  const vals = r.json.families.map((f) => f.avg_q50);
  let sawNull = false;
  for (let i = 0; i < vals.length; i++) {
    if (vals[i] == null) { sawNull = true; continue; }
    assert.ok(!sawNull, `non-null avg_q50 at idx ${i} appeared after a null`);
    if (i > 0 && vals[i - 1] != null) {
      assert.ok(vals[i] <= vals[i - 1],
        `value-desc broken at idx ${i}: ${vals[i - 1]} < ${vals[i]}`);
    }
  }
});

test('GET /api/family?sort=bogus falls back to default ordering (no error)', async () => {
  const r = await get('/api/family?sort=not-a-real-sort');
  assert.equal(r.status, 200);
  assert.ok(Array.isArray(r.json.families));
  assert.ok(r.json.families.length >= 2);
});

test('GET /api/family?q=birkin narrows result to matching family', async () => {
  const r = await get('/api/family?q=birkin');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'birkin');
  assert.equal(r.json.sort, null);
  assert.ok(r.json.families.length >= 1);
  for (const f of r.json.families) {
    const hay = `${f.slug} ${f.name} ${f.brand && f.brand.slug} ${f.brand && f.brand.name}`.toLowerCase();
    assert.ok(hay.includes('birkin'), `${f.slug} should match birkin in slug/name/brand`);
  }
});

test('GET /api/family?q=hermes matches via brand and surfaces ≥2 families', async () => {
  const r = await get('/api/family?q=hermes');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'hermes');
  assert.ok(r.json.families.length >= 2, 'birkin + kelly both Hermès');
  for (const f of r.json.families) {
    assert.equal(f.brand.slug, 'hermes');
  }
});

test('GET /api/family?q=birkin&sort=assets-desc composes filter + sort', async () => {
  const r = await get('/api/family?q=birkin&sort=assets-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'birkin');
  assert.equal(r.json.sort, 'assets-desc');
  const counts = r.json.families.map((f) => f.asset_count || 0);
  for (let i = 1; i < counts.length; i++) {
    assert.ok(counts[i] <= counts[i - 1],
      `assets-desc broken at idx ${i}: ${counts[i - 1]} < ${counts[i]}`);
  }
  for (const f of r.json.families) {
    const hay = `${f.slug} ${f.name} ${f.brand.slug} ${f.brand.name}`.toLowerCase();
    assert.ok(hay.includes('birkin'));
  }
});

test('GET /api/family?q=xyzzy-no-match returns empty families list', async () => {
  const r = await get('/api/family?q=xyzzy-no-match');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'xyzzy-no-match');
  assert.equal(r.json.count, 0);
  assert.deepEqual(r.json.families, []);
});

test('GET /family renders the families index page', async () => {
  const r = await get('/family');
  assert.equal(r.status, 200);
  assert.match(r.text, /Model families/);
  assert.match(r.text, /Hermès/);
  assert.match(r.text, /href="\/family\/birkin"/);
  assert.match(r.text, /href="\/family\/kelly"/);
});

test('GET /family/kelly renders cohort detail page', async () => {
  const r = await get('/family/kelly');
  assert.equal(r.status, 200);
  assert.match(r.text, /Cohort summary/);
  assert.match(r.text, /Hermès/);
  assert.match(r.text, /Kelly/);
  assert.match(r.text, /Assets in family/);
});

test('GET /api/index/birkin-30-togo-neutral returns time series', async () => {
  const r = await get('/api/index/birkin-30-togo-neutral');
  assert.equal(r.status, 200);
  assert.equal(r.json.index.slug, 'birkin-30-togo-neutral');
  assert.ok(Array.isArray(r.json.points));
  assert.ok(r.json.points.length >= 4);
});

test('GET /api/index lists every benchmark index with latest-point summary', async () => {
  const r = await get('/api/index');
  assert.equal(r.status, 200);
  assert.ok(typeof r.json.count === 'number');
  assert.ok(Array.isArray(r.json.indices));
  assert.ok(r.json.indices.length >= 4, 'at least the four seeded indices');
  const slugs = r.json.indices.map((i) => i.slug);
  assert.ok(slugs.includes('birkin-30-togo-neutral'));
  assert.ok(slugs.includes('kelly-sellier-premium'));
  const row = r.json.indices.find((i) => i.slug === 'birkin-30-togo-neutral');
  assert.ok(row.methodology_version);
  assert.ok(row.point_count >= 4);
  assert.ok(typeof row.latest_value === 'number');
  assert.ok(row.latest_as_of, 'latest_as_of is present');
});

test('GET /api/index?sort=value-desc orders by latest_value descending (nulls last)', async () => {
  const r = await get('/api/index?sort=value-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.sort, 'value-desc');
  const vals = r.json.indices.map((i) => i.latest_value);
  let sawNull = false;
  for (let i = 0; i < vals.length; i++) {
    if (vals[i] == null) { sawNull = true; continue; }
    assert.ok(!sawNull, `non-null latest_value at idx ${i} appeared after a null`);
    if (i > 0 && vals[i - 1] != null) {
      assert.ok(vals[i] <= vals[i - 1],
        `value-desc broken at idx ${i}: ${vals[i - 1]} < ${vals[i]}`);
    }
  }
});

test('GET /api/index?sort=bogus falls back to default ordering (no error)', async () => {
  const r = await get('/api/index?sort=not-a-real-sort');
  assert.equal(r.status, 200);
  assert.ok(Array.isArray(r.json.indices));
  assert.ok(r.json.indices.length >= 4);
});

test('GET /api/index?q=kelly filters indices case-insensitively by slug/name', async () => {
  const r = await get('/api/index?q=KELLY');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'KELLY');
  assert.ok(r.json.indices.length >= 1, 'matches at least one index');
  for (const idx of r.json.indices) {
    const hay = (idx.slug + ' ' + idx.name).toLowerCase();
    assert.ok(hay.includes('kelly'), `${idx.slug} should match kelly`);
  }
  const slugs = r.json.indices.map((i) => i.slug);
  assert.ok(!slugs.includes('birkin-30-togo-neutral'), 'birkin index filtered out');
});

test('GET /api/index?q=birkin composes with sort=value-desc', async () => {
  const r = await get('/api/index?q=birkin&sort=value-desc');
  assert.equal(r.status, 200);
  assert.equal(r.json.q, 'birkin');
  assert.equal(r.json.sort, 'value-desc');
  assert.ok(r.json.indices.length >= 1);
  for (const idx of r.json.indices) {
    assert.ok((idx.slug + ' ' + idx.name).toLowerCase().includes('birkin'));
  }
});

test('GET /api/index?q=zzznomatch returns empty list (no error)', async () => {
  const r = await get('/api/index?q=zzznomatch');
  assert.equal(r.status, 200);
  assert.equal(r.json.count, 0);
  assert.deepEqual(r.json.indices, []);
});

test('GET / renders the console with sort and density controls', async () => {
  const r = await get('/');
  assert.equal(r.status, 200);
  assert.ok(/<select id="sort"/.test(r.text), 'sort <select> present');
  assert.ok(/id="density"/.test(r.text), 'density <input> present');
  assert.ok(/id="search"/.test(r.text), 'grid-search <input> present');
  assert.ok(/id="themeToggle"/.test(r.text), 'dark/light toggle present');
  assert.ok(/application\/ld\+json/.test(r.text), 'schema.org JSON-LD present');
});

test('GET /admin/audit renders the audit HTML view', async () => {
  // Make one call we know will land in the audit log, so the page
  // isn't proving the empty-state.
  await get('/api/assets?q=birkin');
  // Give the fire-and-forget audit writer a tick to flush.
  await new Promise((r) => setTimeout(r, 50));
  const r = await get('/admin/audit');
  assert.equal(r.status, 200);
  assert.match(r.text, /Audit — recent API calls/);
  assert.match(r.text, /class="audit-table"/);
  // Filter form widgets present
  assert.match(r.text, /name="slug"/);
  assert.match(r.text, /name="limit"/);
  // Must NEVER cache the audit view — it reflects an immutable log
  // that other API calls keep appending to.
  const head = await fetch(baseUrl + '/admin/audit');
  assert.match(head.headers.get('cache-control') || '', /no-store/);
});

test('GET /admin/audit?slug=<slug> filters to a single asset', async () => {
  await get('/api/valuation/birkin-30-togo-gold-ghw-us');
  await new Promise((r) => setTimeout(r, 50));
  const r = await get('/admin/audit?slug=birkin-30-togo-gold-ghw-us&limit=20');
  assert.equal(r.status, 200);
  // Should NOT contain the kelly slug (we filtered to birkin)
  assert.ok(!/asset\/kelly-/.test(r.text), 'kelly slug should not leak into birkin-filtered view');
});

test('GET /asset/birkin-30-togo-gold-ghw-us renders the detail page', async () => {
  const r = await get('/asset/birkin-30-togo-gold-ghw-us');
  assert.equal(r.status, 200);
  assert.ok(/Hermès/.test(r.text));
  assert.ok(/Birkin/.test(r.text));
  assert.ok(/Confidence breakdown/.test(r.text));
  assert.ok(/Comparable transactions/.test(r.text));
  assert.ok(/"@type": "Product"/.test(r.text), 'Product schema.org block present');
  // Sparkline Y-axis labels (yolo tick #20): aria-label exposes the range,
  // and at least one $-formatted axis tick appears in the SVG.
  assert.ok(/aria-label="price trend,/.test(r.text), 'sparkline aria-label with range');
  assert.ok(/class="spark-axis"/.test(r.text), 'sparkline axis label text rendered');
});

after(async () => {
  if (server) await new Promise((res) => server.close(res));
  await pool.end();
});