mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(energy): seed EIA chokepoint baseline volumes - Add scripts/seed-chokepoint-baselines.mjs with 7 hardcoded EIA 2023 chokepoints (Hormuz through Panama), 400-day TTL, no network calls - Add tests/chokepoint-baselines-seed.test.mjs with 14 test cases covering payload shape, key constants, TTL, and validateFn - Register seed-chokepoint-baselines in railway-set-watch-paths.mjs with annual cron (0 0 1 1 *) * fix(energy): 3 review fixes for chokepoint-baselines PR P1 — IEA seed: move per-country Redis writes from fetch phase to afterPublish pipeline. fetchIeaOilStocks now returns pure data; publishTransform builds the canonical index; writeCountryKeys sends all 32 country keys atomically via pipeline in the publish phase. A mid-run Redis failure can no longer leave a partially-updated snapshot with a stale index. P2 — Wire chokepointBaselines into bootstrap: add to BOOTSTRAP_CACHE_KEYS + SLOW_KEYS in api/bootstrap.js and server/_shared/cache-keys.ts + BOOTSTRAP_TIERS. P3 — Wire IEA seed operationally: add seed-iea-oil-stocks service to railway-set-watch-paths.mjs (monthly cron 0 6 20 * *) and ieaOilStocks health entry (40-day maxStaleMin) to api/health.js. * fix(test): add chokepointBaselines to PENDING_CONSUMERS Frontend consumer not yet implemented; consistent with chokepointTransits, correlationCards, euGasStorage which are also wired to bootstrap ahead of their UI panels. * fix(energy): register country keys in extraKeys for TTL preservation afterPublish runs in the publish phase but is NOT included in runSeed's failure-path TTL extension. Replace afterPublish+writeCountryKeys with COUNTRY_EXTRA_KEYS (one entry per COUNTRY_MAP iso2) declared as extraKeys: - On fetch failure or validation skip: runSeed extends TTL for all 32 country keys alongside the canonical index - On successful publish: writeExtraKey writes each country key with a per-iso2 transform; no dangling index entries after failed refreshes Also removes now-unused getRedisCredentials import. * fix(energy): 3 follow-up review fixes High — seed-meta TTL: writeFreshnessMetadata now accepts a ttlSeconds param and uses max(7d, ttlSeconds). runSeed passes its data TTL so monthly/annual seeds (IEA: 40d, chokepoint: 400d) no longer lose their seed-meta key on day 8 before health maxStaleMin is reached. Medium — Turkey name: IEA API returns "Turkiye" (no umlaut) while COUNTRY_MAP keys "Türkiye". parseRecord now normalizes the alias before lookup; TR is no longer silently dropped. Test added to cover the normalized form. Medium — Bootstrap revert: remove chokepointBaselines from BOOTSTRAP_CACHE_KEYS, SLOW_KEYS (bootstrap.js), BOOTSTRAP_TIERS (cache-keys.ts), and PENDING_CONSUMERS (bootstrap test) until a src/ consumer exists. Static 7-entry payload should not load on every bootstrap request for a feature with no frontend. * fix(seed-utils): pass ttlSeconds to writeFreshnessMetadata on skip path The validation-skip branch at runSeed:657 was still calling writeFreshnessMetadata without ttlSeconds, reintroducing the 7-day meta TTL for any monthly/annual seed that hits an empty-data run. * fix(test): restore chokepointBaselines in PENDING_CONSUMERS Rebase conflict resolution kept chokepointBaselines in BOOTSTRAP_CACHE_KEYS but the follow-up fix commit's test change auto-merged and removed it from PENDING_CONSUMERS. Re-add it so the consumer-coverage test passes while the frontend consumer is still pending. * fix(iea): align COUNTRY_MAP to ASCII Turkiye key (matches main + test) main (PR #2733) uses 'Turkiye' (no umlaut) as the COUNTRY_MAP key directly. Our branch had 'Türkiye' + parseRecord normalization. Align with main's approach: single key, no normalization shim needed.
104 lines
3.3 KiB
JavaScript
104 lines
3.3 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
CHOKEPOINTS,
|
|
CANONICAL_KEY,
|
|
CHOKEPOINT_TTL_SECONDS,
|
|
buildPayload,
|
|
validateFn,
|
|
} from '../scripts/seed-chokepoint-baselines.mjs';
|
|
|
|
describe('buildPayload', () => {
|
|
it('returns all 7 chokepoints', () => {
|
|
const payload = buildPayload();
|
|
assert.equal(payload.chokepoints.length, 7);
|
|
});
|
|
|
|
it('includes required top-level fields', () => {
|
|
const payload = buildPayload();
|
|
assert.ok(payload.source);
|
|
assert.equal(payload.referenceYear, 2023);
|
|
assert.ok(typeof payload.updatedAt === 'string');
|
|
assert.ok(Array.isArray(payload.chokepoints));
|
|
});
|
|
|
|
it('each chokepoint has id, name, mbd, lat, lon fields', () => {
|
|
const payload = buildPayload();
|
|
for (const cp of payload.chokepoints) {
|
|
assert.ok('id' in cp, `Missing id: ${JSON.stringify(cp)}`);
|
|
assert.ok('name' in cp, `Missing name: ${JSON.stringify(cp)}`);
|
|
assert.ok('mbd' in cp, `Missing mbd: ${JSON.stringify(cp)}`);
|
|
assert.ok('lat' in cp, `Missing lat: ${JSON.stringify(cp)}`);
|
|
assert.ok('lon' in cp, `Missing lon: ${JSON.stringify(cp)}`);
|
|
}
|
|
});
|
|
|
|
it('all mbd values are positive numbers', () => {
|
|
const payload = buildPayload();
|
|
for (const cp of payload.chokepoints) {
|
|
assert.equal(typeof cp.mbd, 'number', `mbd not a number for ${cp.id}`);
|
|
assert.ok(cp.mbd > 0, `mbd not positive for ${cp.id}`);
|
|
}
|
|
});
|
|
|
|
it('Hormuz has the highest mbd (21.0)', () => {
|
|
const payload = buildPayload();
|
|
const hormuz = payload.chokepoints.find(cp => cp.id === 'hormuz');
|
|
assert.ok(hormuz, 'Hormuz entry missing');
|
|
assert.equal(hormuz.mbd, 21.0);
|
|
const maxMbd = Math.max(...payload.chokepoints.map(cp => cp.mbd));
|
|
assert.equal(hormuz.mbd, maxMbd);
|
|
});
|
|
|
|
it('Panama has the lowest mbd (0.9)', () => {
|
|
const payload = buildPayload();
|
|
const panama = payload.chokepoints.find(cp => cp.id === 'panama');
|
|
assert.ok(panama, 'Panama entry missing');
|
|
assert.equal(panama.mbd, 0.9);
|
|
const minMbd = Math.min(...payload.chokepoints.map(cp => cp.mbd));
|
|
assert.equal(panama.mbd, minMbd);
|
|
});
|
|
});
|
|
|
|
describe('CANONICAL_KEY', () => {
|
|
it('is energy:chokepoint-baselines:v1', () => {
|
|
assert.equal(CANONICAL_KEY, 'energy:chokepoint-baselines:v1');
|
|
});
|
|
});
|
|
|
|
describe('CHOKEPOINT_TTL_SECONDS', () => {
|
|
it('is at least 1 year in seconds', () => {
|
|
const oneYearSeconds = 365 * 24 * 3600;
|
|
assert.ok(CHOKEPOINT_TTL_SECONDS >= oneYearSeconds, `TTL ${CHOKEPOINT_TTL_SECONDS} < 1 year`);
|
|
});
|
|
});
|
|
|
|
describe('CHOKEPOINTS', () => {
|
|
it('exports 7 chokepoint entries', () => {
|
|
assert.equal(CHOKEPOINTS.length, 7);
|
|
});
|
|
});
|
|
|
|
describe('validateFn', () => {
|
|
it('returns false for null', () => {
|
|
assert.equal(validateFn(null), false);
|
|
});
|
|
|
|
it('returns false for empty object', () => {
|
|
assert.equal(validateFn({}), false);
|
|
});
|
|
|
|
it('returns false when chokepoints array is empty', () => {
|
|
assert.equal(validateFn({ chokepoints: [] }), false);
|
|
});
|
|
|
|
it('returns false when chokepoints has fewer than 7 entries', () => {
|
|
assert.equal(validateFn({ chokepoints: [1, 2, 3] }), false);
|
|
});
|
|
|
|
it('returns true for correct shape with 7 chokepoints', () => {
|
|
const payload = buildPayload();
|
|
assert.equal(validateFn(payload), true);
|
|
});
|
|
});
|