mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
- Wrap all 4 behavioral it() blocks in try/finally so clearAllCircuitBreakers() always runs on assertion failure (P2 — leaked breaker state between tests) - Add assert.ok(fnStart !== -1) guards for fetchHapiSummary, fetchPositiveGdeltArticles, and fetchGdeltArticles so renames produce a clear diagnostic (P2 — silent false-positives) - Fix misleading comment in seed-wb-indicators.mjs: WLD/EAS are 3-char codes and aren't filtered by iso3.length !== 3 (P3) - Add timeout-minutes: 10 and permissions: contents: read to seed GHA workflow (P3)
300 lines
12 KiB
JavaScript
300 lines
12 KiB
JavaScript
/**
|
|
* Regression tests for HAPI per-country and GDELT split circuit breakers (PR #879).
|
|
*
|
|
* Root cause: two instances of the shared-breaker anti-pattern fixed in the same
|
|
* audit pass that caught the World Bank breaker bug (PR #877):
|
|
*
|
|
* 1. hapiBreaker — single shared breaker used in a Promise.allSettled loop over
|
|
* 20 countries. 2 failures in any country tripped the breaker for ALL countries,
|
|
* and the last country's result overwrote the cache for every other country.
|
|
* Fix: getHapiBreaker(iso2) Map — one breaker per ISO2 country code.
|
|
*
|
|
* 2. gdeltBreaker — one breaker shared between fetchGdeltArticles (military/conflict
|
|
* queries, 10-min cache) and fetchPositiveGdeltArticles (peace/humanitarian queries,
|
|
* different topic set). Failures in one function silenced the other, and the 10-min
|
|
* cache stored whichever query ran last, poisoning the other function's results.
|
|
* Fix: positiveGdeltBreaker — dedicated breaker for the positive sentiment path.
|
|
*/
|
|
|
|
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, pathToFileURL } from 'node:url';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const root = resolve(__dirname, '..');
|
|
|
|
const readSrc = (relPath) => readFileSync(resolve(root, relPath), 'utf-8');
|
|
|
|
// ============================================================
|
|
// 1. Static analysis: conflict/index.ts — per-country HAPI breakers
|
|
// ============================================================
|
|
|
|
describe('conflict/index.ts — per-country HAPI circuit breakers', () => {
|
|
const src = readSrc('src/services/conflict/index.ts');
|
|
|
|
// Scoped slices to avoid false positives from comments or unrelated code
|
|
const breakerSection = src.slice(src.indexOf('hapiBreakers'), src.indexOf('hapiBreakers') + 400);
|
|
const fnStart = src.indexOf('export async function fetchHapiSummary');
|
|
assert.ok(fnStart !== -1, 'fetchHapiSummary not found in conflict/index.ts — was it renamed?');
|
|
const fnBody = src.slice(fnStart, src.indexOf('\nexport ', fnStart + 1));
|
|
|
|
it('does NOT have a single shared hapiBreaker', () => {
|
|
assert.doesNotMatch(
|
|
src,
|
|
/\bconst\s+hapiBreaker\s*=/,
|
|
'Single shared hapiBreaker must not exist — use getHapiBreaker(iso2) instead',
|
|
);
|
|
});
|
|
|
|
it('has a hapiBreakers Map for per-country instances', () => {
|
|
assert.match(
|
|
breakerSection,
|
|
/new\s+Map/,
|
|
'hapiBreakers Map must exist to store per-country circuit breakers',
|
|
);
|
|
});
|
|
|
|
it('has a getHapiBreaker(iso2) factory function', () => {
|
|
assert.match(
|
|
src,
|
|
/function\s+getHapiBreaker\s*\(\s*iso2/,
|
|
'getHapiBreaker(iso2) factory function must exist',
|
|
);
|
|
});
|
|
|
|
it('fetchHapiSummary calls getHapiBreaker(iso2).execute not a shared breaker', () => {
|
|
assert.match(
|
|
fnBody,
|
|
/getHapiBreaker\s*\(\s*iso2\s*\)\s*\.execute/,
|
|
'fetchHapiSummary must use getHapiBreaker(iso2).execute, not a shared hapiBreaker',
|
|
);
|
|
});
|
|
|
|
it('per-country breaker names embed iso2', () => {
|
|
assert.match(
|
|
breakerSection,
|
|
/name\s*:\s*`HDX HAPI:\$\{iso2\}`/,
|
|
'Breaker name must embed iso2 (e.g. "HDX HAPI:US") for unique IndexedDB persistence per country',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 2. Static analysis: gdelt-intel.ts — split breakers per query type
|
|
// ============================================================
|
|
|
|
describe('gdelt-intel.ts — dedicated circuit breakers per GDELT query type', () => {
|
|
const src = readSrc('src/services/gdelt-intel.ts');
|
|
|
|
// Scoped function body slices
|
|
const posStart = src.indexOf('export async function fetchPositiveGdeltArticles');
|
|
assert.ok(posStart !== -1, 'fetchPositiveGdeltArticles not found in gdelt-intel.ts — was it renamed?');
|
|
const posBody = src.slice(posStart, src.indexOf('\nexport ', posStart + 1));
|
|
const regStart = src.indexOf('export async function fetchGdeltArticles');
|
|
assert.ok(regStart !== -1, 'fetchGdeltArticles not found in gdelt-intel.ts — was it renamed?');
|
|
const regBody = src.slice(regStart, src.indexOf('\nexport ', regStart + 1));
|
|
|
|
it('has a dedicated positiveGdeltBreaker separate from gdeltBreaker', () => {
|
|
assert.match(
|
|
src,
|
|
/\bpositiveGdeltBreaker\s*=\s*createCircuitBreaker/,
|
|
'positiveGdeltBreaker must be a separate createCircuitBreaker instance',
|
|
);
|
|
});
|
|
|
|
it('GDELT breakers have distinct names', () => {
|
|
assert.match(
|
|
src,
|
|
/GDELT Intelligence/,
|
|
'gdeltBreaker must have name "GDELT Intelligence"',
|
|
);
|
|
assert.match(
|
|
src,
|
|
/GDELT Positive/,
|
|
'positiveGdeltBreaker must have name "GDELT Positive"',
|
|
);
|
|
});
|
|
|
|
it('fetchGdeltArticles uses gdeltBreaker, NOT positiveGdeltBreaker', () => {
|
|
assert.match(
|
|
regBody,
|
|
/gdeltBreaker\.execute/,
|
|
'fetchGdeltArticles must use gdeltBreaker.execute',
|
|
);
|
|
assert.doesNotMatch(
|
|
regBody,
|
|
/positiveGdeltBreaker\.execute/,
|
|
'fetchGdeltArticles must NOT use positiveGdeltBreaker',
|
|
);
|
|
});
|
|
|
|
it('fetchPositiveGdeltArticles uses positiveGdeltBreaker, NOT gdeltBreaker', () => {
|
|
assert.match(
|
|
posBody,
|
|
/positiveGdeltBreaker\.execute/,
|
|
'fetchPositiveGdeltArticles must use positiveGdeltBreaker.execute',
|
|
);
|
|
// word-boundary prevents matching `positiveGdeltBreaker.execute`
|
|
assert.doesNotMatch(
|
|
posBody,
|
|
/\bgdeltBreaker\.execute/,
|
|
'fetchPositiveGdeltArticles must NOT use gdeltBreaker (only positiveGdeltBreaker)',
|
|
);
|
|
});
|
|
});
|
|
|
|
// ============================================================
|
|
// 3. Behavioral: circuit breaker isolation
|
|
// ============================================================
|
|
|
|
describe('CircuitBreaker isolation — HAPI per-country independence', () => {
|
|
const CIRCUIT_BREAKER_URL = pathToFileURL(
|
|
resolve(root, 'src/utils/circuit-breaker.ts'),
|
|
).href;
|
|
|
|
it('HAPI: failure in one country does not trip another', async () => {
|
|
const { createCircuitBreaker, clearAllCircuitBreakers } = await import(
|
|
`${CIRCUIT_BREAKER_URL}?t=${Date.now()}`
|
|
);
|
|
|
|
clearAllCircuitBreakers();
|
|
|
|
try {
|
|
const breakerUS = createCircuitBreaker({ name: 'HDX HAPI:US', cacheTtlMs: 30 * 60 * 1000 });
|
|
const breakerRU = createCircuitBreaker({ name: 'HDX HAPI:RU', cacheTtlMs: 30 * 60 * 1000 });
|
|
|
|
const fallback = { summary: null };
|
|
const alwaysFail = () => { throw new Error('HDX HAPI unavailable'); };
|
|
|
|
// Force breakerUS into cooldown (2 failures = maxFailures)
|
|
await breakerUS.execute(alwaysFail, fallback); // failure 1
|
|
await breakerUS.execute(alwaysFail, fallback); // failure 2 → cooldown
|
|
assert.equal(breakerUS.isOnCooldown(), true, 'breakerUS should be on cooldown after 2 failures');
|
|
|
|
// breakerRU must NOT be affected
|
|
assert.equal(breakerRU.isOnCooldown(), false, 'breakerRU must not be on cooldown when breakerUS fails');
|
|
|
|
// breakerRU should still call through successfully
|
|
const goodData = { summary: { countryCode: 'RU', conflictEvents: 12, displacedPersons: 5000 } };
|
|
const result = await breakerRU.execute(async () => goodData, fallback);
|
|
assert.deepEqual(result, goodData, 'breakerRU should return live data unaffected by breakerUS cooldown');
|
|
} finally {
|
|
clearAllCircuitBreakers();
|
|
}
|
|
});
|
|
|
|
it('HAPI: different countries cache independently (no cross-country poisoning)', async () => {
|
|
const { createCircuitBreaker, clearAllCircuitBreakers } = await import(
|
|
`${CIRCUIT_BREAKER_URL}?t=${Date.now()}`
|
|
);
|
|
|
|
clearAllCircuitBreakers();
|
|
|
|
try {
|
|
const breakerUS = createCircuitBreaker({ name: 'HDX HAPI:US', cacheTtlMs: 30 * 60 * 1000 });
|
|
const breakerRU = createCircuitBreaker({ name: 'HDX HAPI:RU', cacheTtlMs: 30 * 60 * 1000 });
|
|
|
|
const fallback = { summary: null };
|
|
const usData = { summary: { countryCode: 'US', conflictEvents: 3, displacedPersons: 100 } };
|
|
const ruData = { summary: { countryCode: 'RU', conflictEvents: 47, displacedPersons: 120000 } };
|
|
|
|
// Populate both caches with different data
|
|
await breakerUS.execute(async () => usData, fallback);
|
|
await breakerRU.execute(async () => ruData, fallback);
|
|
|
|
// Each must return its own cached value; pass a fallback fn that would return wrong data
|
|
const cachedUS = await breakerUS.execute(async () => fallback, fallback);
|
|
const cachedRU = await breakerRU.execute(async () => fallback, fallback);
|
|
|
|
assert.equal(cachedUS.summary?.countryCode, 'US',
|
|
'breakerUS cache must return US data, not RU data');
|
|
assert.equal(cachedRU.summary?.countryCode, 'RU',
|
|
'breakerRU cache must return RU data, not US data');
|
|
assert.notEqual(cachedUS.summary?.conflictEvents, cachedRU.summary?.conflictEvents,
|
|
'Cached conflict event counts must be independent per country');
|
|
} finally {
|
|
clearAllCircuitBreakers();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('CircuitBreaker isolation — GDELT split breaker independence', () => {
|
|
const CIRCUIT_BREAKER_URL = pathToFileURL(
|
|
resolve(root, 'src/utils/circuit-breaker.ts'),
|
|
).href;
|
|
|
|
it('GDELT: positive breaker failure does not trip regular breaker', async () => {
|
|
const { createCircuitBreaker, clearAllCircuitBreakers } = await import(
|
|
`${CIRCUIT_BREAKER_URL}?t=${Date.now()}`
|
|
);
|
|
|
|
clearAllCircuitBreakers();
|
|
|
|
try {
|
|
const gdelt = createCircuitBreaker({ name: 'GDELT Intelligence', cacheTtlMs: 10 * 60 * 1000 });
|
|
const positive = createCircuitBreaker({ name: 'GDELT Positive', cacheTtlMs: 10 * 60 * 1000 });
|
|
|
|
const fallback = { articles: [], totalArticles: 0 };
|
|
const alwaysFail = () => { throw new Error('GDELT API unavailable'); };
|
|
|
|
// Force positive breaker into cooldown (2 failures)
|
|
await positive.execute(alwaysFail, fallback); // failure 1
|
|
await positive.execute(alwaysFail, fallback); // failure 2 → cooldown
|
|
assert.equal(positive.isOnCooldown(), true, 'positive breaker should be on cooldown after 2 failures');
|
|
|
|
// gdelt breaker must NOT be affected
|
|
assert.equal(gdelt.isOnCooldown(), false, 'gdelt breaker must not be on cooldown when positive fails');
|
|
|
|
// gdelt should still call through successfully
|
|
const realArticles = { articles: [{ url: 'https://news.example/military', title: 'Conflict update' }], totalArticles: 1 };
|
|
const result = await gdelt.execute(async () => realArticles, fallback);
|
|
assert.deepEqual(result, realArticles, 'gdelt breaker should return live data unaffected by positive cooldown');
|
|
} finally {
|
|
clearAllCircuitBreakers();
|
|
}
|
|
});
|
|
|
|
it('GDELT: regular and positive breakers cache different data independently', async () => {
|
|
const { createCircuitBreaker, clearAllCircuitBreakers } = await import(
|
|
`${CIRCUIT_BREAKER_URL}?t=${Date.now()}`
|
|
);
|
|
|
|
clearAllCircuitBreakers();
|
|
|
|
try {
|
|
const gdelt = createCircuitBreaker({ name: 'GDELT Intelligence', cacheTtlMs: 10 * 60 * 1000 });
|
|
const positive = createCircuitBreaker({ name: 'GDELT Positive', cacheTtlMs: 10 * 60 * 1000 });
|
|
|
|
const fallback = { articles: [], totalArticles: 0 };
|
|
const militaryData = { articles: [{ url: 'https://news.example/military', title: 'Military operations' }], totalArticles: 1 };
|
|
const peaceData = { articles: [{ url: 'https://good.example/peace', title: 'Peace agreement' }], totalArticles: 1 };
|
|
|
|
// Populate both caches with different data
|
|
await gdelt.execute(async () => militaryData, fallback);
|
|
await positive.execute(async () => peaceData, fallback);
|
|
|
|
// Each must return its own cached value; pass fallback fn that would return wrong data
|
|
const cachedGdelt = await gdelt.execute(async () => fallback, fallback);
|
|
const cachedPositive = await positive.execute(async () => fallback, fallback);
|
|
|
|
assert.ok(
|
|
cachedGdelt.articles[0]?.url.includes('military'),
|
|
'gdelt cache must return military article URL, not peace article',
|
|
);
|
|
assert.ok(
|
|
cachedPositive.articles[0]?.url.includes('peace'),
|
|
'positive cache must return peace article URL, not military article',
|
|
);
|
|
assert.notEqual(
|
|
cachedGdelt.articles[0]?.url,
|
|
cachedPositive.articles[0]?.url,
|
|
'Cached article URLs must be distinct per breaker (no cross-contamination)',
|
|
);
|
|
} finally {
|
|
clearAllCircuitBreakers();
|
|
}
|
|
});
|
|
});
|