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