diff --git a/.meteorignore b/.meteorignore index b25101c..c013f6f 100644 --- a/.meteorignore +++ b/.meteorignore @@ -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 diff --git a/imports/startup/server/IPGeocoder.js b/imports/startup/server/IPGeocoder.js index 96c5ef1..59a99af 100644 --- a/imports/startup/server/IPGeocoder.js +++ b/imports/startup/server/IPGeocoder.js @@ -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 diff --git a/smoke/README.md b/smoke/README.md new file mode 100644 index 0000000..84402cf --- /dev/null +++ b/smoke/README.md @@ -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. diff --git a/smoke/run.js b/smoke/run.js new file mode 100755 index 0000000..82650ba --- /dev/null +++ b/smoke/run.js @@ -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); }); diff --git a/smoke/seed.js b/smoke/seed.js new file mode 100644 index 0000000..2268c5f --- /dev/null +++ b/smoke/seed.js @@ -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/ })); diff --git a/smoke/smoke.sh b/smoke/smoke.sh new file mode 100755 index 0000000..92854ec --- /dev/null +++ b/smoke/smoke.sh @@ -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 "$@" diff --git a/smoke/snapshots/fires_in.json b/smoke/snapshots/fires_in.json new file mode 100644 index 0000000..1a871d2 --- /dev/null +++ b/smoke/snapshots/fires_in.json @@ -0,0 +1,4 @@ +{ + "real": 2, + "total": 2 +} diff --git a/smoke/snapshots/fires_in_full.json b/smoke/snapshots/fires_in_full.json new file mode 100644 index 0000000..e4ee439 --- /dev/null +++ b/smoke/snapshots/fires_in_full.json @@ -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 +} diff --git a/smoke/snapshots/mobile_falsepositive_post.json b/smoke/snapshots/mobile_falsepositive_post.json new file mode 100644 index 0000000..710da50 --- /dev/null +++ b/smoke/snapshots/mobile_falsepositive_post.json @@ -0,0 +1,9 @@ +{ + "data": { + "upsert": { + "insertedId": "", + "numberAffected": 1 + } + }, + "status": "success" +} diff --git a/smoke/snapshots/mobile_subscriptions_all.json b/smoke/snapshots/mobile_subscriptions_all.json new file mode 100644 index 0000000..1ea5c99 --- /dev/null +++ b/smoke/snapshots/mobile_subscriptions_all.json @@ -0,0 +1,29 @@ +{ + "data": { + "count": 1, + "subscriptions": [ + { + "_id": { + "_str": "d0000000000000000000000d" + }, + "createdAt": "", + "distance": 40, + "geo": { + "coordinates": [ + -3.7, + 40.41 + ], + "type": "Point" + }, + "location": { + "lat": 40.41, + "lon": -3.7 + }, + "owner": "", + "type": "mobile", + "updatedAt": "" + } + ] + }, + "status": "success" +} diff --git a/smoke/snapshots/mobile_subscriptions_delete.json b/smoke/snapshots/mobile_subscriptions_delete.json new file mode 100644 index 0000000..8adacc8 --- /dev/null +++ b/smoke/snapshots/mobile_subscriptions_delete.json @@ -0,0 +1,4 @@ +{ + "data": {}, + "status": "success" +} diff --git a/smoke/snapshots/mobile_subscriptions_post.json b/smoke/snapshots/mobile_subscriptions_post.json new file mode 100644 index 0000000..a9d088f --- /dev/null +++ b/smoke/snapshots/mobile_subscriptions_post.json @@ -0,0 +1,6 @@ +{ + "data": { + "subsId": "d0000000000000000000000d" + }, + "status": "success" +} diff --git a/smoke/snapshots/mobile_users_post.json b/smoke/snapshots/mobile_users_post.json new file mode 100644 index 0000000..43a236f --- /dev/null +++ b/smoke/snapshots/mobile_users_post.json @@ -0,0 +1,13 @@ +{ + "data": { + "lang": "es", + "mobileToken": "smoke-mobile-token", + "upsertResult": { + "insertedId": "", + "numberAffected": 1 + }, + "userId": "", + "username": "" + }, + "status": "success" +} diff --git a/smoke/snapshots/status_active_fires_count.json b/smoke/snapshots/status_active_fires_count.json new file mode 100644 index 0000000..6743747 --- /dev/null +++ b/smoke/snapshots/status_active_fires_count.json @@ -0,0 +1,3 @@ +{ + "total": 2 +} diff --git a/smoke/snapshots/status_last_fire_check.json b/smoke/snapshots/status_last_fire_check.json new file mode 100644 index 0000000..eac0763 --- /dev/null +++ b/smoke/snapshots/status_last_fire_check.json @@ -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" +} diff --git a/smoke/snapshots/status_last_fire_detected.json b/smoke/snapshots/status_last_fire_detected.json new file mode 100644 index 0000000..b7c7d53 --- /dev/null +++ b/smoke/snapshots/status_last_fire_detected.json @@ -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" +} diff --git a/smoke/snapshots/status_subs_public_union.json b/smoke/snapshots/status_subs_public_union.json new file mode 100644 index 0000000..3eb57ce --- /dev/null +++ b/smoke/snapshots/status_subs_public_union.json @@ -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" +} diff --git a/smoke/snapshots/status_uptime.json b/smoke/snapshots/status_uptime.json new file mode 100644 index 0000000..60ee143 --- /dev/null +++ b/smoke/snapshots/status_uptime.json @@ -0,0 +1,3 @@ +{ + "ms": "" +}