← back to Stars of Design

test/smoke.js

237 lines

#!/usr/bin/env node
'use strict';
// Smoke test for starsofdesign. Hits every public route + verifies key DOM
// markers (canonical, JSON-LD type, BreadcrumbList, footer links). Zero deps.
//
// Usage:
//   node test/smoke.js
//   SOD_URL=https://… node test/smoke.js

const http = require('http');
const https = require('https');
const { URL } = require('url');

const BASE = process.env.SOD_URL || 'http://127.0.0.1:9928';
const TIMEOUT_MS = 8_000;

function fetch(path, opts = {}) {
  const u = new URL(path, BASE);
  const lib = u.protocol === 'https:' ? https : http;
  const method = opts.method || 'GET';
  return new Promise((resolve, reject) => {
    const req = lib.request(u, { method, timeout: TIMEOUT_MS, headers: opts.headers || {} }, (res) => {
      let body = '';
      res.on('data', (c) => (body += c));
      res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body }));
    });
    req.on('error', reject);
    req.on('timeout', () => { req.destroy(new Error('timeout')); });
    req.end();
  });
}

const checks = [];
let pass = 0, fail = 0;
function check(name, cond, hint = '') {
  if (cond) { pass++; checks.push({ name, ok: true }); }
  else      { fail++; checks.push({ name, ok: false, hint }); }
}

