Files
worldmonitor/tests/chokepoint-id-mapping.test.mjs
Elie Habib 13bb3ef080 fix(supply-chain): increase Redis timeout for PortWatch and remove content height cap (#1598)
* 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).
2026-03-14 23:27:27 +04:00

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);
});
});