todos-contra-el-fuego-web/smoke/run.js
vjrj 296dab4f38 smoke: REST API regression harness + baseline snapshots (Meteor 1.6.1.1)
Adds a standalone Node smoke test (smoke/) that exercises every REST endpoint
consumed by the Flutter app against a seeded local dev server and compares
responses to committed snapshots. This is the safety net for the Meteor upgrade:
it must stay byte-identical after every escala.

- smoke/seed.js: deterministic Mongo seed (fixed ObjectIds, geo indexes)
- smoke/run.js: calls endpoints, normalizes volatile fields, diffs snapshots
- smoke/smoke.sh: seed + run wrapper
- smoke/snapshots/: baseline captured on Meteor 1.6.1.1
- IPGeocoder.js: degrade gracefully when MaxMind DB absent (dev boot)
- settings-development.json: dev-only internalApiToken to enable the REST API
- .meteorignore: exclude smoke/ from the Meteor server build
2026-07-13 19:42:55 +02:00

172 lines
6.4 KiB
JavaScript
Executable file

#!/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); });