(async () => {
  if (!process.argv.includes('--json')) console.log(`starsofdesign smoke — ${BASE}\n`);

  // 1. home — perf budget enforced (1500ms cold ceiling; ~5ms warm)
  const homeT0 = Date.now();
  let r = await fetch('/');
  const homeMs = Date.now() - homeT0;
  check('home: <1500ms',       homeMs < 1500, `actual ${homeMs}ms`);
  check('home: 200',           r.status === 200);
  check('home: title',         /Stars of Design/.test(r.body));
  check('home: canonical',     /rel="canonical"/.test(r.body));
  check('home: WebSite schema',/"@type":"WebSite"/.test(r.body));
  check('home: SearchAction',  /SearchAction/.test(r.body));
  check('home: favicon',       /rel="icon"/.test(r.body));
  check('home: footer Privacy',/\/privacy/.test(r.body));
  check('home: footer Terms',  /\/terms/.test(r.body));
  check('home: X-Content-Type-Options', (r.headers['x-content-type-options'] || '') === 'nosniff');
  check('home: X-Frame-Options',        /SAMEORIGIN|DENY/.test(r.headers['x-frame-options'] || ''));
  check('home: Permissions-Policy',     /geolocation=/.test(r.headers['permissions-policy'] || ''));
  check('home: canonical',              /rel="canonical"/.test(r.body));
  check('home: color-scheme',           /name="color-scheme"/.test(r.body));
  check('home: Referrer-Policy',        /strict-origin/.test(r.headers['referrer-policy'] || ''));
  check('home: Strict-Transport-Security', /max-age=/.test(r.headers['strict-transport-security'] || ''));
  check('home: CSP',                    /default-src/.test(r.headers['content-security-policy'] || ''));

  // 2. /designers
  r = await fetch('/designers');
  check('designers: 200',      r.status === 200);
  check('designers: canonical',/rel="canonical"/.test(r.body));
  check('designers: ItemList', /"@type":"ItemList"/.test(r.body));
  check('designers: sort',     /id="sort-select"|name="sort"/.test(r.body));
  check('designers: density',  /type="range"/.test(r.body));

  // 3. /firms
  r = await fetch('/firms');
  check('firms: 200',          r.status === 200);
  check('firms: canonical',    /rel="canonical"/.test(r.body));
  check('firms: ItemList',     /"@type":"ItemList"/.test(r.body));
  check('firms: sort',         /id="sort-select"|name="sort"/.test(r.body));
  check('firms: density',      /type="range"/.test(r.body));

  // 4. /clients
  r = await fetch('/clients');
  check('clients: 200',        r.status === 200);
  check('clients: ItemList',   /"@type":"ItemList"/.test(r.body));
  check('clients: sort',       /id="sort-select"|name="sort"/.test(r.body));
  check('clients: density',    /type="range"/.test(r.body));

  // 5. /feed
  r = await fetch('/feed');
  check('feed: 200',           r.status === 200);

  // 6. /videos
  r = await fetch('/videos');
  check('videos: 200',         r.status === 200);

  // 7. detail pages
  r = await fetch('/designers/dorothy-draper');
  check('designer/:slug: 200',         r.status === 200);
  check('designer/:slug: Person',      /"@type":"Person"/.test(r.body));
  check('designer/:slug: Breadcrumb',  /"@type":"BreadcrumbList"/.test(r.body));

  r = await fetch('/firms/nick-olsen-style');
  check('firm/:slug: 200',             r.status === 200);
  check('firm/:slug: Organization',    /"@type":"Organization"/.test(r.body));
  check('firm/:slug: Breadcrumb',      /"@type":"BreadcrumbList"/.test(r.body));

  r = await fetch('/clients/nick-olsen-style-firm');
  check('client/:slug: 200',           r.status === 200);
  check('client/:slug: Person',        /"@type":"Person"/.test(r.body));
  check('client/:slug: Breadcrumb',    /"@type":"BreadcrumbList"/.test(r.body));

  // 8a. /api — discovery doc
  r = await fetch('/api');
  check('api: 200',                    r.status === 200);
  check('api: discovery doc',          /endpoints/.test(r.body) && /Stars of Design API/.test(r.body));

  // 8a1. /healthz — k8s liveness alias (no DB, no-store)
  r = await fetch('/healthz');
  check('healthz: 200',                r.status === 200);
  check('healthz: ok JSON',            /"ok":true/.test(r.body));
  check('healthz: no-store',           (r.headers['cache-control'] || '').includes('no-store'));
  // HEAD method support — monitoring tools often probe with HEAD
  r = await fetch('/healthz', { method: 'HEAD' });
  check('healthz: HEAD 200',           r.status === 200);

  // 8a2. /api/health — perf budget 1000ms (was 500, bumped after flaky cold-
  // cache spike). Genuine regressions still fail this; ordinary jitter doesn't.
  {
    const t0 = Date.now();
    r = await fetch('/api/health');
    const ms = Date.now() - t0;
    check('api/health: 200',             r.status === 200);
    check('api/health: ok',              /"ok":true/.test(r.body));
    check('api/health: uptime_s',        /"uptime_s":\d+/.test(r.body));
    check('api/health: counts.designers',/"designers":\d+/.test(r.body));
    check('api/health: <1000ms',         ms < 1000, `actual ${ms}ms`);
  }

  // 8a3. /api/version — deploy fingerprint
  r = await fetch('/api/version');
  check('api/version: 200',            r.status === 200);
  check('api/version: name',           /"name":"starsofdesign"/.test(r.body));
  check('api/version: git sha',        /"git":"[a-f0-9]{4,}/.test(r.body));
  check('api/version: git resolved',   !/"git":"unknown"/.test(r.body));
  check('api/version: node',           /"node":"v\d+/.test(r.body));
  check('api/version: no-store',       (r.headers['cache-control'] || '').includes('no-store'));

  // 8b. /api/stats
  r = await fetch('/api/stats');
  check('api/stats: 200',              r.status === 200);
  check('api/stats: designers field',  /"designers":/.test(r.body));

  // 8c. /api/{designers,firms,clients}/random + /random/{designer,firm,client}
  for (const k of ['designers', 'firms', 'clients']) {
    r = await fetch(`/api/${k}/random`);
    check(`api/${k}/random: 200`,      r.status === 200);
    check(`api/${k}/random: slug`,     /"slug":"[a-z0-9-]+/.test(r.body));
  }
  for (const k of ['designer', 'firm', 'client']) {
    r = await fetch(`/random/${k}`, { redirect: 'manual' });
    check(`/random/${k}: 302`,         r.status === 302);
  }

  // 8d. /api/{designers,firms,clients}/featured?n=3 — multi-row companions
  for (const k of ['designers', 'firms', 'clients']) {
    r = await fetch(`/api/${k}/featured?n=3`);
    check(`api/${k}/featured: 200`,    r.status === 200);
    check(`api/${k}/featured: array`,  new RegExp(`"${k}":\\[`).test(r.body));
    check(`api/${k}/featured: SWR cache`, /stale-while-revalidate/.test(r.headers['cache-control'] || ''));
  }

  // 9. /sitemap.xml + /robots.txt + /favicon.ico
  r = await fetch('/sitemap.xml');
  check('sitemap: 200',                r.status === 200);
  check('sitemap: urlset',             /<urlset/.test(r.body));
  check('sitemap: has /privacy',       /<loc>[^<]*\/privacy<\/loc>/.test(r.body));
  check('sitemap: has /terms',         /<loc>[^<]*\/terms<\/loc>/.test(r.body));
  check('sitemap: Last-Modified',      /GMT$/.test(r.headers['last-modified'] || ''));
  r = await fetch('/sitemap.txt');
  check('sitemap.txt: 200',            r.status === 200);
  check('sitemap.txt: has http URL',   /^https?:\/\//.test(r.body));
  check('sitemap.txt: Last-Modified',  /GMT$/.test(r.headers['last-modified'] || ''));
  r = await fetch('/robots.txt');
  check('robots: 200',                 r.status === 200);
  r = await fetch('/favicon.ico');
  check('favicon: 200',                r.status === 200);
  check('favicon: image/svg',          /image\/svg/.test(r.headers['content-type'] || ''));

  // gzip negotiated + Vary: Accept-Encoding (cache safety)
  r = await fetch('/firms', { headers: { 'Accept-Encoding': 'gzip' } });
  check('gzip /firms',                 (r.headers['content-encoding'] || '') === 'gzip');
  check('vary Accept-Encoding',        (r.headers['vary'] || '').toLowerCase().includes('accept-encoding'));

  // helmet security headers — sod uses helmet, asim doesn't, so this is sod-only.
  r = await fetch('/');
  check('HSTS header',                 /max-age=\d+/.test(r.headers['strict-transport-security'] || ''));
  check('CSP header',                  /default-src/.test(r.headers['content-security-policy'] || ''));

  // 10. legal pages
  r = await fetch('/about');
  check('about: 200',                  r.status === 200);
  check('about: Stars of Design h1',   /About Stars of Design/.test(r.body));
  r = await fetch('/privacy');
  check('privacy: 200',                r.status === 200);
  check('privacy: h1',                 /<h1[^>]*>Privacy<\/h1>/.test(r.body));
  r = await fetch('/terms');
  check('terms: 200',                  r.status === 200);
  check('terms: h1',                   /<h1[^>]*>Terms of use<\/h1>/.test(r.body));

  // 11. /submit
  r = await fetch('/submit');
  check('submit: 200',                 r.status === 200);

  // 12. branded 404 — non-routed path returns HTML with brand name
  r = await fetch('/nonexistent-path-smoke-test');
  check('404: status 404',             r.status === 404);
  check('404: branded HTML',           /Stars of Design/.test(r.body));

  // Report — flags:
  //   --quiet : only print failures + tally (CI / cron)
  //   --json  : emit a single JSON object (monitoring / dashboards)
  if (process.argv.includes('--json')) {
    console.log(JSON.stringify({ base: BASE, pass, fail, total: pass + fail, checks, as_of: new Date().toISOString() }));
    process.exit(fail === 0 ? 0 : 1);
  }
  const quiet = process.argv.includes('--quiet');
  const lines = quiet
    ? checks.filter(c => !c.ok).map(c => '✗ ' + c.name + (c.hint ? '  (' + c.hint + ')' : ''))
    : checks.map(c => (c.ok ? '✓' : '✗') + ' ' + c.name + (c.hint ? '  (' + c.hint + ')' : ''));
  if (lines.length) console.log(lines.join('\n'));
  console.log(`${quiet && fail === 0 ? '' : '\n'}${pass} passed, ${fail} failed`);
  process.exit(fail === 0 ? 0 : 1);
})().catch((e) => {
  console.error('smoke harness error:', e.message);
  process.exit(2);
});