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
This commit is contained in:
vjrj 2026-07-13 19:42:55 +02:00
parent 39415808bf
commit 296dab4f38
18 changed files with 539 additions and 4 deletions

View file

@ -1,3 +1,5 @@
# Don't reload meteor server when we modify or do test
cucumber
output.json
# REST smoke-test harness runs standalone (Node), never as Meteor server code
smoke

View file

@ -21,11 +21,16 @@ function isPrivateIP(ip) {
const dbpath = '/usr/local/share/maxmind-geolite2/GeoLite2-City.mmdb';
// const dbpath = `${process.env.PWD}/private/GeoLite2-City.mmdb`;
if (!fs.existsSync(dbpath)) {
let IPGeocoder;
if (fs.existsSync(dbpath)) {
IPGeocoder = maxmind.openSync(dbpath);
} else {
// In production the DB is provisioned via cron; in local dev it may be
// absent. Degrade gracefully instead of crashing at boot: localize() below
// already falls back to a default location when no geo data is available.
console.error(`Maxmind db not found ${dbpath}, download via cron with https://www.npmjs.com/package/maxmind-geolite2-mirror`);
IPGeocoder = { get() { return null; } };
}
const IPGeocoder = maxmind.openSync(dbpath);
export default IPGeocoder;
// Warning: Meteor cannot access to this.connection with arrow functions
@ -47,7 +52,7 @@ export function localize() {
// http://dev.maxmind.com/geoip/geoip2/geolite2/
const geo = IPGeocoder.get(clientIP);
// console.warn(geo);
if (geo.location && geo.location.latitude && geo.location.longitude) {
if (geo && geo.location && geo.location.latitude && geo.location.longitude) {
return geo;
}
// geoIP fallback, Madrid

73
smoke/README.md Normal file
View file

@ -0,0 +1,73 @@
# REST smoke test
Regression harness for the REST API in `imports/api/Rest/Rest.js` — the API the
Flutter app (`fires_flutter/lib/models/fires_api.dart`) depends on.
It calls every Flutter-consumed endpoint against a locally running dev server
with deterministic seed data, normalizes the intrinsically volatile fields
(uptime, generated ids, insert timestamps) and compares the responses to
committed snapshots in `smoke/snapshots/`.
**It must stay green after every Meteor upgrade escala. The snapshots are the
contract: any drift is treated as an upgrade bug.**
## Reproducible local startup
1. **MongoDB** (prod parity = 3.2; a dev-only instance in Docker):
```bash
docker run -d --name tcef-mongo32 -p 27018:27017 mongo:3.2
```
2. **Meteor dev server** (from the repo root):
```bash
export PATH="$HOME/.meteor:$PATH"
export MONGO_URL="mongodb://localhost:27018/fuegos"
meteor --settings settings-development.json --port 3100
```
`settings-development.json` carries a dev-only `private.internalApiToken`
(`dev-smoke-token`) which is what enables the REST API. Without it the API
routes are not even registered (see `Rest.js`).
> Note: `IPGeocoder.js` degrades gracefully when the MaxMind GeoLite2 DB is
> absent (it is provisioned by cron in production) so the server boots in a
> clean checkout.
## Running the smoke test
```bash
./smoke/smoke.sh # seed + compare against baseline snapshots
./smoke/smoke.sh --update # seed + regenerate the baseline snapshots
```
Env overrides: `MONGO_CONTAINER` (default `tcef-mongo32`), `MONGO_DB`
(`fuegos`), `BASE_URL` (`http://localhost:3100`).
## What is covered
| Snapshot | Endpoint | Flutter call |
|---|---|---|
| `status_uptime` | `GET status/uptime` | — |
| `status_active_fires_count` | `GET status/active-fires-count` | — |
| `status_last_fire_check` | `GET status/last-fire-check` | — |
| `status_last_fire_detected` | `GET status/last-fire-detected` | — |
| `status_subs_public_union` | `GET status/subs-public-union/:token` | `getMonitoredAreas` |
| `fires_in` | `GET fires-in/:token/:lat/:lng/:km` | — |
| `fires_in_full` | `GET fires-in-full/:token/:lat/:lng/:km` | `getFiresInLocation` |
| `mobile_users_post` | `POST mobile/users` | `createUser` |
| `mobile_subscriptions_post` | `POST mobile/subscriptions` | `subscribe` |
| `mobile_subscriptions_all` | `GET mobile/subscriptions/all/:token/:mobileToken` | `fetchYourLocations` |
| `mobile_subscriptions_delete` | `DELETE mobile/subscriptions/:token/:mobileToken/:subsId` | `unsubscribe` |
| `mobile_falsepositive_post` | `POST mobile/falsepositive` | `markFalsePositive` |
## Normalized (volatile) fields
Compared structurally but blanked to a placeholder because they cannot be
deterministic: `status/uptime``ms`; `mobile/users``userId`, `username`,
`upsertResult.insertedId`; `mobile/subscriptions/all``owner`, `createdAt`,
`updatedAt`; `mobile/falsepositive``upsert.insertedId`.
Everything else (ids of seeded docs, geometries, counts, jsend envelope, field
names and types) is asserted byte-for-byte.

172
smoke/run.js Executable file
View file

@ -0,0 +1,172 @@
#!/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); });

96
smoke/seed.js Normal file
View file

@ -0,0 +1,96 @@
/*
* Deterministic seed for the REST smoke test.
* Run against the local Mongo used by the dev Meteor server, e.g.:
* docker exec -i tcef-mongo32 mongo fuegos < smoke/seed.js
*
* It resets the collections the REST API reads/writes to a fixed, known state
* so that endpoint responses are reproducible across Meteor upgrade escalas.
* Only test-owned documents are touched.
*/
// --- Fixed identifiers (24-hex ObjectIds) -------------------------------
var FIRE_A = ObjectId('a0000000000000000000000a');
var FIRE_B = ObjectId('b0000000000000000000000b');
var ARCHIVE_FIRE = ObjectId('c0000000000000000000000c');
var MOBILE_TOKEN = 'smoke-mobile-token';
// Madrid-ish coordinates
var LAT = 40.41;
var LON = -3.70;
var WHEN = ISODate('2024-01-01T00:00:00.000Z');
var WHEN_B = ISODate('2024-01-02T00:00:00.000Z'); // later, so last-fire-detected is deterministic
var FIXED = ISODate('2024-01-01T00:00:00.000Z');
// --- activefires --------------------------------------------------------
db.activefires.deleteMany({});
db.activefires.insertMany([
{
_id: FIRE_A,
ourid: { type: 'Point', coordinates: [LON, LAT] },
lat: LAT, lon: LON, type: 'viirs', when: WHEN,
scan: 0.5, track: 0.4, acq_date: '2024-01-01', acq_time: '00:00',
satellite: 'N', confidence: 50, version: '1.0NRT', frp: 4.3,
daynight: 'N', bright_ti4: 330.2, bright_ti5: 291,
createdAt: FIXED, updatedAt: FIXED
},
{
_id: FIRE_B,
ourid: { type: 'Point', coordinates: [LON + 0.02, LAT + 0.02] },
lat: LAT + 0.02, lon: LON + 0.02, type: 'viirs', when: WHEN_B,
scan: 0.5, track: 0.4, acq_date: '2024-01-01', acq_time: '00:05',
satellite: 'N', confidence: 60, version: '1.0NRT', frp: 6.1,
daynight: 'N', bright_ti4: 331.0, bright_ti5: 292,
createdAt: FIXED, updatedAt: FIXED
}
]);
db.activefires.createIndex({ ourid: '2dsphere' });
// --- fires (archive) — must match the sealed fire used by mobile/falsepositive
db.fires.deleteMany({});
db.fires.insertOne({
_id: ARCHIVE_FIRE,
ourid: { type: 'Point', coordinates: [LON, LAT] },
lat: LAT, lon: LON, type: 'viirs', when: WHEN,
scan: 0.5, track: 0.4, address: 'Madrid, Spain',
createdAt: FIXED, updatedAt: FIXED
});
db.fires.createIndex({ ourid: '2dsphere' });
// --- falsePositives / industries: empty so "real fire" counting is stable
db.falsePositives.deleteMany({});
db.industries.deleteMany({});
db.industries.createIndex({ geo: '2dsphere' });
// --- subscriptions: clean; index for geo queries
db.subscriptions.deleteMany({});
db.subscriptions.createIndex({ geo: '2dsphere' });
// --- siteSettings consumed by status endpoints --------------------------
db.siteSettings.deleteMany({ name: { $in: ['last-fire-check', 'subs-public-union', 'subs-public-union-bounds'] } });
db.siteSettings.insertMany([
{ _id: ObjectId('51e00000000000000000000c'), name: 'last-fire-check', value: FIXED, createdAt: FIXED, updatedAt: FIXED },
{
_id: ObjectId('51e0000000000000000000a1'),
name: 'subs-public-union',
value: JSON.stringify({
type: 'Feature',
geometry: {
type: 'MultiPolygon',
coordinates: [[[[LON - 1, LAT - 1], [LON + 1, LAT - 1], [LON + 1, LAT + 1], [LON - 1, LAT + 1], [LON - 1, LAT - 1]]]]
}
}),
createdAt: FIXED, updatedAt: FIXED
},
{
_id: ObjectId('51e0000000000000000000b2'),
name: 'subs-public-union-bounds',
value: JSON.stringify({ ne: { lat: LAT + 1, lon: LON + 1 }, sw: { lat: LAT - 1, lon: LON - 1 } }),
createdAt: FIXED, updatedAt: FIXED
}
]);
// --- users: remove any previous smoke user so mobile/users creates fresh
db.users.deleteMany({ fireBaseToken: MOBILE_TOKEN });
print('seed OK: activefires=' + db.activefires.count() + ' fires=' + db.fires.count() + ' siteSettings=' + db.siteSettings.count({ name: /fire|union/ }));

25
smoke/smoke.sh Executable file
View file

@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Seed the dev Mongo deterministically and run the REST smoke test.
#
# smoke/smoke.sh # compare against committed baseline snapshots
# smoke/smoke.sh --update # (re)generate the baseline snapshots
#
# Env overrides:
# MONGO_CONTAINER docker container running the dev Mongo (default tcef-mongo32)
# MONGO_DB database name (default fuegos)
# BASE_URL dev server URL (default http://localhost:3100)
set -euo pipefail
cd "$(dirname "$0")/.."
MONGO_CONTAINER="${MONGO_CONTAINER:-tcef-mongo32}"
MONGO_DB="${MONGO_DB:-fuegos}"
echo "== Seeding ${MONGO_DB} in ${MONGO_CONTAINER} =="
# Copy the script in and run it as a whole file: the mongo shell evaluates a
# file argument atomically (piping via stdin is line-by-line and breaks on
# multi-line comments / var scope).
docker cp smoke/seed.js "${MONGO_CONTAINER}:/tmp/tcef-seed.js"
docker exec "${MONGO_CONTAINER}" mongo "${MONGO_DB}" /tmp/tcef-seed.js | tail -1
echo "== Running REST smoke test against ${BASE_URL:-http://localhost:3100} =="
node smoke/run.js "$@"

View file

@ -0,0 +1,4 @@
{
"real": 2,
"total": 2
}

View file

@ -0,0 +1,30 @@
{
"falsePos": [],
"fires": [
{
"_id": {
"_str": "a0000000000000000000000a"
},
"lat": 40.41,
"lon": -3.7,
"scan": 0.5,
"track": 0.4,
"type": "viirs",
"when": "2024-01-01T00:00:00.000Z"
},
{
"_id": {
"_str": "b0000000000000000000000b"
},
"lat": 40.43,
"lon": -3.68,
"scan": 0.5,
"track": 0.4,
"type": "viirs",
"when": "2024-01-02T00:00:00.000Z"
}
],
"industries": [],
"real": 2,
"total": 2
}

View file

@ -0,0 +1,9 @@
{
"data": {
"upsert": {
"insertedId": "<insertedId>",
"numberAffected": 1
}
},
"status": "success"
}

View file

@ -0,0 +1,29 @@
{
"data": {
"count": 1,
"subscriptions": [
{
"_id": {
"_str": "d0000000000000000000000d"
},
"createdAt": "<createdAt>",
"distance": 40,
"geo": {
"coordinates": [
-3.7,
40.41
],
"type": "Point"
},
"location": {
"lat": 40.41,
"lon": -3.7
},
"owner": "<owner>",
"type": "mobile",
"updatedAt": "<updatedAt>"
}
]
},
"status": "success"
}

View file

@ -0,0 +1,4 @@
{
"data": {},
"status": "success"
}

View file

@ -0,0 +1,6 @@
{
"data": {
"subsId": "d0000000000000000000000d"
},
"status": "success"
}

View file

@ -0,0 +1,13 @@
{
"data": {
"lang": "es",
"mobileToken": "smoke-mobile-token",
"upsertResult": {
"insertedId": "<insertedId>",
"numberAffected": 1
},
"userId": "<userId>",
"username": "<username>"
},
"status": "success"
}

View file

@ -0,0 +1,3 @@
{
"total": 2
}

View file

@ -0,0 +1,9 @@
{
"_id": {
"_str": "51e00000000000000000000c"
},
"createdAt": "2024-01-01T00:00:00.000Z",
"name": "last-fire-check",
"updatedAt": "2024-01-01T00:00:00.000Z",
"value": "2024-01-01T00:00:00.000Z"
}

View file

@ -0,0 +1,29 @@
{
"_id": {
"_str": "b0000000000000000000000b"
},
"acq_date": "2024-01-01",
"acq_time": "00:05",
"bright_ti4": 331,
"bright_ti5": 292,
"confidence": 60,
"createdAt": "2024-01-01T00:00:00.000Z",
"daynight": "N",
"frp": 6.1,
"lat": 40.43,
"lon": -3.68,
"ourid": {
"coordinates": [
-3.68,
40.43
],
"type": "Point"
},
"satellite": "N",
"scan": 0.5,
"track": 0.4,
"type": "viirs",
"updatedAt": "2024-01-01T00:00:00.000Z",
"version": "1.0NRT",
"when": "2024-01-02T00:00:00.000Z"
}

View file

@ -0,0 +1,23 @@
{
"data": {
"bounds": {
"_id": {
"_str": "51e0000000000000000000b2"
},
"createdAt": "2024-01-01T00:00:00.000Z",
"name": "subs-public-union-bounds",
"updatedAt": "2024-01-01T00:00:00.000Z",
"value": "{\"ne\":{\"lat\":41.41,\"lon\":-2.7},\"sw\":{\"lat\":39.41,\"lon\":-4.7}}"
},
"union": {
"_id": {
"_str": "51e0000000000000000000a1"
},
"createdAt": "2024-01-01T00:00:00.000Z",
"name": "subs-public-union",
"updatedAt": "2024-01-01T00:00:00.000Z",
"value": "{\"type\":\"Feature\",\"geometry\":{\"type\":\"MultiPolygon\",\"coordinates\":[[[[-4.7,39.41],[-2.7,39.41],[-2.7,41.41],[-4.7,41.41],[-4.7,39.41]]]]}}"
}
},
"status": "success"
}

View file

@ -0,0 +1,3 @@
{
"ms": "<ms>"
}