Files
worldmonitor/tests/resilience-handlers.test.mts
Elie Habib 17e34dfca7 feat(resilience): recovery capacity pillar — 6 new dimensions + 5 seeders (Phase 2 T2.2b) (#2987)
* feat(resilience): recovery capacity pillar — 6 new dimensions + 5 seeders (Phase 2 T2.2b)

Add the recovery-capacity pillar with 6 new dimensions:
- fiscalSpace: IMF GGR_G01_GDP_PT + GGXCNL_G01_GDP_PT + GGXWDG_NGDP_PT
- reserveAdequacy: World Bank FI.RES.TOTL.MO
- externalDebtCoverage: WB DT.DOD.DSTC.CD / FI.RES.TOTL.CD ratio
- importConcentration: UN Comtrade HHI (stub seeder)
- stateContinuity: derived from WGI + UCDP + displacement (no new fetch)
- fuelStockDays: IEA/EIA (stub seeder, Enrichment tier)

Each dimension has a scorer in _dimension-scorers.ts, registry entries in
_indicator-registry.ts, methodology doc subsections, and fixture data.

Seeders: fiscal-space (real, IMF WEO), reserve-adequacy (real, WB API),
external-debt (real, WB API), import-hhi (stub), fuel-stocks (stub).

Recovery domain weight is 0 until PR 4 (T2.3) ships the penalized weighted
mean across pillars. The domain appears in responses structurally but does
not affect the overall score.

Bootstrap: STANDALONE_KEYS + SEED_META + EMPTY_DATA_OK_KEYS + ON_DEMAND_KEYS
all updated in api/health.js. Source-failure mapping updated for
stateContinuity (WGI adapter). Widget labels and LOCKED_PREVIEW updated.

All 282 resilience tests pass, typecheck clean, methodology lint clean.

* fix(resilience): ISO3→ISO2 normalization in WB recovery seeders (#2987 P1)

Both seed-recovery-reserve-adequacy.mjs and seed-recovery-external-debt.mjs
used countryiso3code from the World Bank API response then immediately
rejected codes where length !== 2. WB returns ISO3 codes (USA, DEU, etc.),
so all real rows were silently dropped and the feed was always empty.

Fix: import scripts/shared/iso3-to-iso2.json and normalize before the
length check. Also removed from EMPTY_DATA_OK_KEYS in health.js since
empty results now indicate a real failure, not a structural absence.

* fix(resilience): remove unused import + no-op overrides (#2987 review)

* fix(test): update release-gate to expect 6 domains after recovery pillar
2026-04-12 10:10:10 +04:00

71 lines
3.8 KiB
TypeScript

import assert from 'node:assert/strict';
import { afterEach, describe, it } from 'node:test';
import { getResilienceScore } from '../server/worldmonitor/resilience/v1/get-resilience-score.ts';
import { createRedisFetch } from './helpers/fake-upstash-redis.mts';
import { RESILIENCE_FIXTURES } from './helpers/resilience-fixtures.mts';
const originalFetch = globalThis.fetch;
const originalRedisUrl = process.env.UPSTASH_REDIS_REST_URL;
const originalRedisToken = process.env.UPSTASH_REDIS_REST_TOKEN;
const originalVercelEnv = process.env.VERCEL_ENV;
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalRedisUrl == null) delete process.env.UPSTASH_REDIS_REST_URL;
else process.env.UPSTASH_REDIS_REST_URL = originalRedisUrl;
if (originalRedisToken == null) delete process.env.UPSTASH_REDIS_REST_TOKEN;
else process.env.UPSTASH_REDIS_REST_TOKEN = originalRedisToken;
if (originalVercelEnv == null) delete process.env.VERCEL_ENV;
else process.env.VERCEL_ENV = originalVercelEnv;
});
describe('resilience handlers', () => {
it('computes and caches a country score with domains, trend metadata, and history writes', async () => {
const today = new Date().toISOString().slice(0, 10);
process.env.UPSTASH_REDIS_REST_URL = 'https://redis.example';
process.env.UPSTASH_REDIS_REST_TOKEN = 'token';
delete process.env.VERCEL_ENV;
const { fetchImpl, redis, sortedSets } = createRedisFetch(RESILIENCE_FIXTURES);
sortedSets.set('resilience:history:v4:US', [
{ member: '2026-04-01:20', score: 20260401 },
{ member: '2026-04-02:30', score: 20260402 },
]);
globalThis.fetch = fetchImpl;
const response = await getResilienceScore({ request: new Request('https://example.com') } as never, {
countryCode: 'us',
});
assert.equal(response.countryCode, 'US');
assert.equal(response.domains.length, 6);
assert.equal(response.domains.flatMap((domain) => domain.dimensions).length, 19);
assert.ok(response.overallScore > 0 && response.overallScore <= 100);
assert.equal(response.level, response.overallScore >= 70 ? 'high' : response.overallScore >= 40 ? 'medium' : 'low');
assert.equal(response.trend, 'rising');
assert.ok(response.change30d > 0);
assert.equal(typeof response.lowConfidence, 'boolean');
assert.ok(response.imputationShare >= 0 && response.imputationShare <= 1, `imputationShare out of bounds: ${response.imputationShare}`);
assert.equal(typeof response.baselineScore, 'number', 'baselineScore should be present');
assert.equal(typeof response.stressScore, 'number', 'stressScore should be present');
assert.equal(typeof response.stressFactor, 'number', 'stressFactor should be present');
assert.ok(response.baselineScore >= 0 && response.baselineScore <= 100, `baselineScore out of bounds: ${response.baselineScore}`);
assert.ok(response.stressScore >= 0 && response.stressScore <= 100, `stressScore out of bounds: ${response.stressScore}`);
assert.ok(response.stressFactor >= 0 && response.stressFactor <= 0.5, `stressFactor out of bounds: ${response.stressFactor}`);
assert.equal(response.dataVersion, '2024-04-03', 'dataVersion should be the ISO date from seed-meta fetchedAt');
const cachedScore = redis.get('resilience:score:v8:US');
assert.ok(cachedScore, 'expected score cache to be written');
assert.equal(JSON.parse(cachedScore || '{}').countryCode, 'US');
const history = sortedSets.get('resilience:history:v4:US') ?? [];
assert.ok(history.some((entry) => entry.member.startsWith(today + ':')), 'expected today history member to be written');
await getResilienceScore({ request: new Request('https://example.com') } as never, {
countryCode: 'US',
});
assert.equal((sortedSets.get('resilience:history:v4:US') ?? []).length, history.length, 'cache hit must not append history');
});
});