mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(health): heal portwatch-disruptions + three stale-registry false alarms * fix(resilience): log Upstash non-2xx when writing ranking seed-meta fetch() doesn't throw on HTTP errors, so a 401/429/500 from Upstash would be treated as success — the new meta write would fail silently and /api/health would keep alerting with no diagnostic log. Check resp.ok explicitly and log status + body snippet on failure. Greptile review catch on #3051. * fix(health): sync seed-health.js portwatch cadence with api/health.js (WEEK) Companion fix to the same logical bug on api/health.js: api/seed-health.js still read 'portwatch:chokepoints-ref' as a daily cron (intervalMin 1440), so its stale threshold (intervalMin*2 = 48h) would still flag a false stale even though api/health.js was updated to 14d. Both endpoints now agree at 14d for a WEEK-cadence seeder. Greptile review catch on #3051.
209 lines
8.1 KiB
JavaScript
209 lines
8.1 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { dirname, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, '..');
|
|
|
|
const src = readFileSync(resolve(root, 'scripts/seed-portwatch-disruptions.mjs'), 'utf-8');
|
|
const flowsSrc = readFileSync(resolve(root, 'scripts/seed-chokepoint-flows.mjs'), 'utf-8');
|
|
|
|
// ── seeder source assertions ──────────────────────────────────────────────────
|
|
|
|
describe('seed-portwatch-disruptions.mjs exports', () => {
|
|
it('exports fetchAll', () => {
|
|
assert.match(src, /export\s+async\s+function\s+fetchAll/);
|
|
});
|
|
|
|
// Regression guard: ArcGIS SQL parser rejects bare epoch-ms integers against
|
|
// Date-typed fields with "Cannot perform query. Invalid query parameters."
|
|
// The WHERE clause must use `timestamp '...'` literals, produced by
|
|
// toArcgisTimestamp(). Reverting to a raw ${sinceEpoch} breaks every run.
|
|
it('WHERE clause uses ArcGIS timestamp literal, not raw epoch-ms', () => {
|
|
assert.match(src, /todate\s*>\s*timestamp\s*'\$\{sinceSql\}'/, 'WHERE clause must compare against an ArcGIS timestamp literal');
|
|
assert.doesNotMatch(src, /todate\s*>\s*\$\{sinceEpoch\}/, 'WHERE clause must NOT compare against a raw epoch-ms integer');
|
|
});
|
|
|
|
it('toArcgisTimestamp formats epoch-ms as "YYYY-MM-DD HH:MM:SS"', async () => {
|
|
const mod = await import('../scripts/seed-portwatch-disruptions.mjs');
|
|
const formatted = mod.toArcgisTimestamp(Date.UTC(2026, 3, 13, 8, 30, 45)); // 2026-04-13T08:30:45Z
|
|
assert.equal(formatted, '2026-04-13 08:30:45');
|
|
});
|
|
|
|
it('exports validateFn', () => {
|
|
assert.match(src, /export\s+function\s+validateFn/);
|
|
});
|
|
|
|
it('writes to portwatch:disruptions:active:v1', () => {
|
|
assert.match(src, /portwatch:disruptions:active:v1/);
|
|
});
|
|
|
|
it('uses ArcGIS portwatch_disruptions_database endpoint', () => {
|
|
assert.match(src, /portwatch_disruptions_database.*FeatureServer/);
|
|
});
|
|
|
|
it('fetches 30 days of recent + active events (including NULL todate)', () => {
|
|
assert.match(src, /DAYS_BACK\s*=\s*30/);
|
|
assert.match(src, /todate > .* OR todate IS NULL/);
|
|
});
|
|
|
|
it('has TTL of 7200 (2 hours)', () => {
|
|
assert.match(src, /7[_\s]*200/);
|
|
});
|
|
|
|
it('extracts eventId, eventType, eventName, alertLevel, lat, lon fields', () => {
|
|
assert.match(src, /eventId/);
|
|
assert.match(src, /eventType/);
|
|
assert.match(src, /alertLevel/);
|
|
assert.match(src, /a\.lat/);
|
|
assert.match(src, /a\.long/);
|
|
});
|
|
|
|
it('parses affectedPorts from comma-separated string', () => {
|
|
assert.match(src, /split\(','\)/);
|
|
});
|
|
|
|
it('computes active flag (todate in future or null)', () => {
|
|
assert.match(src, /active:/);
|
|
assert.match(src, /a\.todate > now/);
|
|
});
|
|
|
|
it('wraps runSeed in isMain guard', () => {
|
|
assert.match(src, /isMain.*=.*process\.argv/s);
|
|
assert.match(src, /if\s*\(isMain\)/);
|
|
});
|
|
});
|
|
|
|
describe('seed-chokepoint-flows.mjs hazard integration', () => {
|
|
it('reads portwatch:disruptions:active:v1 key', () => {
|
|
assert.match(flowsSrc, /portwatch:disruptions:active:v1/);
|
|
});
|
|
|
|
it('reads disruptions in parallel with other keys', () => {
|
|
assert.match(flowsSrc, /DISRUPTIONS_KEY/);
|
|
});
|
|
|
|
it('only matches RED and ORANGE alerts for hazard badge', () => {
|
|
assert.match(flowsSrc, /alertLevel.*RED/s);
|
|
assert.match(flowsSrc, /alertLevel.*ORANGE/s);
|
|
});
|
|
|
|
it('uses haversine 500km radius for hazard matching', () => {
|
|
assert.match(flowsSrc, /HAZARD_RADIUS_KM\s*=\s*500/);
|
|
assert.match(flowsSrc, /haversineKm/);
|
|
});
|
|
|
|
it('CHOKEPOINT_MAP entries include lat/lon coordinates', () => {
|
|
assert.match(flowsSrc, /hormuz.*lat:\s*26\.\d+/s);
|
|
assert.match(flowsSrc, /babelm.*lat:\s*12\.\d+/s);
|
|
});
|
|
|
|
it('disruptions read is non-fatal (catch → null)', () => {
|
|
assert.match(flowsSrc, /DISRUPTIONS_KEY.*catch.*null/s);
|
|
});
|
|
});
|
|
|
|
// ── unit tests for disruption event parsing ───────────────────────────────────
|
|
|
|
function parseAffectedPorts(raw) {
|
|
if (!raw) return [];
|
|
return String(raw).split(',').map(s => s.trim()).filter(Boolean);
|
|
}
|
|
|
|
function isActive(todateMs, now) {
|
|
return !todateMs || todateMs > now;
|
|
}
|
|
|
|
describe('disruption event parsing', () => {
|
|
it('parses comma-separated affectedPorts correctly', () => {
|
|
assert.deepEqual(parseAffectedPorts('port137,port138, port139'), ['port137', 'port138', 'port139']);
|
|
});
|
|
|
|
it('returns empty array for null affectedPorts', () => {
|
|
assert.deepEqual(parseAffectedPorts(null), []);
|
|
assert.deepEqual(parseAffectedPorts(''), []);
|
|
});
|
|
|
|
it('marks event as active when todate is in the future', () => {
|
|
assert.equal(isActive(Date.now() + 86400_000, Date.now()), true);
|
|
});
|
|
|
|
it('marks event as inactive when todate is in the past', () => {
|
|
assert.equal(isActive(Date.now() - 86400_000, Date.now()), false);
|
|
});
|
|
|
|
it('marks event as active when todate is null (no end date)', () => {
|
|
assert.equal(isActive(null, Date.now()), true);
|
|
});
|
|
});
|
|
|
|
// ── haversine + hazard matching ────────────────────────────────────────────────
|
|
|
|
function haversineKm(lat1, lon1, lat2, lon2) {
|
|
const R = 6371;
|
|
const toRad = d => d * Math.PI / 180;
|
|
const dLat = toRad(lat2 - lat1);
|
|
const dLon = toRad(lon2 - lon1);
|
|
const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2;
|
|
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
}
|
|
|
|
function findNearestHazard(events, cpLat, cpLon, radiusKm = 500) {
|
|
if (!Array.isArray(events)) return null;
|
|
let best = null;
|
|
let bestDist = radiusKm;
|
|
for (const ev of events) {
|
|
if (ev.alertLevel !== 'RED' && ev.alertLevel !== 'ORANGE') continue;
|
|
if (!ev.active) continue;
|
|
const dist = haversineKm(cpLat, cpLon, ev.lat, ev.lon);
|
|
if (dist < bestDist) { bestDist = dist; best = ev; }
|
|
}
|
|
return best;
|
|
}
|
|
|
|
describe('hazard matching', () => {
|
|
const hormuzLat = 26.56;
|
|
const hormuzLon = 56.25;
|
|
|
|
it('matches RED event within 500km of chokepoint', () => {
|
|
const events = [{ alertLevel: 'RED', active: true, lat: 25.0, lon: 57.0, eventName: 'CYCLONE-X' }];
|
|
const dist = haversineKm(hormuzLat, hormuzLon, 25.0, 57.0);
|
|
assert.ok(dist < 500, `event should be within 500km (${dist.toFixed(1)}km)`);
|
|
assert.ok(findNearestHazard(events, hormuzLat, hormuzLon) !== null);
|
|
});
|
|
|
|
it('does NOT match event beyond 500km', () => {
|
|
// Mumbai: ~1600km from Hormuz
|
|
const events = [{ alertLevel: 'RED', active: true, lat: 19.0, lon: 72.8, eventName: 'FAR-FLOOD' }];
|
|
const dist = haversineKm(hormuzLat, hormuzLon, 19.0, 72.8);
|
|
assert.ok(dist > 500, `event should be beyond 500km (${dist.toFixed(1)}km)`);
|
|
assert.equal(findNearestHazard(events, hormuzLat, hormuzLon), null);
|
|
});
|
|
|
|
it('does NOT match YELLOW or GREEN alerts', () => {
|
|
const events = [{ alertLevel: 'YELLOW', active: true, lat: 25.0, lon: 57.0, eventName: 'YELLOW-EV' }];
|
|
assert.equal(findNearestHazard(events, hormuzLat, hormuzLon), null);
|
|
});
|
|
|
|
it('does NOT match inactive (ended) events', () => {
|
|
const events = [{ alertLevel: 'RED', active: false, lat: 25.0, lon: 57.0, eventName: 'OLD-EV' }];
|
|
assert.equal(findNearestHazard(events, hormuzLat, hormuzLon), null);
|
|
});
|
|
|
|
it('returns closest RED/ORANGE event when multiple qualify', () => {
|
|
const events = [
|
|
{ alertLevel: 'ORANGE', active: true, lat: 26.0, lon: 56.5, eventName: 'CLOSE-FLOOD' },
|
|
{ alertLevel: 'RED', active: true, lat: 24.0, lon: 58.0, eventName: 'FAR-STORM' },
|
|
];
|
|
const result = findNearestHazard(events, hormuzLat, hormuzLon);
|
|
assert.equal(result?.eventName, 'CLOSE-FLOOD', 'should return closest qualifying event');
|
|
});
|
|
|
|
it('returns null when events array is empty or absent', () => {
|
|
assert.equal(findNearestHazard([], hormuzLat, hormuzLon), null);
|
|
assert.equal(findNearestHazard(null, hormuzLat, hormuzLon), null);
|
|
});
|
|
});
|