mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* fix(supply-chain): increase Redis timeout for PortWatch and remove content height cap Root cause: getCachedJson has a 1500ms timeout, but the PortWatch payload (~149KB for 13 chokepoints x 175 days) exceeds this on high-latency Edge regions. The fetch silently times out and returns null, so the handler builds responses with empty transit summaries. Fix: add optional timeoutMs param to getCachedJson, use 5000ms for the PortWatch fetch. Also remove the 300px max-height on .economic-content so the Supply Chain panel fills available height. * refactor(supply-chain): move transit summary assembly to Railway relay Vercel Edge was reading 3 large Redis keys (PortWatch 149KB, transit counts, CorridorRisk) and assembling transit summaries on every request. The 1500ms Redis timeout caused the 149KB PortWatch fetch to silently fail on high-latency Edge regions (Mumbai bom1), leaving all transit data empty. Now Railway builds the pre-assembled transit summaries (including anomaly detection) and writes them to a single key. Vercel reads ONE small pre-built key instead of 3 raw keys. Flow: Railway seeds PortWatch + transit counts -> builds summaries -> writes supply_chain:transit-summaries:v1 -> Vercel reads it. This follows the gold standard: "Vercel reads Redis ONLY; Railway makes ALL external API calls and data assembly." * test(supply-chain): add sync tests for relay threat levels and name mappings detectTrafficAnomalyRelay and CHOKEPOINT_THREAT_LEVELS in the relay are duplicated from _scoring.mjs and get-chokepoint-status.ts because ais-relay.cjs is CJS. Added sync tests that validate: - Every canonical chokepoint has a relay threat level - Relay threat levels match handler CHOKEPOINTS config - RELAY_NAME_TO_ID covers all canonical chokepoints This catches drift between the two source-of-truth files. * fix(ui): restore bounded scroll on economic-content with flex layout The previous fix replaced max-height: 300px with flex: 1 1 auto, but .panel-content was not a flex container so the flex rule was ignored. This caused tabs to scroll away with the content. Fix: use :has(.economic-content) to make .panel-content a flex column only for panels with tabbed economic content. Tabs stay pinned, content area scrolls independently. * feat(supply-chain): fix CorridorRisk API integration (open beta, no key needed) The CorridorRisk API is in open beta at corridorrisk.io/api/corridors (not api.corridorrisk.io/v1/corridors). No API key required during beta. Changes: - Fix URL to corridorrisk.io/api/corridors - Remove API key requirement (open beta) - Update name matching for actual API names (e.g. "Persian Gulf & Strait of Hormuz" -> hormuz_strait) - Derive riskLevel from score (>=70 critical, >=50 high, etc.) - Store riskScore, vesselCount, eventCount7d, riskSummary - Feed CorridorRisk data into transit summaries * test(supply-chain): comprehensive transit summary integration tests 75 tests across 10 suites covering: - Relay seedTransitSummaries assembly (Redis key, fields, triggers) - CorridorRisk name mapping and risk level derivation from score - Handler reads pre-built summaries (not raw upstream keys) - Handler isolation: no PortWatchData/CorridorRiskData/CANONICAL_CHOKEPOINTS imports - detectTrafficAnomalyRelay sync with _scoring.mjs (side-by-side execution) - detectTrafficAnomaly edge cases (boundaries, threat levels, unsorted history) - CHOKEPOINT_THREAT_LEVELS relay-handler sync validation * fix(supply-chain): hydrate transit summaries from Redis on relay restart After relay restart, latestPortwatchData and latestCorridorRiskData are null. The initial seedTransitSummaries call (35s after boot) would return early with no data, leaving the transit-summaries:v1 key stale until the next PortWatch seed completes (6+ seconds later). Fix: seedTransitSummaries now reads persisted PortWatch and CorridorRisk data from Redis when in-memory state is empty. This covers the cold-start gap so Vercel always has fresh transit summaries. Also adds 5 tests validating the hydration path order and assignment. * fix(supply-chain): add fallback to raw Redis keys when pre-built summaries are empty P1: If supply_chain:transit-summaries:v1 is absent (relay not deployed, restart in progress, or transient PortWatch failure), the handler now falls back to reading the raw portwatch, corridorrisk, and transit count keys directly and assembling summaries on the fly. This ensures corridor risk data (riskLevel, incidentCount7d, disruptionPct) is never silently zeroed out, and users keep history/counts even during the 6-hour PortWatch re-seed window. Strategy: pre-built summaries (fast path) -> raw keys fallback (slow path) -> all-zero defaults (last resort).
121 lines
4.4 KiB
JavaScript
121 lines
4.4 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
CANONICAL_CHOKEPOINTS,
|
|
relayNameToId,
|
|
portwatchNameToId,
|
|
corridorRiskNameToId,
|
|
} from '../server/worldmonitor/supply-chain/v1/_chokepoint-ids.ts';
|
|
|
|
describe('CANONICAL_CHOKEPOINTS registry', () => {
|
|
it('contains exactly 13 canonical chokepoints', () => {
|
|
assert.equal(CANONICAL_CHOKEPOINTS.length, 13);
|
|
});
|
|
|
|
it('has no duplicate IDs', () => {
|
|
const ids = CANONICAL_CHOKEPOINTS.map(c => c.id);
|
|
assert.equal(new Set(ids).size, ids.length);
|
|
});
|
|
|
|
it('has no duplicate relay names', () => {
|
|
const names = CANONICAL_CHOKEPOINTS.map(c => c.relayName);
|
|
assert.equal(new Set(names).size, names.length);
|
|
});
|
|
|
|
it('has no duplicate portwatch names (excluding empty)', () => {
|
|
const names = CANONICAL_CHOKEPOINTS.map(c => c.portwatchName).filter(n => n);
|
|
assert.equal(new Set(names).size, names.length);
|
|
});
|
|
|
|
it('Bosphorus has relayName "Bosporus Strait"', () => {
|
|
const bos = CANONICAL_CHOKEPOINTS.find(c => c.id === 'bosphorus');
|
|
assert.equal(bos.relayName, 'Bosporus Strait');
|
|
});
|
|
});
|
|
|
|
describe('relayNameToId', () => {
|
|
it('maps "Strait of Hormuz" to hormuz_strait', () => {
|
|
assert.equal(relayNameToId('Strait of Hormuz'), 'hormuz_strait');
|
|
});
|
|
|
|
it('returns undefined for unknown relay name', () => {
|
|
assert.equal(relayNameToId('unknown'), undefined);
|
|
});
|
|
});
|
|
|
|
describe('portwatchNameToId', () => {
|
|
it('maps "Suez Canal" to suez', () => {
|
|
assert.equal(portwatchNameToId('Suez Canal'), 'suez');
|
|
});
|
|
|
|
it('maps actual PortWatch feed names correctly', () => {
|
|
assert.equal(portwatchNameToId('Malacca Strait'), 'malacca_strait');
|
|
assert.equal(portwatchNameToId('Bab el-Mandeb Strait'), 'bab_el_mandeb');
|
|
assert.equal(portwatchNameToId('Gibraltar Strait'), 'gibraltar');
|
|
assert.equal(portwatchNameToId('Bosporus Strait'), 'bosphorus');
|
|
assert.equal(portwatchNameToId('Korea Strait'), 'korea_strait');
|
|
assert.equal(portwatchNameToId('Dover Strait'), 'dover_strait');
|
|
assert.equal(portwatchNameToId('Kerch Strait'), 'kerch_strait');
|
|
assert.equal(portwatchNameToId('Lombok Strait'), 'lombok_strait');
|
|
});
|
|
|
|
it('returns undefined for empty string', () => {
|
|
assert.equal(portwatchNameToId(''), undefined);
|
|
});
|
|
|
|
it('is case-insensitive', () => {
|
|
assert.equal(portwatchNameToId('suez canal'), 'suez');
|
|
assert.equal(portwatchNameToId('MALACCA STRAIT'), 'malacca_strait');
|
|
});
|
|
});
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
const relaySrc = readFileSync('scripts/ais-relay.cjs', 'utf8');
|
|
const handlerSrc = readFileSync('server/worldmonitor/supply-chain/v1/get-chokepoint-status.ts', 'utf8');
|
|
|
|
describe('relay CHOKEPOINT_THREAT_LEVELS sync', () => {
|
|
|
|
it('relay has a threat level entry for every canonical chokepoint', () => {
|
|
for (const cp of CANONICAL_CHOKEPOINTS) {
|
|
assert.match(relaySrc, new RegExp(`${cp.id}:\\s*'`), `Missing relay threat level for ${cp.id}`);
|
|
}
|
|
});
|
|
|
|
it('relay threat levels match handler CHOKEPOINTS config', () => {
|
|
const relayBlock = relaySrc.match(/CHOKEPOINT_THREAT_LEVELS\s*=\s*\{([^}]+)\}/)?.[1] || '';
|
|
for (const cp of CANONICAL_CHOKEPOINTS) {
|
|
const relayMatch = relayBlock.match(new RegExp(`${cp.id}:\\s*'(\\w+)'`));
|
|
const handlerMatch = handlerSrc.match(new RegExp(`id:\\s*'${cp.id}'[^}]*threatLevel:\\s*'(\\w+)'`));
|
|
if (relayMatch && handlerMatch) {
|
|
assert.equal(relayMatch[1], handlerMatch[1], `Threat level mismatch for ${cp.id}: relay=${relayMatch[1]} handler=${handlerMatch[1]}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('relay RELAY_NAME_TO_ID covers all canonical chokepoints', () => {
|
|
for (const cp of CANONICAL_CHOKEPOINTS) {
|
|
assert.match(relaySrc, new RegExp(`'${cp.relayName}':\\s*'${cp.id}'`), `Missing relay name mapping for ${cp.relayName} -> ${cp.id}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('corridorRiskNameToId', () => {
|
|
it('maps "Hormuz" to hormuz_strait', () => {
|
|
assert.equal(corridorRiskNameToId('Hormuz'), 'hormuz_strait');
|
|
});
|
|
|
|
it('returns undefined for unmapped names', () => {
|
|
assert.equal(corridorRiskNameToId('Nonexistent'), undefined);
|
|
});
|
|
|
|
it('Gibraltar has null corridorRiskName', () => {
|
|
const gib = CANONICAL_CHOKEPOINTS.find(c => c.id === 'gibraltar');
|
|
assert.equal(gib.corridorRiskName, null);
|
|
});
|
|
|
|
it('Bosphorus has null corridorRiskName', () => {
|
|
const bos = CANONICAL_CHOKEPOINTS.find(c => c.id === 'bosphorus');
|
|
assert.equal(bos.corridorRiskName, null);
|
|
});
|
|
});
|