#!/usr/bin/env node /* * REST smoke test for the Flutter-consumed API of todos-contra-el-fuego-web. * * Calls every endpoint the Flutter app (fires_flutter/lib/models/fires_api.dart) * depends on, normalizes the intrinsically volatile fields (uptime, generated * ids, insert timestamps), and compares the result against committed snapshots. * * The responses MUST stay identical across every Meteor upgrade escala — the * app parses them field by field. * * Usage: * node smoke/run.js --update # (re)write baseline snapshots * node smoke/run.js # compare against baseline, exit 1 on drift * * Env: * BASE_URL default http://localhost:3100 * SETTINGS default settings-development.json (token + ironPassword) * * Requires the dev server running and the DB seeded (smoke/seed.sh does both). */ const fs = require('fs'); const path = require('path'); const ROOT = path.resolve(__dirname, '..'); const SNAP_DIR = path.join(__dirname, 'snapshots'); const BASE = (process.env.BASE_URL || 'http://localhost:3100').replace(/\/$/, ''); const API = `${BASE}/api/v1`; const UPDATE = process.argv.includes('--update'); const settings = JSON.parse(fs.readFileSync(path.join(ROOT, process.env.SETTINGS || 'settings-development.json'), 'utf8')); const TOKEN = settings.private.internalApiToken; const IRON_PASSWORD = settings.private.ironPassword; // Fixed inputs — must match smoke/seed.js const MOBILE_TOKEN = 'smoke-mobile-token'; const SUBS_ID = 'd0000000000000000000000d'; const LAT = 40.41; const LON = -3.70; // ---------- helpers ---------- function stableStringify(v) { return JSON.stringify(sortKeys(v), null, 2); } function sortKeys(v) { if (Array.isArray(v)) return v.map(sortKeys); if (v && typeof v === 'object') { return Object.keys(v).sort().reduce((acc, k) => { acc[k] = sortKeys(v[k]); return acc; }, {}); } return v; } async function req(method, url, body) { const opts = { method, headers: {} }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } const res = await fetch(url, opts); const text = await res.text(); let json; try { json = JSON.parse(text); } catch (e) { json = { __nonJson: text }; } return { status: res.status, json }; } // Replace volatile fields (by key name) with a stable placeholder, recursively. function scrub(obj, keys) { if (Array.isArray(obj)) return obj.map(o => scrub(o, keys)); if (obj && typeof obj === 'object') { const out = {}; for (const k of Object.keys(obj)) { out[k] = keys.includes(k) ? `<${k}>` : scrub(obj[k], keys); } return out; } return obj; } // ---------- iron seal for mobile/falsepositive ---------- async function sealFire() { const Iron = require('iron'); const fire = { ourid: { type: 'Point', coordinates: [LON, LAT] }, lat: LAT, lon: LON, type: 'viirs', when: '2024-01-01T00:00:00.000Z', scan: 0.5, track: 0.4, address: 'Madrid, Spain' }; // iron v5 seal returns a promise return Iron.seal(fire, IRON_PASSWORD, Iron.defaults); } // ---------- test sequence ---------- async function collect() { const snaps = {}; snaps['status_uptime'] = scrub((await req('GET', `${API}/status/uptime`)).json, ['ms']); snaps['status_active_fires_count'] = (await req('GET', `${API}/status/active-fires-count`)).json; snaps['status_last_fire_check'] = (await req('GET', `${API}/status/last-fire-check`)).json; snaps['status_last_fire_detected'] = (await req('GET', `${API}/status/last-fire-detected`)).json; snaps['status_subs_public_union'] = (await req('GET', `${API}/status/subs-public-union/${TOKEN}`)).json; // Read-only fires query BEFORE any false-positive is inserted. snaps['fires_in_full'] = (await req('GET', `${API}/fires-in-full/${TOKEN}/${LAT}/${LON}/100`)).json; snaps['fires_in'] = (await req('GET', `${API}/fires-in/${TOKEN}/${LAT}/${LON}/100`)).json; // Create mobile user const users = await req('POST', `${API}/mobile/users`, { token: TOKEN, mobileToken: MOBILE_TOKEN, lang: 'es' }); snaps['mobile_users_post'] = scrub(users.json, ['userId', 'username', 'insertedId']); // Subscribe const sub = await req('POST', `${API}/mobile/subscriptions`, { token: TOKEN, mobileToken: MOBILE_TOKEN, id: SUBS_ID, lat: LAT, lon: LON, distance: 40 }); snaps['mobile_subscriptions_post'] = sub.json; // List subscriptions const list = await req('GET', `${API}/mobile/subscriptions/all/${TOKEN}/${MOBILE_TOKEN}`); snaps['mobile_subscriptions_all'] = scrub(list.json, ['owner', 'createdAt', 'updatedAt']); // Delete the subscription const del = await req('DELETE', `${API}/mobile/subscriptions/${TOKEN}/${MOBILE_TOKEN}/${SUBS_ID}`); snaps['mobile_subscriptions_delete'] = del.json; // False positive (LAST — mutates falsePositives collection) const sealed = await sealFire(); const fp = await req('POST', `${API}/mobile/falsepositive`, { token: TOKEN, mobileToken: MOBILE_TOKEN, sealed, type: 'falsealarm' }); snaps['mobile_falsepositive_post'] = scrub(fp.json, ['insertedId']); return snaps; } async function main() { if (!TOKEN) { console.error('No internalApiToken in settings'); process.exit(2); } fs.mkdirSync(SNAP_DIR, { recursive: true }); const snaps = await collect(); let fail = 0; for (const [name, value] of Object.entries(snaps)) { const file = path.join(SNAP_DIR, `${name}.json`); const got = stableStringify(value); if (UPDATE) { fs.writeFileSync(file, `${got}\n`); console.log(` wrote ${name}`); continue; } if (!fs.existsSync(file)) { console.error(` MISSING baseline: ${name}`); fail++; continue; } const want = fs.readFileSync(file, 'utf8').trimEnd(); if (want === got.trimEnd()) { console.log(` PASS ${name}`); } else { fail++; console.error(` FAIL ${name}`); console.error(diff(want, got)); } } if (UPDATE) { console.log('baseline updated'); return; } if (fail) { console.error(`\n${fail} snapshot(s) drifted`); process.exit(1); } console.log('\nAll REST snapshots identical ✔'); } function diff(a, b) { const al = a.split('\n'); const bl = b.split('\n'); const out = []; const n = Math.max(al.length, bl.length); for (let i = 0; i < n; i++) { if (al[i] !== bl[i]) { if (al[i] !== undefined) out.push(` - ${al[i]}`); if (bl[i] !== undefined) out.push(` + ${bl[i]}`); } } return out.slice(0, 40).join('\n'); } main().catch((e) => { console.error(e); process.exit(2); });