← 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();
});