Files
worldmonitor/tests/server-handlers.test.mjs
Elie Habib 5c955691a9 feat(energy-atlas): live tanker map layer + contract (parity PR 3, plan U7-U8) (#3402)
* feat(energy-atlas): live tanker map layer + contract (PR 3, plan U7-U8)

Lands the third and final parity-push surface — per-vessel tanker positions
inside chokepoint bounding boxes, refreshed every 60s. Closes the visual
gap with peer reference energy-intel sites for the live AIS tanker view.

Per docs/plans/2026-04-25-003-feat-energy-parity-pushup-plan.md PR 3.
Codex-approved through 8 review rounds against origin/main @ 050073354.

U7 — Contract changes (relay + handler + proto + gateway + rate-limit + test):

- scripts/ais-relay.cjs: parallel `tankerReports` Map populated for AIS
  ship type 80-89 (tanker class) per ITU-R M.1371. SEPARATE from the
  existing `candidateReports` Map (military-only) so the existing
  military-detection consumer's contract stays unchanged. Snapshot
  endpoint extended to accept `bbox=swLat,swLon,neLat,neLon` + `tankers=true`
  query params, with bbox-filtering applied server-side. Tanker reports
  cleaned up on the same retention window as candidate reports; capped
  at 200 per response (10× headroom for global storage).
- proto/worldmonitor/maritime/v1/{get_,}vessel_snapshot.proto:
  - new `bool include_tankers = 6` request field
  - new `repeated SnapshotCandidateReport tanker_reports = 7` response
    field (reuses existing message shape; parallel to candidate_reports)
- server/worldmonitor/maritime/v1/get-vessel-snapshot.ts: REPLACES the
  prior 5-minute `with|without` cache with a request-keyed cache —
  (includeCandidates, includeTankers, quantizedBbox) — at 60s TTL for
  the live-tanker path and 5min TTL for the existing density/disruption
  consumers. Also adds 1° bbox quantization for cache-key reuse and a
  10° max-bbox guard (BboxTooLargeError) to prevent malicious clients
  from pulling all tankers through one query.
- server/gateway.ts: NEW `'live'` cache tier. CacheTier union extended;
  TIER_HEADERS + TIER_CDN_CACHE both gain entries with `s-maxage=60,
  stale-while-revalidate=60`. RPC_CACHE_TIER maps the maritime endpoint
  from `'no-store'` to `'live'` so the CDN absorbs concurrent identical
  requests across all viewers (without this, N viewers × 6 chokepoints
  hit AISStream upstream linearly).
- server/_shared/rate-limit.ts: ENDPOINT_RATE_POLICIES entry for the
  maritime endpoint at 60 req/min/IP — enough headroom for one user's
  6-chokepoint tab plus refreshes; flags only true scrape-class traffic.
- tests/route-cache-tier.test.mjs: regex extended to include `live` so
  the every-route-has-an-explicit-tier check still recognises the new
  mapping. Without this, the new tier would silently drop the maritime
  route from the validator's route map.

U8 — LiveTankersLayer consumer:

- src/services/live-tankers.ts: per-chokepoint fetcher with 60s in-memory
  cache. Promise.allSettled — never .all — so one chokepoint failing
  doesn't blank the whole layer (failed zones serve last-known data).
  Sources bbox centroids from src/config/chokepoint-registry.ts
  (CORRECT location — server/.../​_chokepoint-ids.ts strips lat/lon).
  Default chokepoint set: hormuz_strait, suez, bab_el_mandeb,
  malacca_strait, panama, bosphorus.
- src/components/DeckGLMap.ts: new `createLiveTankersLayer()` ScatterplotLayer
  styled by speed (anchored amber when speed < 0.5 kn, underway cyan,
  unknown gray); new `loadLiveTankers()` async loader with abort-controller
  cancellation. Layer instantiated when `mapLayers.liveTankers && this.liveTankers.length > 0`.
- src/config/map-layer-definitions.ts: `LayerDefinition` for `liveTankers`
  with `renderers: ['flat'], deckGLOnly: true` (matches existing
  storageFacilities/fuelShortages pattern). Added to `VARIANT_LAYER_ORDER.energy`
  near `ais` so getLayersForVariant() and sanitizeLayersForVariant()
  include it on the energy variant — without this addition the layer
  would be silently stripped even when toggled on.
- src/types/index.ts: `liveTankers?: boolean` on the MapLayers union.
- src/config/panels.ts: ENERGY_MAP_LAYERS + ENERGY_MOBILE_MAP_LAYERS
  both gain `liveTankers: true`. Default `false` everywhere else.
- src/services/maritime/index.ts: existing snapshot consumer pinned to
  `includeTankers: false` to satisfy the proto's new required field;
  preserves identical behavior for the AIS-density / military-detection
  surfaces.

Tests:
- npm run typecheck clean.
- 5 unit tests in tests/live-tankers-service.test.mjs cover the default
  chokepoint set (rejects ids that aren't in CHOKEPOINT_REGISTRY), the
  60s cache TTL pin (must match gateway 'live' tier s-maxage), and bbox
  derivation (±2° padding, total span under the 10° handler guard).
- tests/route-cache-tier.test.mjs continues to pass after the regex
  extension; the new maritime tier is correctly extracted.

Defense in depth:
- THREE-layer cache (CDN 'live' tier → handler bbox-keyed 60s → service
  in-memory 60s) means concurrent users hit the relay sub-linearly.
- Server-side 200-vessel cap on tanker_reports + client-side cap;
  protects layer render perf even on a runaway relay payload.
- Bbox-size guard (10° max) prevents a single global-bbox query from
  exfiltrating every tanker.
- Per-IP rate limit at 60/min covers normal use; flags scrape-class only.
- Existing military-detection contract preserved: `candidate_reports`
  field semantics unchanged; consumers self-select via include_tankers
  vs include_candidates rather than the response field changing meaning.

* fix(energy-atlas): wire LiveTankers loop + 400 bbox-range guard (PR3 review)

Three findings from review of #3402:

P1 — loadLiveTankers() was never called (DeckGLMap.ts:2999):
- Add ensureLiveTankersLoop() / stopLiveTankersLoop() helpers paired with
  the layer-enabled / layer-disabled branches in updateLayers(). The
  ensure helper kicks an immediate load + a 60s setInterval; idempotent
  so calling it on every layers update is safe.
- Wire stopLiveTankersLoop() into destroy() and into the layer-disabled
  branch so we don't hammer the relay when the layer is off.
- Layer factory now runs only when liveTankers.length > 0; ensureLoop
  fires on every observed-enabled tick so first-paint kicks the load
  even before the first tanker arrives.

P1 — bbox lat/lon range guard (get-vessel-snapshot.ts:253):
- Out-of-range bboxes (e.g. ne_lat=200) previously passed the size
  guard (200-195=5° < 10°) but failed at the relay, which silently
  drops the bbox param and returns a global capped subset — making
  the layer appear to "work" with stale phantom data.
- Add isValidLatLon() check inside extractAndValidateBbox(): every
  corner must satisfy [-90, 90] / [-180, 180] before the size guard
  runs. Failure throws BboxValidationError.

P2 — BboxTooLargeError surfaced as 500 instead of 400:
- server/error-mapper.ts maps errors to HTTP status by checking
  `'statusCode' in error`. The previous BboxTooLargeError extended
  Error without that property, so the mapper fell through to
  "unhandled error" → 500.
- Rename to BboxValidationError, add `readonly statusCode = 400`.
  Mapper now surfaces it as HTTP 400 with a descriptive reason.
- Keep BboxTooLargeError as a backwards-compat alias so existing
  imports / tests don't break.

Tests:
- Updated tests/server-handlers.test.mjs structural test to pin the
  new class name + statusCode + lat/lon range checks. 24 tests pass.
- typecheck (src + api) clean.

* fix(energy-atlas): thread AbortSignal through fetchLiveTankers (PR3 review #2)

P2 — AbortController was created + aborted but signal was never passed
into the actual fetch path (DeckGLMap.ts:3048 / live-tankers.ts:100):
- Toggling the layer off, destroying the map, or starting a new refresh
  did not actually cancel in-flight network work. A slow older refresh
  could complete after a newer one and overwrite this.liveTankers with
  stale data.

Threading:
- fetchLiveTankers() now accepts `options.signal: AbortSignal`. Signal
  is passed through to client.getVesselSnapshot() per chokepoint via
  the Connect-RPC client's standard `{ signal }` option.
- Per-zone abort handling: bail early if signal is already aborted
  before the fetch starts (saves a wasted RPC + cache write); re-check
  after the fetch resolves so a slow resolver can't clobber cache
  after the caller cancelled.

Stale-result race guard in DeckGLMap.loadLiveTankers:
- Capture controller in a local before storing on this.liveTankersAbort.
- After fetchLiveTankers resolves, drop the result if EITHER:
  - controller.signal is now aborted (newer load cancelled this one)
  - this.liveTankersAbort points to a different controller (a newer
    load already started + replaced us in the field)
- Without these guards, an older fetch that completed despite
  signal.aborted could still write to this.liveTankers and call
  updateLayers, racing with the newer load.

Tests: 1 new signature-pin test in tests/live-tankers-service.test.mts
verifies fetchLiveTankers accepts options.signal — guards against future
edits silently dropping the parameter and re-introducing the race.
6 tests pass. typecheck clean.

* fix(energy-atlas): bound vessel-snapshot cache via LRU eviction (PR3 review)

Greptile P2 finding: the in-process cache Map grows unbounded across the
serverless instance lifetime. Each distinct (includeCandidates,
includeTankers, quantizedBbox) triple creates a slot that's never evicted.
With 1° quantization and a misbehaving client the keyspace is ~64,000
entries — realistic load is ~12, so a 128-slot cap leaves 10x headroom
while making OOM impossible.

Implementation:
- SNAPSHOT_CACHE_MAX_SLOTS = 128.
- evictIfNeeded() walks insertion order and evicts the first slot whose
  inFlight is null. Slots with active fetches are skipped to avoid
  orphaning awaiting callers; we accept brief over-cap growth until
  in-flight settles.
- touchSlot() re-inserts a slot at the end of Map insertion order on
  hit / in-flight join / fresh write so it counts as most-recently-used.
2026-04-25 17:56:23 +04:00

319 lines
15 KiB
JavaScript

/**
* Tests for server handler correctness after PR #106 review fixes.
*
* These tests verify:
* - Humanitarian summary handler rejects unmapped country codes
* - Humanitarian summary returns ISO-2 country_code (not ISO-3)
* - Hardcoded political context is removed from LLM prompts
* - Headline deduplication logic works correctly
* - Cache key builder produces deterministic output
* - Vessel snapshot handler has cache + in-flight dedup
*/
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';
import { deduplicateHeadlines } from '../server/worldmonitor/news/v1/dedup.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');
// Helper to read a source file relative to project root
const readSrc = (relPath) => readFileSync(resolve(root, relPath), 'utf-8');
// ========================================================================
// 1. Humanitarian summary: country fallback + ISO-2 contract
// ========================================================================
describe('getHumanitarianSummary handler', () => {
const src = readSrc('server/worldmonitor/conflict/v1/get-humanitarian-summary.ts');
it('returns undefined when country has no ISO3 mapping (BLOCKING-1)', () => {
// Must have early return when no ISO3 mapping (before HAPI fetch)
assert.match(src, /if\s*\(\s*!iso3\s*\)\s*return\s+undefined/,
'Should return undefined when no ISO3 mapping exists');
// The countryCode branch must NOT fall back to Object.values(byCountry)[0]
// Extract only the "if (countryCode)" block for picking entry and verify no fallback
const pickSection = src.slice(
src.indexOf('// Pick the right country entry'),
src.indexOf('if (!entry) return undefined;'),
);
// Inside the countryCode branch, should NOT have Object.values(byCountry)[0] as fallback
const countryCodeBranch = pickSection.slice(0, pickSection.indexOf('} else {'));
assert.doesNotMatch(countryCodeBranch, /Object\.values\(byCountry\)\[0\]/,
'countryCode branch should not fallback to first entry');
});
it('returns ISO-2 country_code per proto contract (BLOCKING-2)', () => {
// Must NOT return ISO2_TO_ISO3[...] as countryCode
assert.doesNotMatch(src, /countryCode:\s*ISO2_TO_ISO3/,
'Should not return ISO-3 code in countryCode field');
// Should return the original countryCode (uppercased)
assert.match(src, /countryCode:\s*countryCode.*\.toUpperCase\(\)/,
'Should return original ISO-2 countryCode uppercased');
});
it('uses renamed conflict-event proto fields (MEDIUM-1)', () => {
assert.match(src, /conflictEventsTotal/,
'Should use conflictEventsTotal field');
assert.match(src, /conflictPoliticalViolenceEvents/,
'Should use conflictPoliticalViolenceEvents field');
assert.match(src, /conflictFatalities/,
'Should use conflictFatalities field');
assert.match(src, /referencePeriod/,
'Should use referencePeriod field');
assert.match(src, /conflictDemonstrations/,
'Should use conflictDemonstrations field');
// Old field names must not appear
assert.doesNotMatch(src, /populationAffected/,
'Should not reference old populationAffected field');
assert.doesNotMatch(src, /peopleInNeed/,
'Should not reference old peopleInNeed field');
});
});
// ========================================================================
// 2. Humanitarian summary proto: field semantics
// ========================================================================
describe('humanitarian_summary.proto', () => {
const proto = readSrc('proto/worldmonitor/conflict/v1/humanitarian_summary.proto');
it('has conflict-event field names instead of humanitarian field names', () => {
assert.match(proto, /conflict_events_total/);
assert.match(proto, /conflict_political_violence_events/);
assert.match(proto, /conflict_fatalities/);
assert.match(proto, /reference_period/);
assert.match(proto, /conflict_demonstrations/);
// Old names removed
assert.doesNotMatch(proto, /population_affected/);
assert.doesNotMatch(proto, /people_in_need/);
assert.doesNotMatch(proto, /internally_displaced/);
assert.doesNotMatch(proto, /food_insecurity_level/);
assert.doesNotMatch(proto, /water_access_pct/);
});
it('declares country_code as ISO-2', () => {
assert.match(proto, /ISO 3166-1 alpha-2/);
});
});
// ========================================================================
// 3. Hardcoded political context removed (LOW-1)
// ========================================================================
describe('LLM prompt political context (LOW-1)', () => {
const src = readSrc('server/worldmonitor/news/v1/_shared.ts');
it('does not contain hardcoded "Donald Trump" reference', () => {
assert.doesNotMatch(src, /Donald Trump/,
'Should not contain hardcoded political figure name');
});
it('uses date-based dynamic context instead', () => {
assert.match(src, /Provide geopolitical context appropriate for the current date/,
'Should instruct LLM to use current-date context');
});
});
// ========================================================================
// 4. Headline deduplication (ported logic test)
// ========================================================================
describe('headline deduplication', () => {
// Imports the real deduplicateHeadlines from dedup.mjs (shared with _shared.ts)
it('removes near-duplicate headlines', () => {
const headlines = [
'Russia launches missile strike on Ukrainian energy infrastructure targets',
'Russia launches missile strike on Ukrainian energy infrastructure overnight',
'EU approves new sanctions package against Russia',
];
// Words >= 4 chars for headline 1: russia, launches, missile, strike, ukrainian, energy, infrastructure, targets (8)
// Words >= 4 chars for headline 2: russia, launches, missile, strike, ukrainian, energy, infrastructure, overnight (8)
// Intersection: 7/8 = 0.875 > 0.6 threshold
const result = deduplicateHeadlines(headlines);
assert.equal(result.length, 2, 'Should deduplicate near-identical headlines');
assert.equal(result[0], headlines[0], 'Should keep the first occurrence');
assert.equal(result[1], headlines[2], 'Should keep the dissimilar headline');
});
it('keeps all unique headlines', () => {
const headlines = [
'Tech stocks rally on AI optimism',
'Federal Reserve holds interest rates steady',
'New climate report warns of tipping points',
];
const result = deduplicateHeadlines(headlines);
assert.equal(result.length, 3, 'All unique headlines should be kept');
});
it('handles empty input', () => {
assert.deepEqual(deduplicateHeadlines([]), []);
});
it('handles single headline', () => {
const result = deduplicateHeadlines(['Single headline here']);
assert.equal(result.length, 1);
});
});
// ========================================================================
// 5. Cache key builder (determinism test)
// ========================================================================
describe('getCacheKey determinism', () => {
const src = readSrc('src/utils/summary-cache-key.ts');
const sharedSrc = readSrc('server/worldmonitor/news/v1/_shared.ts');
it('getCacheKey function exists and builds versioned keys', () => {
assert.match(src, /export function buildSummaryCacheKey\(/,
'buildSummaryCacheKey should be exported from shared module');
assert.match(sharedSrc, /getCacheKey/,
'_shared.ts should re-export getCacheKey');
assert.match(src, /CACHE_VERSION/,
'Should use CACHE_VERSION for cache key prefixing');
assert.match(src, /`summary:\$\{CACHE_VERSION\}:\$\{mode\}/,
'Cache key should include mode');
});
it('handles translate mode separately', () => {
assert.match(src, /if\s*\(mode\s*===\s*'translate'\)/,
'Should have separate key format for translate mode');
});
});
// ========================================================================
// 6. Vessel snapshot caching (structural verification)
// ========================================================================
describe('getVesselSnapshot caching (HIGH-1)', () => {
const src = readSrc('server/worldmonitor/maritime/v1/get-vessel-snapshot.ts');
it('cache is keyed by request shape (candidates, tankers, quantized bbox)', () => {
// PR 3 (parity-push) replaced the prior `Record<'with'|'without'>` cache
// with a Map<string, SnapshotCacheSlot> where the key embeds all three
// axes that change response payload: includeCandidates, includeTankers,
// and (when present) a 1°-quantized bbox. This prevents distinct bboxes
// from collapsing onto a single cached response.
assert.match(src, /const\s+cache\s*=\s*new\s+Map<string,\s*SnapshotCacheSlot>/,
'cache should be a Map<string, SnapshotCacheSlot> keyed by request shape');
assert.match(src, /cacheKeyFor\s*\(/,
'cacheKeyFor() helper should compose the cache key');
// Key must distinguish includeCandidates, includeTankers, and bbox.
assert.match(src, /includeCandidates\s*\?\s*'1'\s*:\s*'0'/,
'cache key must encode includeCandidates');
assert.match(src, /includeTankers\s*\?\s*'1'\s*:\s*'0'/,
'cache key must encode includeTankers');
});
it('has split TTLs for base (5min) and live tanker / bbox (60s) reads', () => {
// Base path (density + military-detection consumers) keeps the prior
// 5-min cache. Live-tanker and bbox-filtered paths drop to 60s to honor
// the freshness contract that drives the Energy Atlas LiveTankersLayer.
assert.match(src, /SNAPSHOT_CACHE_TTL_BASE_MS\s*=\s*300[_]?000/,
'base TTL should remain 5 minutes (300000ms) for density/disruption consumers');
assert.match(src, /SNAPSHOT_CACHE_TTL_LIVE_MS\s*=\s*60[_]?000/,
'live tanker / bbox TTL should be 60s to match the gateway live tier s-maxage');
});
it('checks cache before calling relay', () => {
// fetchVesselSnapshot should check slot freshness before fetchVesselSnapshotFromRelay
const cacheCheckIdx = src.indexOf('slot.snapshot && (now - slot.timestamp)');
const relayCallIdx = src.indexOf('fetchVesselSnapshotFromRelay(');
assert.ok(cacheCheckIdx > -1, 'Should check slot freshness');
assert.ok(relayCallIdx > -1, 'Should have relay fetch function');
assert.ok(cacheCheckIdx < relayCallIdx,
'Cache check should come before relay call');
});
it('has in-flight dedup via per-slot promise', () => {
assert.match(src, /if\s*\(slot\.inFlight\)/,
'Should check for in-flight request on the selected slot');
assert.match(src, /slot\.inFlight\s*=\s*fetchVesselSnapshotFromRelay/,
'Should assign in-flight promise on the slot');
assert.match(src, /slot\.inFlight\s*=\s*null/,
'Should clear in-flight promise in finally block');
});
it('serves stale snapshot when relay fetch fails', () => {
assert.match(src, /return\s+result\s*\?\?\s*slot\.snapshot/,
'Should return stale cached snapshot from the selected slot when fresh relay fetch fails');
});
it('rejects oversized bbox AND out-of-range coords with statusCode=400', () => {
// PR 3 (parity-push): server-side guard against a malicious or buggy
// global-bbox query that would pull every tanker through one request.
// Range guard added in #3402 review-fix: relay silently drops malformed
// bboxes and serves global capped subsets — handler MUST validate
// -90..90 / -180..180 before calling relay. Error must carry
// statusCode=400 or error-mapper.ts maps it to a generic 500.
assert.match(src, /MAX_BBOX_DEGREES\s*=\s*10/,
'should declare a 10° max-bbox guard');
assert.match(src, /class\s+BboxValidationError/,
'should throw BboxValidationError on invalid bbox');
assert.match(src, /readonly\s+statusCode\s*=\s*400/,
'BboxValidationError must carry statusCode=400 (error-mapper surfaces it as HTTP 400 only when the error has a statusCode property)');
assert.match(src, /lat\s*>=\s*-90\s*&&\s*lat\s*<=\s*90/,
'must validate lat is in [-90, 90]');
assert.match(src, /lon\s*>=\s*-180\s*&&\s*lon\s*<=\s*180/,
'must validate lon is in [-180, 180]');
});
// NOTE: Full integration test (mocking fetch, verifying cache hits) requires
// a TypeScript-capable test runner. This structural test verifies the pattern.
});
// ========================================================================
// getSimulationOutcome handler — structural tests
// ========================================================================
describe('getSimulationOutcome handler', () => {
const src = readSrc('server/worldmonitor/forecast/v1/get-simulation-outcome.ts');
it('returns found:false (NOT_FOUND) when pointer is absent', () => {
// The handler must define a NOT_FOUND sentinel with found: false
assert.match(src, /found:\s*false/,
'NOT_FOUND constant should set found: false');
// And return it when the pointer is missing
assert.match(src, /return\s+NOT_FOUND/,
'Should return NOT_FOUND when key is absent');
});
it('uses isOutcomePointer type guard before accessing pointer fields', () => {
assert.match(src, /isOutcomePointer\(raw\)/,
'Should use isOutcomePointer type guard on getRawJson result');
// Guard must check string and number fields — not just truthy
assert.match(src, /typeof\s+o\[.runId.\]\s*===\s*'string'/,
'Type guard should verify runId is a string');
assert.match(src, /typeof\s+o\[.theaterCount.\]\s*===\s*'number'/,
'Type guard should verify theaterCount is a number');
});
it('returns found:true with all pointer fields on success', () => {
assert.match(src, /found:\s*true/,
'Success path should return found: true');
// Must propagate all pointer fields
assert.match(src, /outcomeKey:\s*pointer\.outcomeKey/,
'Success path should include outcomeKey from pointer');
assert.match(src, /theaterCount:\s*pointer\.theaterCount/,
'Success path should include theaterCount from pointer');
});
it('populates note when runId supplied but does not match pointer runId', () => {
assert.match(src, /req\.runId.*pointer\.runId/,
'Should compare req.runId with pointer.runId for note');
assert.match(src, /runId filter not yet active/,
'Note text should explain the Phase 3 deferral');
});
it('returns redis_unavailable error string on Redis failure', () => {
assert.match(src, /redis_unavailable/,
'Should return redis_unavailable on catch');
assert.match(src, /markNoCacheResponse.*catch|catch[\s\S]*?markNoCacheResponse/,
'Should mark no-cache on error to avoid caching error state');
});
});