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
172 lines
6.4 KiB
JavaScript
Executable file
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); });
|