← back to AsSeenInMovies

test/smoke.js

243 lines

#!/usr/bin/env node
'use strict';
// Smoke test for asseeninmovies. Hits every public route and verifies:
//   1. HTTP 200/3xx where expected
//   2. Key DOM markers (title, canonical, JSON-LD type, OG tag)
//
// Usage:
//   node test/smoke.js               # against http://127.0.0.1:9742
//   ASIM_URL=https://… node test/smoke.js
//
// Exit 0 on all-pass, 1 if any check fails. No framework deps — just node.

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

const BASE = process.env.ASIM_URL || 'http://127.0.0.1:9742';
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(`asseeninmovies smoke — ${BASE}\n`);

  // 1. home — perf budget enforced (1500ms cold-cache, ~2ms 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',         /AsSeenInMovies/.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 About',  /\/about/.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: Referrer-Policy',         /strict-origin/.test(r.headers['referrer-policy'] || ''));
  check('home: Permissions-Policy',      /geolocation=/.test(r.headers['permissions-policy'] || ''));
  check('home: og:image',                /property="og:image"/.test(r.body));
  check('home: color-scheme',            /name="color-scheme"/.test(r.body));

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

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

  // 4. /spotted
  r = await fetch('/spotted');
  check('spotted: 200',        r.status === 200);
  check('spotted: stats',      /sp-stats/.test(r.body));
  check('spotted: ItemList',   /"@type":"ItemList"/.test(r.body));
  check('spotted: submit cta', /\/spotted\/submit/.test(r.body));

  // 5. /spotted/submit
  r = await fetch('/spotted/submit');
  check('submit: 200',         r.status === 200);
  check('submit: form',        /<form/.test(r.body));

  // 6. /spotted/queue (admin, no token → 401)
  r = await fetch('/spotted/queue');
  check('queue: 401 no token', r.status === 401);

  // 7. /movie/:id (use a known credited movie)
  r = await fetch('/movie/34029');
  check('movie/:id: 200',         r.status === 200);
  check('movie/:id: Movie type',  /"@type":"Movie"/.test(r.body));
  check('movie/:id: Breadcrumb',  /"@type":"BreadcrumbList"/.test(r.body));
  check('movie/:id: og:image',    /property="og:image"/.test(r.body));

  // 8. /person/:id
  r = await fetch('/person/36');
  check('person/:id: 200',        r.status === 200);
  check('person/:id: Person type',/"@type":"Person"/.test(r.body));
  check('person/:id: Breadcrumb', /"@type":"BreadcrumbList"/.test(r.body));

  // 9. /search
  r = await fetch('/search');
  check('search: 200',         r.status === 200);
  r = await fetch('/search?q=hotel');
  check('search?q: 200',       r.status === 200);

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

  // 10a2. /healthz — k8s liveness alias (no DB, no cache, 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);

  // 10b. /api/health — perf budget. Bumped 500ms → 1000ms after a flaky cold-
  // cache spike (build-pages restart briefly stole event-loop time and asim's
  // first /api/health probe missed 500ms by 30-40ms). 1000ms still catches
  // genuine N+1 / pool-exhaustion regressions, which run hundreds of ms.
  {
    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: <1000ms',     ms < 1000, `actual ${ms}ms`);
  }

  // 10c. /api/version — deploy fingerprint
  r = await fetch('/api/version');
  check('api/version: 200',        r.status === 200);
  check('api/version: name',       /"name":"asseeninmovies"/.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'));

  // 11. /api/spotted/stats
  r = await fetch('/api/spotted/stats');
  check('api/spotted/stats: 200', r.status === 200);
  check('api/spotted/stats: live_total field', /"live_total":/.test(r.body));

  // 11b. /api/spotted/random
  r = await fetch('/api/spotted/random');
  check('api/spotted/random: 200', r.status === 200);
  check('api/spotted/random: spotted field', /"spotted":\{/.test(r.body));

  // 11b2. /api/spotted/featured?n=3
  r = await fetch('/api/spotted/featured?n=3');
  check('api/spotted/featured: 200', r.status === 200);
  check('api/spotted/featured: array', /"spotted":\[/.test(r.body));
  check('api/spotted/featured: SWR cache', /stale-while-revalidate/.test(r.headers['cache-control'] || ''));

  // 11c. /api/credits/stats
  r = await fetch('/api/credits/stats');
  check('api/credits/stats: 200', r.status === 200);
  check('api/credits/stats: total field', /"total":/.test(r.body));

  // 12. /random/movie + /random/person + /random/spotted
  r = await fetch('/random/movie');
  check('random/movie: 302', r.status === 302);
  check('random/movie: -> /movie/', /^\/movie\/\d+/.test(r.headers.location || ''));
  r = await fetch('/random/person');
  check('random/person: 302', r.status === 302);
  r = await fetch('/random/spotted');
  check('random/spotted: 302', r.status === 302);
  check('random/spotted: -> /movie/.../#spotted', /\/movie\/\d+#spotted/.test(r.headers.location || ''));

  // 13. /sitemap.xml + /sitemap.txt + /robots.txt
  r = await fetch('/sitemap.xml');
  check('sitemap: 200',        r.status === 200);
  check('sitemap: urlset',     /<urlset/.test(r.body));
  check('sitemap: has /about', /<loc>[^<]*\/about<\/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);
  check('robots: disallow queue', /\/spotted\/queue/.test(r.body));

  // 14. /favicon.ico
  r = await fetch('/favicon.ico');
  check('favicon: 200',        r.status === 200);
  check('favicon: image/svg',  /image\/svg/.test(r.headers['content-type'] || ''));

  // 14b. gzip negotiated + Vary: Accept-Encoding (so caches/CDNs don't
  // serve the gzipped body to clients that didn't ask for gzip)
  r = await fetch('/movies', { headers: { 'Accept-Encoding': 'gzip' } });
  check('gzip /movies',        (r.headers['content-encoding'] || '') === 'gzip');
  check('vary Accept-Encoding',(r.headers['vary'] || '').toLowerCase().includes('accept-encoding'));

  // 15. /about + /privacy + /terms
  r = await fetch('/about');
  check('about: 200',          r.status === 200);
  check('about: h1',           /About AsSeenInMovies/.test(r.body));
  r = await fetch('/privacy');
  check('privacy: 200',        r.status === 200);
  r = await fetch('/terms');
  check('terms: 200',          r.status === 200);

  // 16. 404 — branded HTML
  r = await fetch('/zzz-not-a-real-page');
  check('404 path: 404',       r.status === 404);
  check('404 path: branded',   /class="title"/.test(r.body));
  r = await fetch('/api/zzz-not-a-real');
  check('404 api: JSON 404',   r.status === 404 && /"error":"not-found"/.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);
});