mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-12 06:12:18 +00:00
Deleted World News API news is not restored by a refresh that fails; a refund goes only to the day the read was counted in, and not to a request that timed out; a cockpit brief from another place is dropped, not kept for a language change; a feed list started over lets scrolling ask again. Co-Authored-By: Claude Fable 5.2 <[email protected]>
1302 lines
44 KiB
JavaScript
1302 lines
44 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
createKeyedNews,
|
|
gdeltPlace,
|
|
NEWS_KEYS,
|
|
WORLDWIDE_HOTSPOT,
|
|
} from './keyed.js';
|
|
import { KEY_SETUP_KEYS } from '../../../src/keySetupCore.mjs';
|
|
import {
|
|
normalizeFeedPayload,
|
|
normalizeHotspotsPayload,
|
|
normalizeOutlinePayload,
|
|
} from '../../../src/layers/news/records.js';
|
|
import { inPolygons } from '../../../src/layers/news/model.js';
|
|
import { areaAt, countryPolygons } from './areas.js';
|
|
import { exportsOfDay } from './gdeltEvents.js';
|
|
import { eventRow, gkgRow, zipTable } from './gdeltFixtures.mjs';
|
|
|
|
const NOW = Date.parse('2026-11-05T12:00:00Z');
|
|
const seconds = (iso) => Date.parse(iso) / 1000;
|
|
|
|
// Liveuamap's `a=mpts` answer, cut down: strings where it writes strings.
|
|
const LIVEUAMAP = {
|
|
success: true,
|
|
key: 'redacted',
|
|
venues: [
|
|
{
|
|
id: 101,
|
|
name: 'Russian drone strike on Kharkiv kills 3, injures 12',
|
|
lat: '49.99350',
|
|
lng: '36.23040',
|
|
location: 'Kharkiv',
|
|
timestamp: seconds('2026-11-05T11:00:00Z'),
|
|
link: 'https://liveuamap.com/en/2026/5-november-kharkiv',
|
|
img_share: 'https://a.liveuamap.com/pics/101.jpg',
|
|
video: 'https://a.liveuamap.com/clips/101.mp4',
|
|
},
|
|
{
|
|
id: 102,
|
|
name: 'Floods kill dozens in Amman',
|
|
lat: '31.95390',
|
|
lng: '35.91060',
|
|
location: 'Amman',
|
|
timestamp: seconds('2026-11-05T10:00:00Z'),
|
|
link: 'https://mideast.liveuamap.com/en/2026/5-november-amman',
|
|
img_share: '',
|
|
video: '',
|
|
},
|
|
{
|
|
id: 103,
|
|
name: 'Officials in "Kherson" report shelling',
|
|
lat: '46.63540',
|
|
lng: '32.61690',
|
|
timestamp: seconds('2026-11-05T09:00:00Z'),
|
|
link: 'https://liveuamap.com/en/2026/5-november-kherson',
|
|
},
|
|
// Without a point, a page or a headline: left out.
|
|
{ id: 104, name: 'No point', timestamp: seconds('2026-11-05T09:00:00Z') },
|
|
{
|
|
id: 105,
|
|
name: 'No page',
|
|
lat: '1',
|
|
lng: '1',
|
|
timestamp: seconds('2026-11-05T09:00:00Z'),
|
|
link: 'javascript:alert(1)',
|
|
},
|
|
{
|
|
id: 106,
|
|
name: '',
|
|
lat: '1',
|
|
lng: '1',
|
|
timestamp: seconds('2026-11-05T09:00:00Z'),
|
|
link: 'https://liveuamap.com/x',
|
|
},
|
|
],
|
|
};
|
|
|
|
// World News API's search-news answer, cut down.
|
|
const WORLD_NEWS = {
|
|
offset: 0,
|
|
number: 100,
|
|
available: 3,
|
|
news: [
|
|
{
|
|
id: 9001,
|
|
title: 'Visiting minister arrives in Amman for talks',
|
|
url: 'https://www.daily.example/en/minister-amman-talks',
|
|
image: 'https://www.daily.example/pics/visit.jpg',
|
|
publish_date: '2026-11-05 08:30:00',
|
|
language: 'en',
|
|
category: 'politics',
|
|
text: 'The whole article, never kept.',
|
|
},
|
|
{
|
|
id: 9002,
|
|
title: 'Two oil firms advance subsalt exploration',
|
|
url: 'https://markets.example/economic/oil-firms-exploration',
|
|
publish_date: '2026-11-05 07:00:00',
|
|
language: 'ru',
|
|
category: 'business',
|
|
},
|
|
// Sport is left out.
|
|
{
|
|
id: 9004,
|
|
title: 'Al-Faisaly win the cup final',
|
|
url: 'https://sport.example/cup',
|
|
publish_date: '2026-11-05 09:00:00',
|
|
category: 'sports',
|
|
},
|
|
// The same story from another outlet is one.
|
|
{
|
|
id: 9003,
|
|
title: 'TWO OIL FIRMS ADVANCE SUBSALT EXPLORATION',
|
|
url: 'https://portal.test/oil-firms',
|
|
publish_date: '2026-11-05 06:00:00',
|
|
},
|
|
],
|
|
};
|
|
|
|
// GDELT's exports around NOW: the newest not published yet, two with
|
|
// events, the rest of the day empty.
|
|
const KHARKIV =
|
|
'https://www.wire.example/rubric-ato/3912-russian-drones-attack-kharkiv-overnight-three-killed.html';
|
|
const RED_SEA =
|
|
'https://news.example.com/2026/11/05/houthis-claim-missile-attack-on-tanker-in-red-sea';
|
|
const COUNCIL =
|
|
'https://news.example.com/2026/11/05/city-council-approves-new-budget-for-schools';
|
|
const BARE = 'https://news.example.com/a/b/123456';
|
|
const exportName = (stamp) =>
|
|
`https://data.gdeltproject.org/gdeltv2/${stamp}.export.CSV.zip`;
|
|
const EXPORTS = {
|
|
[exportName('20261105120000')]: 404,
|
|
[exportName('20261105114500')]: [
|
|
eventRow({
|
|
id: 1,
|
|
code: '195',
|
|
mentions: 3,
|
|
url: KHARKIV,
|
|
added: '20261105114500',
|
|
}),
|
|
eventRow({
|
|
id: 2,
|
|
code: '195',
|
|
mentions: 8,
|
|
geoType: '1',
|
|
place: 'Yemen',
|
|
lat: 15.5,
|
|
lon: 47.5,
|
|
url: RED_SEA,
|
|
added: '20261105114500',
|
|
}),
|
|
eventRow({ id: 3, url: COUNCIL, added: '20261105114500' }),
|
|
],
|
|
[exportName('20261105113000')]: [
|
|
eventRow({
|
|
id: 4,
|
|
geoType: '1',
|
|
place: 'Ukraine',
|
|
lat: 49,
|
|
lon: 32,
|
|
url: BARE,
|
|
added: '20261105113000',
|
|
}),
|
|
// An article already read is not a second report.
|
|
eventRow({ id: 5, code: '190', url: KHARKIV, added: '20261105113000' }),
|
|
],
|
|
};
|
|
const EMPTY = zipTable('');
|
|
// The page titles of the newest export's quarter hour.
|
|
const GKG = {
|
|
[exportName('20261105114500').replace('.export.CSV.zip', '.gkg.csv.zip')]: [
|
|
gkgRow(
|
|
KHARKIV,
|
|
'Russian drones attack Kharkiv overnight, three killed - Wire',
|
|
),
|
|
],
|
|
};
|
|
|
|
const host = (call) => call.url.host;
|
|
|
|
/**
|
|
* A fake upstream: answers by host, counts calls, records URLs and headers.
|
|
* An answer is a payload, a status, or a function giving a Response (one
|
|
* held open). `unlisted` is the status of an export not in `exports` (an
|
|
* empty table by default); `gkg` a status for every GKG export.
|
|
*/
|
|
function upstream(answers = {}) {
|
|
const calls = [];
|
|
const fetchImpl = async (url, init) => {
|
|
const href = String(url);
|
|
calls.push({ url: new URL(href), headers: init?.headers || {} });
|
|
if (href.includes('gdeltproject.org')) {
|
|
if (typeof answers.gdelt === 'number')
|
|
return new Response('busy', { status: answers.gdelt });
|
|
if (href.endsWith('/lastupdate.txt') && answers.lastupdate)
|
|
return new Response(
|
|
`95141 9197 http://data.gdeltproject.org/gdeltv2/${answers.lastupdate}.export.CSV.zip
|
|
`,
|
|
{ status: 200 },
|
|
);
|
|
if (answers.corrupt?.includes(href))
|
|
return new Response('not a zip at all', { status: 200 });
|
|
if (answers.failing?.includes(href))
|
|
return new Response('busy', { status: 503 });
|
|
const gkg = href.endsWith('.gkg.csv.zip');
|
|
const listed = answers.exports ?? EXPORTS;
|
|
const answer = gkg
|
|
? (answers.gkg ?? GKG[href])
|
|
: Object.hasOwn(listed, href)
|
|
? listed[href]
|
|
: answers.unlisted;
|
|
if (typeof answer === 'number')
|
|
return new Response('', { status: answer });
|
|
return new Response(answer ? zipTable(answer.join('\n')) : EMPTY, {
|
|
status: 200,
|
|
});
|
|
}
|
|
const answer = href.includes('liveuamap.com')
|
|
? (answers.liveuamap ?? LIVEUAMAP)
|
|
: (answers.worldnews ?? WORLD_NEWS);
|
|
if (typeof answer === 'function') return answer();
|
|
if (typeof answer === 'number')
|
|
return new Response('{"message":"bad key ABC"}', { status: answer });
|
|
return new Response(JSON.stringify(answer), { status: 200 });
|
|
};
|
|
return { calls, fetchImpl };
|
|
}
|
|
|
|
function keyed({ env, answers, clock = { now: NOW }, logs = [] } = {}) {
|
|
const fake = upstream(answers);
|
|
// The next GDELT reads, run by settle rather than on a timer.
|
|
const timers = [];
|
|
const news = createKeyedNews({
|
|
env: () =>
|
|
env ?? { LIVEUAMAP_API_KEY: 'live-key', WORLDNEWSAPI_KEY: 'wna-key' },
|
|
fetchImpl: fake.fetchImpl,
|
|
now: () => clock.now,
|
|
log: (line) => logs.push(line),
|
|
later: (run) => timers.push(run),
|
|
});
|
|
const ask = (path, query = '') =>
|
|
news.answer(path, new URLSearchParams(query));
|
|
/**
|
|
* Let the background reads finish (the rest of the day, the titles):
|
|
* until nothing is asked for a while, or a bounded number of turns.
|
|
*/
|
|
async function settle() {
|
|
let quiet = 0;
|
|
for (let turn = 0; turn < 200 && quiet < 5; turn++) {
|
|
const before = fake.calls.length;
|
|
for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r));
|
|
await new Promise((r) => setTimeout(r, 1));
|
|
for (const run of timers.splice(0)) run();
|
|
quiet = fake.calls.length === before ? quiet + 1 : 0;
|
|
}
|
|
}
|
|
return { ...fake, ask, clock, logs, settle, timers };
|
|
}
|
|
|
|
const CONFLICTS =
|
|
'hotspot=all&geo=1&today=1&events=1&relevance=core,related&limit=50';
|
|
const gdeltCalls = (calls) =>
|
|
calls.filter(
|
|
(c) =>
|
|
host(c) === 'data.gdeltproject.org' &&
|
|
c.url.pathname.endsWith('.export.CSV.zip'),
|
|
);
|
|
const gkgCalls = (calls) =>
|
|
calls.filter((c) => c.url.pathname.endsWith('.gkg.csv.zip'));
|
|
|
|
test('without any key Conflicts are the day of GDELT events, one worldwide hotspot', async () => {
|
|
const { ask, calls, settle } = keyed({ env: {} });
|
|
const answer = await ask('/hotspots');
|
|
assert.equal(answer.status, 200);
|
|
const payload = normalizeHotspotsPayload(answer.body);
|
|
assert.deepEqual(
|
|
payload.hotspots.map((h) => [h.id, h.catchAll]),
|
|
[[WORLDWIDE_HOTSPOT, true]],
|
|
);
|
|
assert.equal(payload.hotspots[0].last24h, 2);
|
|
assert.equal(payload.hotspots[0].mapped24h, 2);
|
|
assert.equal(payload.maxWindowHours, 24);
|
|
// The newest dozen first; the rest of the day after, in the background.
|
|
assert.deepEqual(
|
|
gdeltCalls(calls)
|
|
.slice(0, 12)
|
|
.map((c) => String(c.url)),
|
|
exportsOfDay(NOW).slice(0, 12),
|
|
);
|
|
assert.equal(
|
|
String(gdeltCalls(calls)[0].url),
|
|
'https://data.gdeltproject.org/gdeltv2/20261105120000.export.CSV.zip',
|
|
);
|
|
await settle();
|
|
assert.ok(gdeltCalls(calls).length >= 96);
|
|
assert.ok(calls.every((c) => host(c) === 'data.gdeltproject.org'));
|
|
// Page titles only for the newest three hours (a GKG export is 6 MB).
|
|
assert.ok(gkgCalls(calls).length > 0 && gkgCalls(calls).length <= 12);
|
|
});
|
|
|
|
test('a GDELT article is a report at its place, in its words, credited to GDELT', async () => {
|
|
const { ask, settle } = keyed({ env: {} });
|
|
// The first answer has the events in, titled by their address words; the
|
|
// page titles follow in the background.
|
|
const first = normalizeFeedPayload((await ask('/feed', CONFLICTS)).body);
|
|
assert.equal(
|
|
first.reports.find((r) => r.id === 'gdelt-5').title,
|
|
'Russian drones attack Kharkiv overnight three killed',
|
|
);
|
|
await settle();
|
|
const { reports } = normalizeFeedPayload(
|
|
(await ask('/feed', CONFLICTS)).body,
|
|
);
|
|
// Kharkiv's story was first seen in the earlier export: its first event
|
|
// names it.
|
|
assert.deepEqual(
|
|
reports.map((r) => r.id),
|
|
['gdelt-2', 'gdelt-5'],
|
|
);
|
|
const [redSea, kharkiv] = reports;
|
|
// Its page title, from the GKG export.
|
|
assert.equal(
|
|
kharkiv.title,
|
|
'Russian drones attack Kharkiv overnight, three killed',
|
|
);
|
|
assert.equal(kharkiv.category, 'strike');
|
|
assert.equal(kharkiv.relevance, 'core');
|
|
assert.equal(kharkiv.severity, 2);
|
|
assert.equal(kharkiv.publisher, 'wire.example');
|
|
assert.equal(kharkiv.attribution, 'GDELT Project');
|
|
assert.equal(kharkiv.kind, 'gdelt');
|
|
assert.equal(kharkiv.side, 'unknown');
|
|
assert.equal(kharkiv.geo.precision, 'city');
|
|
assert.deepEqual(
|
|
kharkiv.pins.map(({ lat, lon }) => [lat, lon]),
|
|
[[49.9935, 36.2304]],
|
|
);
|
|
assert.equal(redSea.geo.place, 'Yemen');
|
|
assert.equal(redSea.geo.precision, 'country');
|
|
// An address without words has GDELT's coding alone: minor, so not in
|
|
// Conflicts, until a page title says what it is.
|
|
const bare = (await ask('/feed', 'hotspot=all&today=1')).body.reports.find(
|
|
(r) => r.id === 'gdelt-4',
|
|
);
|
|
assert.equal(bare.title, 'Fighting reported in Ukraine');
|
|
assert.equal(bare.relevance, 'minor');
|
|
// The council story GDELT coded as fighting is minor: not in Conflicts.
|
|
assert.ok(!reports.some((r) => r.url.includes('city-council')));
|
|
});
|
|
|
|
test('with a Liveuamap key its mapped items join GDELT in the one hotspot', async () => {
|
|
const { ask, calls, settle } = keyed();
|
|
const answer = await ask('/hotspots');
|
|
assert.equal(answer.body.hotspots[0].counts.last24h, 5);
|
|
const live = calls.find((c) => host(c) === 'a.liveuamap.com').url;
|
|
assert.equal(live.searchParams.get('a'), 'mpts');
|
|
assert.equal(live.searchParams.get('key'), 'live-key');
|
|
assert.equal(
|
|
Number(live.searchParams.get('time')),
|
|
(NOW - 86_400_000) / 1000,
|
|
);
|
|
const { reports } = normalizeFeedPayload(
|
|
(await ask('/feed', CONFLICTS)).body,
|
|
);
|
|
const kharkiv = reports.find((r) => r.id === 'lum-101');
|
|
assert.equal(kharkiv.category, 'strike');
|
|
assert.equal(kharkiv.severity, 2);
|
|
assert.equal(kharkiv.publisher, 'Liveuamap');
|
|
assert.deepEqual(
|
|
kharkiv.pins.map(({ lat, lon }) => [lat, lon]),
|
|
[[49.9935, 36.2304]],
|
|
);
|
|
assert.equal(
|
|
kharkiv.event.image.video,
|
|
'https://a.liveuamap.com/clips/101.mp4',
|
|
);
|
|
assert.deepEqual(kharkiv.media, ['video']);
|
|
assert.equal(
|
|
reports.find((r) => r.id === 'lum-103').title,
|
|
'Officials in "Kherson" report shelling',
|
|
);
|
|
// Counts over a shorter window.
|
|
const short = await ask('/hotspots', 'hours=2');
|
|
assert.equal(short.body.hotspots[0].counts.last24h, 4);
|
|
await settle();
|
|
});
|
|
|
|
test('the Conflicts feed pages by time and filters by category and hotspot', async () => {
|
|
const { ask, settle } = keyed({ env: {} });
|
|
// Every report of the day, the minor ones too.
|
|
const all = 'hotspot=all&today=1&events=1';
|
|
// A page ends between two times: both of 11:45 come together, though
|
|
// the page asked for one.
|
|
const first = await ask('/feed', `${all}&limit=1`);
|
|
assert.deepEqual(
|
|
first.body.reports.map((r) => r.id),
|
|
['gdelt-2', 'gdelt-3'],
|
|
);
|
|
assert.equal(first.body.nextBefore, '2026-11-05T11:45:00.000Z');
|
|
const next = await ask(
|
|
'/feed',
|
|
`${all}&limit=1&before=${first.body.nextBefore}`,
|
|
);
|
|
assert.deepEqual(
|
|
next.body.reports.map((r) => r.id),
|
|
['gdelt-5', 'gdelt-4'],
|
|
);
|
|
assert.equal(next.body.nextBefore, null);
|
|
const strikes = await ask('/feed', 'hotspot=all&category=strike');
|
|
assert.equal(strikes.body.reports.length, 2);
|
|
const elsewhere = await ask('/feed', 'hotspot=ua-ru-war');
|
|
assert.deepEqual(elsewhere.body.reports, []);
|
|
await settle();
|
|
});
|
|
|
|
test('an export not published yet is looked for again, and the day is kept a day', async () => {
|
|
const exports = { ...EXPORTS };
|
|
const { ask, calls, clock, settle } = keyed({
|
|
env: {},
|
|
answers: { exports },
|
|
});
|
|
await ask('/hotspots');
|
|
await settle();
|
|
const read = gdeltCalls(calls).length;
|
|
// Within five minutes nothing is asked again.
|
|
clock.now += 2 * 60_000;
|
|
await ask('/hotspots');
|
|
assert.equal(gdeltCalls(calls).length, read);
|
|
// The late export is published now, with a new article.
|
|
exports[exportName('20261105120000')] = [
|
|
eventRow({
|
|
id: 6,
|
|
code: '183',
|
|
url: 'https://news.example.com/2026/11/05/car-bomb-kills-five-in-mogadishu-market',
|
|
place: 'Mogadishu, Banaadir, Somalia',
|
|
lat: 2.04,
|
|
lon: 45.34,
|
|
added: '20261105120000',
|
|
}),
|
|
];
|
|
clock.now += 4 * 60_000;
|
|
await ask('/hotspots');
|
|
await settle();
|
|
const later = await ask('/hotspots');
|
|
assert.equal(later.body.hotspots[0].counts.last24h, 3, 'added, not replaced');
|
|
assert.equal(
|
|
gdeltCalls(calls)
|
|
.slice(read)
|
|
.filter((c) => String(c.url).includes('20261105120000')).length,
|
|
1,
|
|
);
|
|
// A day on, the old ones have aged out.
|
|
clock.now += 24 * 3_600_000;
|
|
const dayOn = await ask('/hotspots');
|
|
await settle();
|
|
assert.equal(dayOn.body.hotspots[0].counts.last24h, 0);
|
|
});
|
|
|
|
test('Local names the country at the point and outlines it, with no key', async () => {
|
|
const { ask, calls } = keyed({ env: {} });
|
|
const answer = await ask('/outline', 'at=31.95,35.93&v=4');
|
|
const outline = normalizeOutlinePayload(answer.body);
|
|
assert.deepEqual(outline.area, {
|
|
key: 'JO',
|
|
name: 'Jordan',
|
|
kind: 'country',
|
|
country: 'JO',
|
|
});
|
|
assert.ok(outline.polygons.length > 0);
|
|
const sea = await ask('/outline', 'at=0,-30&v=4');
|
|
assert.equal(normalizeOutlinePayload(sea.body).area, null);
|
|
const developing = await ask('/outline', 'country=UA&v=4');
|
|
assert.ok(developing.body.polygons.length > 0);
|
|
assert.equal(calls.length, 0);
|
|
});
|
|
|
|
test("Local in a country is its outlets' everyday news, pinned inside it, plus the Conflicts reports there", async () => {
|
|
const { ask, calls } = keyed();
|
|
const answer = await ask(
|
|
'/feed',
|
|
'hotspot=all&geo=1&today=1&events=1&limit=40&at=31.95,35.93',
|
|
);
|
|
assert.equal(answer.status, 200);
|
|
const search = calls.find((c) => host(c) === 'api.worldnewsapi.com');
|
|
assert.equal(search.url.pathname, '/search-news');
|
|
assert.equal(search.url.searchParams.get('source-country'), 'jo');
|
|
assert.equal(search.url.searchParams.has('categories'), false);
|
|
assert.equal(
|
|
search.url.searchParams.get('earliest-publish-date'),
|
|
'2026-11-04 12:00:00',
|
|
);
|
|
assert.equal(search.url.searchParams.has('api-key'), false);
|
|
assert.equal(search.headers['x-api-key'], 'wna-key');
|
|
|
|
const { reports } = normalizeFeedPayload(answer.body);
|
|
assert.deepEqual(
|
|
reports.map((r) => r.id),
|
|
['lum-102', 'wna-9001', 'wna-9002'],
|
|
);
|
|
const [, visit, exploration] = reports;
|
|
assert.equal(visit.category, 'political');
|
|
assert.equal(visit.relevance, 'minor');
|
|
assert.equal(visit.publisher, 'daily.example');
|
|
assert.equal(visit.snippet, null, 'titles and links only');
|
|
assert.equal(visit.geo.precision, 'country');
|
|
assert.equal(visit.geo.place, 'Jordan');
|
|
assert.equal(visit.event.image.publisher, 'daily.example');
|
|
assert.equal(exploration.category, 'economy');
|
|
|
|
// The point each is pinned at lies in the area Local outlines.
|
|
const outline = normalizeOutlinePayload(
|
|
(await ask('/outline', 'at=31.95,35.93&v=4')).body,
|
|
);
|
|
const { inPolygons } = await import('../../../src/layers/news/model.js');
|
|
for (const report of reports)
|
|
assert.ok(inPolygons(outline.polygons, report.pins[0]), report.id);
|
|
});
|
|
|
|
test('a disputed area is named as itself: outlined alone, left out of the country around it, read from no country', async () => {
|
|
const { ask, calls, settle } = keyed();
|
|
const crimea = normalizeOutlinePayload(
|
|
(await ask('/outline', 'at=44.95,34.1&v=4')).body,
|
|
);
|
|
assert.deepEqual(crimea.area, {
|
|
key: 'disputed-crimea',
|
|
name: 'Crimea',
|
|
kind: 'disputed',
|
|
country: null,
|
|
});
|
|
const { inPolygons } = await import('../../../src/layers/news/model.js');
|
|
const russia = normalizeOutlinePayload(
|
|
(await ask('/outline', 'at=55.75,37.6&v=4')).body,
|
|
);
|
|
assert.equal(russia.area.name, 'Russia');
|
|
assert.equal(inPolygons(russia.polygons, { lat: 44.95, lon: 34.1 }), false);
|
|
assert.equal(inPolygons(russia.polygons, { lat: 55.75, lon: 37.6 }), true);
|
|
// Local in Crimea: no national outlets asked, the Conflicts reports there.
|
|
const local = await ask('/feed', 'hotspot=all&at=44.95,34.1');
|
|
assert.equal(local.status, 200);
|
|
assert.ok(calls.every((c) => host(c) !== 'api.worldnewsapi.com'));
|
|
await settle();
|
|
});
|
|
|
|
test("GDELT's places are named by their town and the area there, not its old regions", () => {
|
|
const named = (lat, lon, place, precision = 'city') =>
|
|
gdeltPlace({ lat, lon, place, precision }).place;
|
|
assert.equal(
|
|
named(31.43, 34.39, 'Bureij, Yerushalayim, Israel'),
|
|
'Bureij, Palestine',
|
|
);
|
|
assert.equal(
|
|
named(9.07, 7.48, 'Abuja, Abuja Federal Capital Territory, Niger'),
|
|
'Abuja, Nigeria',
|
|
);
|
|
assert.equal(
|
|
named(44.95, 34.1, 'Simferopol, Crimea, Ukraine'),
|
|
'Simferopol, Crimea',
|
|
);
|
|
assert.equal(named(48.4, 31.2, 'Ukraine', 'country'), 'Ukraine');
|
|
});
|
|
|
|
test("a country's news is read once per half hour and Liveuamap's feed once per five minutes", async () => {
|
|
const { ask, calls, clock } = keyed();
|
|
const local = 'hotspot=all&at=31.95,35.93';
|
|
await ask('/feed', local);
|
|
await ask('/feed', local);
|
|
await ask('/hotspots');
|
|
const count = (name) => calls.filter((c) => host(c) === name).length;
|
|
assert.equal(count('api.worldnewsapi.com'), 1);
|
|
assert.equal(count('a.liveuamap.com'), 1);
|
|
clock.now += 6 * 60_000;
|
|
await ask('/feed', local);
|
|
assert.equal(count('api.worldnewsapi.com'), 1);
|
|
assert.equal(count('a.liveuamap.com'), 2);
|
|
clock.now += 25 * 60_000;
|
|
await ask('/feed', local);
|
|
assert.equal(count('api.worldnewsapi.com'), 2);
|
|
});
|
|
|
|
test('Local without its key says which key it needs and reads nothing', async () => {
|
|
const noLocal = keyed({ env: { LIVEUAMAP_API_KEY: 'live-key' } });
|
|
assert.deepEqual(await noLocal.ask('/feed', 'at=31.95,35.93'), {
|
|
status: 503,
|
|
body: { error: 'no_key', key: 'worldnewsapi' },
|
|
});
|
|
assert.ok(noLocal.calls.every((c) => host(c) !== 'api.worldnewsapi.com'));
|
|
// At sea Local is the Conflicts reports by distance, keyless.
|
|
const sea = await keyed({ env: {} }).ask('/feed', 'hotspot=all&at=0,-30');
|
|
assert.equal(sea.status, 200);
|
|
});
|
|
|
|
test('a refused Liveuamap key leaves GDELT; World News API refusing names its key', async () => {
|
|
const logs = [];
|
|
const refused = keyed({ answers: { liveuamap: 401, worldnews: 403 }, logs });
|
|
const conflicts = await refused.ask('/hotspots');
|
|
assert.equal(conflicts.status, 200);
|
|
assert.equal(conflicts.body.hotspots[0].counts.last24h, 2);
|
|
assert.deepEqual(await refused.ask('/feed', 'at=31.95,35.93'), {
|
|
status: 503,
|
|
body: { error: 'key_rejected', key: 'worldnewsapi' },
|
|
});
|
|
// A refused key is logged once, however often it is tried.
|
|
for (let i = 0; i < 3; i++) {
|
|
refused.clock.now += 61_000;
|
|
assert.equal((await refused.ask('/hotspots')).status, 200);
|
|
}
|
|
assert.equal(
|
|
logs.filter((line) => line.includes('LIVEUAMAP_API_KEY was refused'))
|
|
.length,
|
|
1,
|
|
);
|
|
assert.ok(gdeltCalls(refused.calls).length > 0);
|
|
assert.ok(
|
|
refused.calls.filter((c) => host(c) === 'a.liveuamap.com').length > 1,
|
|
);
|
|
// Neither key nor answer body reaches the log.
|
|
assert.ok(logs.length > 0);
|
|
for (const line of logs) assert.doesNotMatch(line, /live-key|wna-key|ABC/);
|
|
await refused.settle();
|
|
});
|
|
|
|
test('a failing GDELT is tried again after a minute, and what it read is kept', async () => {
|
|
const answers = { gdelt: 503 };
|
|
const flaky = keyed({ env: {}, answers });
|
|
assert.deepEqual(await flaky.ask('/hotspots'), {
|
|
status: 502,
|
|
body: { error: 'news_upstream_error' },
|
|
});
|
|
const tried = gdeltCalls(flaky.calls).length;
|
|
await flaky.ask('/hotspots');
|
|
assert.equal(gdeltCalls(flaky.calls).length, tried, 'not within a minute');
|
|
flaky.clock.now += 61_000;
|
|
delete answers.gdelt;
|
|
assert.equal((await flaky.ask('/hotspots')).status, 200);
|
|
await flaky.settle();
|
|
answers.gdelt = 500;
|
|
flaky.clock.now += 6 * 60_000;
|
|
const kept = await flaky.ask('/hotspots');
|
|
await flaky.settle();
|
|
assert.equal(kept.status, 200);
|
|
assert.equal(kept.body.hotspots[0].counts.last24h, 2);
|
|
});
|
|
|
|
test('a new key drops what the old one read', async () => {
|
|
const env = { LIVEUAMAP_API_KEY: 'old', WORLDNEWSAPI_KEY: 'wna-key' };
|
|
const { ask, calls } = keyed({ env });
|
|
await ask('/hotspots');
|
|
env.LIVEUAMAP_API_KEY = 'new';
|
|
await ask('/hotspots');
|
|
assert.deepEqual(
|
|
calls
|
|
.filter((c) => host(c) === 'a.liveuamap.com')
|
|
.map((c) => c.url.searchParams.get('key')),
|
|
['old', 'new'],
|
|
);
|
|
});
|
|
|
|
test('each key is one the key registry names', () => {
|
|
for (const [id, envVar] of Object.entries(NEWS_KEYS))
|
|
assert.deepEqual(
|
|
KEY_SETUP_KEYS.find((entry) => entry.id === id)?.envVars,
|
|
[envVar],
|
|
id,
|
|
);
|
|
});
|
|
|
|
const MINUTE = 60_000;
|
|
const count = (calls, name) => calls.filter((c) => host(c) === name).length;
|
|
const UPSTREAM_ERROR = { status: 502, body: { error: 'news_upstream_error' } };
|
|
const KEY_QUOTA = {
|
|
status: 503,
|
|
body: { error: 'key_quota', key: 'worldnewsapi' },
|
|
};
|
|
|
|
test('exports never published are given up on, not looked for back to back', async () => {
|
|
// One export of the day has events; GDELT answers 404 for every other.
|
|
const quarter = exportName('20261105114500');
|
|
const { ask, calls, clock, settle } = keyed({
|
|
env: {},
|
|
// GDELT's list names the newest; every other export it answers 404.
|
|
answers: {
|
|
exports: { [quarter]: EXPORTS[quarter] },
|
|
unlisted: 404,
|
|
lastupdate: '20261105120000',
|
|
},
|
|
});
|
|
assert.equal((await ask('/hotspots')).body.hotspots[0].counts.last24h, 2);
|
|
await settle();
|
|
// A round that brings nothing in is the last until the next five minutes.
|
|
let seen = gdeltCalls(calls).length;
|
|
assert.ok(seen <= 2 * 12, `${seen} at first`);
|
|
const steps = [];
|
|
for (let i = 0; i < 20; i++) {
|
|
clock.now += 5 * MINUTE;
|
|
assert.equal((await ask('/hotspots')).status, 200);
|
|
await settle();
|
|
steps.push(gdeltCalls(calls).length - seen);
|
|
seen = gdeltCalls(calls).length;
|
|
}
|
|
// One round each five minutes, until the day has been looked through
|
|
// once; then only the exports of the last hour and a half (GDELT's CDN
|
|
// keeps a 404 for an hour, so a late one may still come).
|
|
assert.ok(
|
|
steps.every((n) => n <= 12),
|
|
steps.join(),
|
|
);
|
|
assert.ok(
|
|
steps.slice(-3).every((n) => n <= 7),
|
|
steps.join(),
|
|
);
|
|
assert.ok(seen <= 2 * 12 + 20 * 12, String(seen));
|
|
});
|
|
|
|
test('GDELT with no export read at all is a 502, not an empty Conflicts', async () => {
|
|
const { ask } = keyed({ env: {}, answers: { exports: {}, unlisted: 404 } });
|
|
assert.deepEqual(await ask('/hotspots'), UPSTREAM_ERROR);
|
|
assert.deepEqual(await ask('/feed', CONFLICTS), UPSTREAM_ERROR);
|
|
});
|
|
|
|
test('GDELT failing for two hours answers 502; until then what it read is served', async () => {
|
|
const answers = {};
|
|
const { ask, clock, settle } = keyed({ env: {}, answers });
|
|
await ask('/hotspots');
|
|
await settle();
|
|
answers.gdelt = 503;
|
|
clock.now += 60 * MINUTE;
|
|
const hour = await ask('/hotspots');
|
|
assert.equal(hour.status, 200);
|
|
assert.equal(hour.body.hotspots[0].counts.last24h, 2);
|
|
clock.now += 61 * MINUTE;
|
|
assert.deepEqual(await ask('/hotspots'), UPSTREAM_ERROR);
|
|
assert.deepEqual(await ask('/feed', CONFLICTS), UPSTREAM_ERROR);
|
|
await settle();
|
|
});
|
|
|
|
test('a failed GKG read keeps the events, titled by their words, and is tried again', async () => {
|
|
const answers = { gkg: 500 };
|
|
const { ask, calls, clock, logs, settle } = keyed({ env: {}, answers });
|
|
const kharkiv = (answer) =>
|
|
normalizeFeedPayload(answer.body).reports.find((r) => r.id === 'gdelt-5');
|
|
await ask('/hotspots');
|
|
await settle();
|
|
const read = await ask('/feed', CONFLICTS);
|
|
assert.equal(read.status, 200);
|
|
assert.equal(read.body.reports.length, 2);
|
|
assert.equal(
|
|
kharkiv(read).title,
|
|
'Russian drones attack Kharkiv overnight three killed',
|
|
);
|
|
assert.ok(logs.some((line) => line.includes('titles read failed')));
|
|
// Each GKG export once, not with every read of the day.
|
|
assert.equal(gkgCalls(calls).length, 11);
|
|
delete answers.gkg;
|
|
clock.now += 5 * MINUTE;
|
|
await ask('/hotspots');
|
|
await settle();
|
|
assert.equal(
|
|
kharkiv(await ask('/feed', CONFLICTS)).title,
|
|
'Russian drones attack Kharkiv overnight, three killed',
|
|
);
|
|
});
|
|
|
|
test('Local where only Conflicts reports speak answers 502 when they fail; in a country the outlets stay', async () => {
|
|
const { ask, calls } = keyed({ answers: { gdelt: 503, liveuamap: 500 } });
|
|
// Crimea, and Somaliland (a territory without a code).
|
|
assert.deepEqual(
|
|
await ask('/feed', 'hotspot=all&at=44.95,34.1'),
|
|
UPSTREAM_ERROR,
|
|
);
|
|
assert.deepEqual(
|
|
await ask('/feed', 'hotspot=all&at=9.56,44.06'),
|
|
UPSTREAM_ERROR,
|
|
);
|
|
assert.equal(count(calls, 'api.worldnewsapi.com'), 0);
|
|
const jordan = await ask('/feed', 'hotspot=all&at=31.95,35.93');
|
|
assert.equal(jordan.status, 200);
|
|
assert.deepEqual(
|
|
jordan.body.reports.map((r) => r.id),
|
|
['wna-9001', 'wna-9002'],
|
|
);
|
|
});
|
|
|
|
test('a territory without a code is named as itself, not read as sea', async () => {
|
|
const { ask, calls, settle } = keyed();
|
|
for (const [at, key, name] of [
|
|
['9.56,44.06', 'disputed-somaliland', 'Somaliland'],
|
|
['35.12,33.94', 'disputed-northern-cyprus', 'Northern Cyprus'],
|
|
]) {
|
|
const outline = normalizeOutlinePayload(
|
|
(await ask('/outline', `at=${at}&v=4`)).body,
|
|
);
|
|
assert.deepEqual(outline.area, {
|
|
key,
|
|
name,
|
|
kind: 'disputed',
|
|
country: null,
|
|
});
|
|
assert.ok(outline.polygons.length > 0);
|
|
}
|
|
assert.equal(
|
|
gdeltPlace({
|
|
lat: 9.56,
|
|
lon: 44.06,
|
|
place: 'Hargeysa, Somalia',
|
|
precision: 'city',
|
|
}).place,
|
|
'Hargeysa, Somaliland',
|
|
);
|
|
const local = await ask('/feed', 'hotspot=all&at=9.56,44.06');
|
|
assert.equal(local.status, 200);
|
|
assert.equal(count(calls, 'api.worldnewsapi.com'), 0);
|
|
await settle();
|
|
});
|
|
|
|
test('a stale Liveuamap or World News API read is served while it is read again', async () => {
|
|
const answers = {};
|
|
const { ask, calls, clock, settle } = keyed({ answers });
|
|
const local = 'hotspot=all&at=31.95,35.93';
|
|
const ids = (answer) => answer.body.reports.map((r) => r.id);
|
|
assert.deepEqual(ids(await ask('/feed', local)), [
|
|
'lum-102',
|
|
'wna-9001',
|
|
'wna-9002',
|
|
]);
|
|
// Both read again, and held open: the answer does not wait for them.
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const later = (payload) => () =>
|
|
held.then(() => new Response(JSON.stringify(payload), { status: 200 }));
|
|
answers.liveuamap = later({ venues: [] });
|
|
answers.worldnews = later({ news: [] });
|
|
clock.now += 31 * MINUTE;
|
|
const served = await ask('/feed', local);
|
|
assert.equal(served.status, 200);
|
|
assert.deepEqual(ids(served), ['lum-102', 'wna-9001', 'wna-9002']);
|
|
assert.equal(count(calls, 'a.liveuamap.com'), 2);
|
|
assert.equal(count(calls, 'api.worldnewsapi.com'), 2);
|
|
// Once read, the new answers are served.
|
|
release();
|
|
await settle();
|
|
assert.deepEqual(ids(await ask('/feed', local)), []);
|
|
// A country not read before is waited for.
|
|
answers.worldnews = WORLD_NEWS;
|
|
assert.equal(
|
|
(await ask('/feed', 'hotspot=all&at=48.85,2.35')).body.reports.length,
|
|
2,
|
|
);
|
|
});
|
|
|
|
test('a read in flight with a replaced key is not the answer for the new one', async () => {
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const env = { LIVEUAMAP_API_KEY: 'old' };
|
|
const answers = {
|
|
liveuamap: () =>
|
|
held.then(() => new Response(JSON.stringify({ venues: [] }))),
|
|
};
|
|
const { ask, calls, settle } = keyed({ env, answers });
|
|
const before = ask('/hotspots');
|
|
env.LIVEUAMAP_API_KEY = 'new';
|
|
delete answers.liveuamap;
|
|
const after = await ask('/hotspots');
|
|
assert.equal(after.body.hotspots[0].counts.last24h, 5);
|
|
assert.deepEqual(
|
|
calls
|
|
.filter((c) => host(c) === 'a.liveuamap.com')
|
|
.map((c) => c.url.searchParams.get('key')),
|
|
['old', 'new'],
|
|
);
|
|
release();
|
|
await before;
|
|
// The old key's late answer does not replace the new one's.
|
|
assert.equal((await ask('/hotspots')).body.hotspots[0].counts.last24h, 5);
|
|
await settle();
|
|
});
|
|
|
|
test("World News API's reads are a daily budget: spent, Local says key_quota and serves what it read", async () => {
|
|
const { ask, calls, clock, logs, settle } = keyed({
|
|
env: { WORLDNEWSAPI_KEY: 'wna-key', WORLDNEWSAPI_DAILY_READS: '2' },
|
|
});
|
|
const at = (point) => ask('/feed', `hotspot=all&at=${point}`);
|
|
assert.equal((await at('31.95,35.93')).status, 200);
|
|
assert.equal((await at('48.85,2.35')).status, 200);
|
|
// Germany would be the third read today.
|
|
assert.deepEqual(await at('52.52,13.4'), KEY_QUOTA);
|
|
assert.equal(count(calls, 'api.worldnewsapi.com'), 2);
|
|
// Jordan, read earlier, is served from what was read.
|
|
clock.now += 31 * MINUTE;
|
|
const kept = await at('31.95,35.93');
|
|
assert.equal(kept.status, 200);
|
|
assert.equal(kept.body.reports.length, 2);
|
|
assert.equal(count(calls, 'api.worldnewsapi.com'), 2);
|
|
assert.equal(
|
|
logs.filter((line) => line.includes('reads for today are spent')).length,
|
|
1,
|
|
);
|
|
// A new UTC day has its own reads.
|
|
clock.now = Date.parse('2026-11-06T00:30:00Z');
|
|
assert.equal((await at('52.52,13.4')).status, 200);
|
|
assert.equal(count(calls, 'api.worldnewsapi.com'), 3);
|
|
await settle();
|
|
});
|
|
|
|
test('World News API answering 402 is key_quota, and nothing more is asked today; a 429 is no quota', async () => {
|
|
const paid = keyed({ answers: { worldnews: 402 } });
|
|
assert.deepEqual(await paid.ask('/feed', 'at=31.95,35.93'), KEY_QUOTA);
|
|
paid.clock.now += 2 * MINUTE;
|
|
assert.deepEqual(await paid.ask('/feed', 'at=48.85,2.35'), KEY_QUOTA);
|
|
assert.equal(count(paid.calls, 'api.worldnewsapi.com'), 1);
|
|
// A 429 is its per-second limit: an ordinary failure, tried again soon.
|
|
const busy = keyed({ answers: { worldnews: 429 } });
|
|
assert.deepEqual(await busy.ask('/feed', 'at=31.95,35.93'), UPSTREAM_ERROR);
|
|
await paid.settle();
|
|
await busy.settle();
|
|
});
|
|
|
|
test('one unreadable export is given up on and never holds the rest back', async () => {
|
|
const bad = exportName('20261105114500');
|
|
const { ask, calls, clock, settle } = keyed({
|
|
env: {},
|
|
answers: { corrupt: [bad] },
|
|
});
|
|
// The other export's stories are served, from the first answer on (its
|
|
// copy of the Kharkiv story among them).
|
|
const first = await ask('/hotspots');
|
|
assert.equal(first.status, 200);
|
|
assert.equal(first.body.hotspots[0].counts.last24h, 1);
|
|
await settle();
|
|
clock.now += 6 * MINUTE;
|
|
assert.equal((await ask('/hotspots')).status, 200);
|
|
await settle();
|
|
const asked = gdeltCalls(calls).filter((c) => String(c.url) === bad).length;
|
|
assert.equal(asked, 1, 'an unreadable file is not fetched again');
|
|
});
|
|
|
|
test('no export later than the newest GDELT lists is asked for (its CDN keeps a 404 an hour)', async () => {
|
|
const { ask, calls, settle } = keyed({
|
|
env: {},
|
|
answers: { lastupdate: '20261105114500' },
|
|
});
|
|
assert.equal((await ask('/hotspots')).status, 200);
|
|
await settle();
|
|
assert.ok(
|
|
!gdeltCalls(calls).some(
|
|
(c) => String(c.url) === exportName('20261105120000'),
|
|
),
|
|
);
|
|
assert.ok(
|
|
gdeltCalls(calls).some(
|
|
(c) => String(c.url) === exportName('20261105114500'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('a town a disputed area is named after is named as the town alone', () => {
|
|
assert.equal(
|
|
gdeltPlace({
|
|
lat: 31.7667,
|
|
lon: 35.2333,
|
|
place: 'Jerusalem, Israel (general), Israel',
|
|
precision: 'city',
|
|
}).place,
|
|
'Jerusalem',
|
|
);
|
|
});
|
|
|
|
test('World News API is credited on each of its reports', async () => {
|
|
const { ask, settle } = keyed();
|
|
const { reports } = normalizeFeedPayload(
|
|
(await ask('/feed', 'hotspot=all&at=31.95,35.93')).body,
|
|
);
|
|
const national = reports.filter((r) => r.kind === 'worldnewsapi');
|
|
assert.ok(national.length > 0);
|
|
for (const r of national) assert.equal(r.attribution, 'World News API');
|
|
await settle();
|
|
});
|
|
|
|
test('GDELT down for a few minutes gives up on no export: they are all read once it is back', async () => {
|
|
const answers = { gdelt: 503 };
|
|
const { ask, calls, clock, settle } = keyed({ env: {}, answers });
|
|
for (let i = 0; i < 4; i++) {
|
|
await ask('/hotspots');
|
|
await settle();
|
|
clock.now += 61_000;
|
|
}
|
|
delete answers.gdelt;
|
|
clock.now += 5 * MINUTE;
|
|
const back = await ask('/hotspots');
|
|
await settle();
|
|
assert.equal(back.status, 200);
|
|
assert.equal(back.body.hotspots[0].counts.last24h, 2);
|
|
assert.ok(
|
|
gdeltCalls(calls).some(
|
|
(c) => String(c.url) === exportName('20261105114500'),
|
|
),
|
|
);
|
|
});
|
|
|
|
test('a GDELT outage answering 404, its list down too, gives up on no export', async () => {
|
|
const answers = { exports: {}, unlisted: 404 };
|
|
const { ask, clock, settle } = keyed({ env: {}, answers });
|
|
// Long enough for the stories' exports to be 90 minutes old.
|
|
for (let i = 0; i < 20; i++) {
|
|
await ask('/hotspots');
|
|
await settle();
|
|
clock.now += 5 * MINUTE;
|
|
}
|
|
delete answers.exports;
|
|
delete answers.unlisted;
|
|
for (let i = 0; i < 3; i++) {
|
|
clock.now += 5 * MINUTE;
|
|
await ask('/hotspots');
|
|
await settle();
|
|
}
|
|
const back = await ask('/hotspots');
|
|
assert.equal(back.status, 200);
|
|
assert.equal(back.body.hotspots[0].counts.last24h, 2);
|
|
});
|
|
|
|
test('a host answering junk for every file gives up on none of them', async () => {
|
|
const answers = {
|
|
corrupt: [exportName('20261105114500'), exportName('20261105113000')],
|
|
exports: {},
|
|
unlisted: 404,
|
|
};
|
|
const { ask, clock, settle } = keyed({ env: {}, answers });
|
|
await ask('/hotspots');
|
|
await settle();
|
|
answers.corrupt = [];
|
|
delete answers.exports;
|
|
delete answers.unlisted;
|
|
clock.now += 6 * MINUTE;
|
|
await ask('/hotspots');
|
|
await settle();
|
|
const back = await ask('/hotspots');
|
|
assert.equal(back.body.hotspots[0].counts.last24h, 2);
|
|
});
|
|
|
|
test('a failing file gets one strike a round, not three within seconds', async () => {
|
|
const flaky = exportName('20261105114500');
|
|
const answers = { failing: [flaky] };
|
|
const { ask, calls, clock, settle } = keyed({ env: {}, answers });
|
|
// The startup fill runs several rounds a few seconds apart.
|
|
await ask('/hotspots');
|
|
await settle();
|
|
// GDELT answers again for it before three rounds five minutes apart.
|
|
answers.failing = [];
|
|
clock.now += 6 * MINUTE;
|
|
await ask('/hotspots');
|
|
await settle();
|
|
const back = await ask('/hotspots');
|
|
assert.equal(back.body.hotspots[0].counts.last24h, 2);
|
|
assert.ok(
|
|
gdeltCalls(calls).filter((c) => String(c.url) === flaky).length >= 2,
|
|
);
|
|
});
|
|
|
|
test("a country's own news keeps World News API's category and no severity, whatever its words", async () => {
|
|
const { worldNewsReport } = await import('./keyed.js');
|
|
const { countryByCode } = await import('./areas.js');
|
|
const country = countryByCode('JO');
|
|
const read = (title, category) =>
|
|
worldNewsReport(
|
|
{
|
|
id: 1,
|
|
title,
|
|
url: 'https://times.example/x',
|
|
publisher: 'times.example',
|
|
publishedAt: '2026-11-05T08:00:00.000Z',
|
|
category,
|
|
},
|
|
country,
|
|
);
|
|
const rankings = read(
|
|
'Jordan Advances in Global Digital Rankings as AI Infrastructure Expands',
|
|
'technology',
|
|
);
|
|
assert.equal(rankings.category, 'other');
|
|
assert.equal(rankings.severity, undefined);
|
|
assert.equal(
|
|
read('Startup advances to the next funding round', 'business').category,
|
|
'economy',
|
|
);
|
|
assert.equal(read('King visits Amman', 'politics').category, 'political');
|
|
assert.equal(read('Parliament session opens', null).category, 'other');
|
|
});
|
|
|
|
test(
|
|
'a first read slower than the browser waits answers unavailable, and the next ask is served from it',
|
|
{ timeout: 5_000 },
|
|
async () => {
|
|
const fake = upstream();
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const news = createKeyedNews({
|
|
env: () => ({ WORLDNEWSAPI_KEY: 'wna-key' }),
|
|
fetchImpl: async (url, init) => {
|
|
if (new URL(url).host === 'api.worldnewsapi.com') await held;
|
|
return fake.fetchImpl(url, init);
|
|
},
|
|
now: () => NOW,
|
|
log: () => {},
|
|
later: () => {},
|
|
firstAnswerMs: 20,
|
|
});
|
|
const local = () =>
|
|
news.answer('/feed', new URLSearchParams('hotspot=all&at=31.95,35.93'));
|
|
assert.deepEqual(await local(), UPSTREAM_ERROR);
|
|
// The read went on, and is not asked again.
|
|
release();
|
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
const served = await local();
|
|
assert.equal(served.status, 200);
|
|
assert.deepEqual(
|
|
served.body.reports.map((r) => r.id),
|
|
['wna-9001', 'wna-9002'],
|
|
);
|
|
assert.equal(count(fake.calls, 'api.worldnewsapi.com'), 1);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a first GDELT read slower than the browser waits answers unavailable, and the next is served from it',
|
|
{ timeout: 5_000 },
|
|
async () => {
|
|
const fake = upstream();
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
const news = createKeyedNews({
|
|
env: () => ({}),
|
|
fetchImpl: async (url, init) => {
|
|
if (new URL(url).host === 'data.gdeltproject.org') await held;
|
|
return fake.fetchImpl(url, init);
|
|
},
|
|
now: () => NOW,
|
|
log: () => {},
|
|
later: () => {},
|
|
firstAnswerMs: 20,
|
|
});
|
|
const hotspots = () => news.answer('/hotspots', new URLSearchParams());
|
|
assert.deepEqual(await hotspots(), UPSTREAM_ERROR);
|
|
release();
|
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
assert.equal((await hotspots()).status, 200);
|
|
},
|
|
);
|
|
|
|
test(
|
|
'a refused key stays refused while its next read is slow',
|
|
{ timeout: 5_000 },
|
|
async () => {
|
|
let release;
|
|
const held = new Promise((resolve) => {
|
|
release = resolve;
|
|
});
|
|
let reads = 0;
|
|
const clock = { now: NOW };
|
|
const fake = upstream({
|
|
worldnews: async () => {
|
|
reads++;
|
|
if (reads > 1) await held;
|
|
return new Response('{"message":"bad key"}', { status: 401 });
|
|
},
|
|
});
|
|
const news = createKeyedNews({
|
|
env: () => ({ WORLDNEWSAPI_KEY: 'wna-key' }),
|
|
fetchImpl: fake.fetchImpl,
|
|
now: () => clock.now,
|
|
log: () => {},
|
|
later: () => {},
|
|
firstAnswerMs: 20,
|
|
});
|
|
const local = () =>
|
|
news.answer('/feed', new URLSearchParams('hotspot=all&at=31.95,35.93'));
|
|
const refused = {
|
|
status: 503,
|
|
body: { error: 'key_rejected', key: 'worldnewsapi' },
|
|
};
|
|
assert.deepEqual(await local(), refused);
|
|
// Past the retry wait it is read again, slowly: still refused meanwhile.
|
|
clock.now += 61_000;
|
|
assert.deepEqual(await local(), refused);
|
|
assert.equal(reads, 2);
|
|
release();
|
|
},
|
|
);
|
|
|
|
test("World News API's news is kept an hour at most, also while it cannot be read again", async () => {
|
|
const answers = {};
|
|
const { ask, clock, settle } = keyed({ answers });
|
|
const local = 'hotspot=all&at=31.95,35.93';
|
|
const own = (answer) =>
|
|
answer.body.reports.filter((r) => r.id.startsWith('wna-')).length;
|
|
assert.equal(own(await ask('/feed', local)), 2);
|
|
// Its terms: a cache of at most an hour, then deleted.
|
|
answers.worldnews = 500;
|
|
clock.now += 59 * MINUTE;
|
|
assert.equal(own(await ask('/feed', local)), 2);
|
|
clock.now += 2 * MINUTE;
|
|
assert.deepEqual(await ask('/feed', local), UPSTREAM_ERROR);
|
|
await settle();
|
|
});
|
|
|
|
test('a World News API read that never reached it is not counted against the day', async () => {
|
|
const answers = {
|
|
worldnews: () => {
|
|
throw new TypeError('fetch failed');
|
|
},
|
|
};
|
|
const { ask, clock, settle } = keyed({
|
|
env: { WORLDNEWSAPI_KEY: 'wna-key', WORLDNEWSAPI_DAILY_READS: '2' },
|
|
answers,
|
|
});
|
|
const paris = () => ask('/feed', 'hotspot=all&at=48.85,2.35');
|
|
for (let i = 0; i < 3; i++) {
|
|
assert.deepEqual(await paris(), UPSTREAM_ERROR);
|
|
clock.now += 2 * MINUTE;
|
|
}
|
|
// Back online: the day's reads are all still there.
|
|
answers.worldnews = WORLD_NEWS;
|
|
assert.equal((await paris()).status, 200);
|
|
// An answer, even a failing one, is a read, and so is a request that
|
|
// timed out, which it may have read: two of them spend the day.
|
|
answers.worldnews = () => {
|
|
throw new DOMException('timed out', 'TimeoutError');
|
|
};
|
|
const berlin = () => ask('/feed', 'hotspot=all&at=52.52,13.4');
|
|
assert.deepEqual(await berlin(), UPSTREAM_ERROR);
|
|
clock.now += 2 * MINUTE;
|
|
assert.deepEqual(await berlin(), KEY_QUOTA);
|
|
await settle();
|
|
});
|
|
|
|
test('Local lists a Conflicts report by any of its pins, not its first alone', async () => {
|
|
const url =
|
|
'https://news.example.com/2026/11/05/drones-hit-kharkiv-and-belgorod-overnight';
|
|
const exports = {
|
|
[exportName('20261105114500')]: [
|
|
eventRow({ id: 1, code: '195', mentions: 9, url }),
|
|
eventRow({
|
|
id: 2,
|
|
code: '195',
|
|
mentions: 2,
|
|
place: "Belgorod, Belgorodskaya Oblast', Russia",
|
|
lat: 50.6,
|
|
lon: 36.6,
|
|
url,
|
|
}),
|
|
],
|
|
};
|
|
const { ask, settle } = keyed({
|
|
env: { WORLDNEWSAPI_KEY: 'wna-key' },
|
|
answers: { exports, worldnews: { news: [] } },
|
|
});
|
|
const urls = async (at) =>
|
|
(await ask('/feed', `hotspot=all&at=${at}`)).body.reports.map((r) => r.url);
|
|
assert.deepEqual(await urls('49.99,36.23'), [url]);
|
|
assert.deepEqual(await urls('50.6,36.6'), [url]);
|
|
await settle();
|
|
});
|
|
|
|
test('the hotspot counts the reports Conflicts lists: no minor ones', async () => {
|
|
const { ask, settle } = keyed({ env: {} });
|
|
const listed = (
|
|
await ask('/feed', 'hotspot=all&today=1&relevance=core,related&limit=60')
|
|
).body.reports;
|
|
const { counts } = (await ask('/hotspots')).body.hotspots[0];
|
|
assert.equal(counts.last24h, listed.length);
|
|
assert.equal(counts.mapped24h, listed.filter((r) => r.geo).length);
|
|
// The council story is among the day's reports, and minor.
|
|
const all = (await ask('/feed', 'hotspot=all&today=1&limit=60')).body.reports;
|
|
assert.ok(all.length > listed.length);
|
|
await settle();
|
|
});
|
|
|
|
test('a disputed area is cut out of the country it lies in, not of the neighbour its first corner touches', () => {
|
|
const outline = (lat, lon) => countryPolygons(areaAt(lat, lon).country);
|
|
for (const [country, at, name, inside] of [
|
|
['Israel', [32.08, 34.78], 'Golan Heights', [33.0, 35.75]],
|
|
['Moldova', [47.0, 28.85], 'Transnistria', [47.2, 29.5]],
|
|
['Egypt', [30.0, 31.2], 'Halayib', [22.5, 35.5]],
|
|
]) {
|
|
const [lat, lon] = inside;
|
|
assert.equal(areaAt(lat, lon).kind, 'disputed', name);
|
|
assert.equal(
|
|
inPolygons(outline(...at), { lat, lon }),
|
|
false,
|
|
`${country} without ${name}`,
|
|
);
|
|
}
|
|
});
|