mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(climate+health):add shared air quality seed and mirrored health/climate RPCs * feat(climate+health):add shared air quality seed and mirrored health/climate RPCs * fix(air-quality): address review findings — TTL, seed-health, FAST_KEYS, shared meta - Raise CACHE_TTL from 3600 to 10800 (3× the 1h cron cadence; gold standard) - Add health:air-quality to api/seed-health.js SEED_DOMAINS so monitoring dashboard tracks freshness - Remove climateAirQuality and healthAirQuality from FAST_KEYS (large station payloads; load in slow batch) - Point climateAirQuality SEED_META to same meta key as healthAirQuality (same seeder run, one source of truth) * fix(bootstrap): move air quality keys to SLOW tier — large station payloads avoid critical-path batch * fix(air-quality): fix malformed OpenAQ URL and remove from bootstrap until panel exists - Drop deprecated first URL attempt (parameters=pm25, order_by=lastUpdated, sort=desc); use correct v3 params (parameters_id=2, sort_order=desc) directly — eliminates guaranteed 4xx retry cycle per page on 20-page crawl - Remove climateAirQuality and healthAirQuality from BOOTSTRAP_CACHE_KEYS, SLOW_KEYS, and BOOTSTRAP_TIERS — no panel consumes these yet; adding thousands of station records to every startup bootstrap is pure payload bloat - Remove normalizeAirQualityPayload helpers from bootstrap.js (no longer called) - Update service wrappers to fetch via RPC directly; re-add bootstrap hydration when a panel actually needs it * fix(air-quality): raise lock TTL to 3600s to cover 20-page crawl worst case 2 OpenAQ calls × 20 pages × (30s timeout × 3 attempts) = 3600s max runtime. Previous 600s TTL allowed concurrent cron runs on any degraded upstream. --------- Co-authored-by: Elie Habib <elie.habib@gmail.com>
232 lines
7.5 KiB
JavaScript
232 lines
7.5 KiB
JavaScript
import { describe, it } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
import {
|
|
buildOpenAqHeaders,
|
|
buildMirrorWriteCommands,
|
|
buildAirQualityPayload,
|
|
buildOpenAqStations,
|
|
buildWaqiStations,
|
|
CLIMATE_AIR_QUALITY_KEY,
|
|
CLIMATE_META_KEY,
|
|
classifyRiskLevel,
|
|
computeUsAqiFromPm25,
|
|
HEALTH_AIR_QUALITY_KEY,
|
|
OPENAQ_META_KEY,
|
|
mergeAirQualityStations,
|
|
} from '../scripts/seed-health-air-quality.mjs';
|
|
|
|
describe('air quality AQI helpers', () => {
|
|
it('maps PM2.5 concentrations onto EPA AQI breakpoints', () => {
|
|
assert.equal(computeUsAqiFromPm25(12.0), 50);
|
|
assert.equal(computeUsAqiFromPm25(35.4), 100);
|
|
assert.equal(computeUsAqiFromPm25(55.4), 150);
|
|
assert.equal(computeUsAqiFromPm25(250.5), 301);
|
|
});
|
|
|
|
it('collapses AQI values into the requested risk buckets', () => {
|
|
assert.equal(classifyRiskLevel(25), 'good');
|
|
assert.equal(classifyRiskLevel(90), 'moderate');
|
|
assert.equal(classifyRiskLevel(220), 'unhealthy');
|
|
assert.equal(classifyRiskLevel(350), 'hazardous');
|
|
});
|
|
|
|
it('requires an OpenAQ API key when building request headers', () => {
|
|
assert.throws(() => buildOpenAqHeaders(''), /OPENAQ_API_KEY/);
|
|
assert.deepEqual(buildOpenAqHeaders('test-key'), {
|
|
Accept: 'application/json',
|
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36',
|
|
'X-API-Key': 'test-key',
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('air quality payload assembly', () => {
|
|
it('filters stale measurements and keeps the freshest reading per location', () => {
|
|
const nowMs = Date.UTC(2026, 3, 3, 12, 0, 0);
|
|
const stations = buildOpenAqStations(
|
|
[
|
|
{
|
|
id: 101,
|
|
locality: 'Delhi',
|
|
country: { code: 'IN' },
|
|
coordinates: { latitude: 28.61, longitude: 77.21 },
|
|
},
|
|
{
|
|
id: 202,
|
|
locality: 'Paris',
|
|
country: { code: 'FR' },
|
|
coordinates: { latitude: 48.85, longitude: 2.35 },
|
|
},
|
|
],
|
|
[
|
|
{
|
|
locationsId: 101,
|
|
value: 82.4,
|
|
datetime: { utc: new Date(nowMs - (10 * 60 * 1000)).toISOString() },
|
|
coordinates: { latitude: 28.61, longitude: 77.21 },
|
|
parameter: { name: 'pm25' },
|
|
},
|
|
{
|
|
locationsId: 101,
|
|
value: 45.2,
|
|
datetime: { utc: new Date(nowMs - (40 * 60 * 1000)).toISOString() },
|
|
coordinates: { latitude: 28.61, longitude: 77.21 },
|
|
parameter: { name: 'pm25' },
|
|
},
|
|
{
|
|
locationsId: 202,
|
|
value: 18.7,
|
|
datetime: { utc: new Date(nowMs - (3 * 60 * 60 * 1000)).toISOString() },
|
|
coordinates: { latitude: 48.85, longitude: 2.35 },
|
|
parameter: { name: 'pm25' },
|
|
},
|
|
],
|
|
nowMs,
|
|
);
|
|
|
|
assert.equal(stations.length, 1);
|
|
assert.equal(stations[0].city, 'Delhi');
|
|
assert.equal(stations[0].countryCode, 'IN');
|
|
assert.equal(stations[0].aqi, computeUsAqiFromPm25(82.4));
|
|
assert.equal(stations[0].riskLevel, 'unhealthy');
|
|
});
|
|
|
|
it('parses WAQI entries when PM2.5 and timestamps are present', () => {
|
|
const nowMs = Date.UTC(2026, 3, 3, 12, 0, 0);
|
|
const stations = buildWaqiStations(
|
|
[
|
|
{
|
|
lat: 25.2,
|
|
lon: 55.27,
|
|
aqi: '180',
|
|
dominentpol: 'pm25',
|
|
iaqi: { pm25: { v: 74.1 } },
|
|
station: {
|
|
name: 'Dubai, AE',
|
|
time: new Date(nowMs - (20 * 60 * 1000)).toISOString(),
|
|
},
|
|
},
|
|
],
|
|
nowMs,
|
|
);
|
|
|
|
assert.equal(stations.length, 1);
|
|
assert.equal(stations[0].city, 'Dubai');
|
|
assert.equal(stations[0].countryCode, 'AE');
|
|
assert.equal(stations[0].source, 'WAQI');
|
|
});
|
|
|
|
it('merges OpenAQ and WAQI stations without duplicating identical locations', () => {
|
|
const openAqStations = [
|
|
{ city: 'Paris', countryCode: 'FR', lat: 48.8566, lng: 2.3522, pm25: 18, aqi: 64, riskLevel: 'moderate', pollutant: 'pm25', measuredAt: 1000, source: 'OpenAQ' },
|
|
];
|
|
const waqiStations = [
|
|
{ city: 'Paris', countryCode: 'FR', lat: 48.8571, lng: 2.3519, pm25: 20, aqi: 68, riskLevel: 'moderate', pollutant: 'pm25', measuredAt: 1100, source: 'WAQI' },
|
|
{ city: 'Dubai', countryCode: 'AE', lat: 25.2048, lng: 55.2708, pm25: 50, aqi: 137, riskLevel: 'unhealthy', pollutant: 'pm25', measuredAt: 1200, source: 'WAQI' },
|
|
];
|
|
|
|
const merged = mergeAirQualityStations(openAqStations, waqiStations);
|
|
|
|
assert.equal(merged.length, 2);
|
|
assert.equal(merged[0].city, 'Dubai');
|
|
});
|
|
|
|
it('builds the final payload with fetchedAt and sorted stations', () => {
|
|
const nowMs = Date.UTC(2026, 3, 3, 12, 0, 0);
|
|
const payload = buildAirQualityPayload({
|
|
locations: [
|
|
{
|
|
id: 11,
|
|
locality: 'Lahore',
|
|
country: { code: 'PK' },
|
|
coordinates: { latitude: 31.52, longitude: 74.36 },
|
|
},
|
|
],
|
|
latestMeasurements: [
|
|
{
|
|
locationsId: 11,
|
|
value: 145.6,
|
|
datetime: { utc: new Date(nowMs - (15 * 60 * 1000)).toISOString() },
|
|
coordinates: { latitude: 31.52, longitude: 74.36 },
|
|
parameter: { name: 'pm25' },
|
|
},
|
|
],
|
|
waqiStations: [],
|
|
nowMs,
|
|
});
|
|
|
|
assert.equal(payload.fetchedAt, nowMs);
|
|
assert.equal(payload.stations.length, 1);
|
|
assert.equal(payload.stations[0].city, 'Lahore');
|
|
assert.equal(payload.stations[0].country_code, 'PK');
|
|
assert.equal(payload.stations[0].risk_level, 'unhealthy');
|
|
assert.equal(typeof payload.stations[0].measured_at, 'number');
|
|
assert.equal('riskLevel' in payload.stations[0], false);
|
|
});
|
|
|
|
it('normalizes legacy raw waqiEntries before merging them into the payload', () => {
|
|
const nowMs = Date.UTC(2026, 3, 3, 12, 0, 0);
|
|
const payload = buildAirQualityPayload({
|
|
locations: [],
|
|
latestMeasurements: [],
|
|
waqiEntries: [
|
|
{
|
|
lat: 25.2,
|
|
lon: 55.27,
|
|
aqi: '180',
|
|
dominentpol: 'pm25',
|
|
iaqi: { pm25: { v: 74.1 } },
|
|
station: {
|
|
name: 'Dubai, AE',
|
|
time: new Date(nowMs - (20 * 60 * 1000)).toISOString(),
|
|
},
|
|
},
|
|
],
|
|
nowMs,
|
|
});
|
|
|
|
assert.equal(payload.fetchedAt, nowMs);
|
|
assert.equal(payload.stations.length, 1);
|
|
assert.equal(payload.stations[0].city, 'Dubai');
|
|
assert.equal(payload.stations[0].country_code, 'AE');
|
|
assert.equal(payload.stations[0].risk_level, 'unhealthy');
|
|
assert.equal(typeof payload.stations[0].measured_at, 'number');
|
|
assert.equal('riskLevel' in payload.stations[0], false);
|
|
});
|
|
|
|
it('builds one Redis pipeline containing both mirrored keys and both seed-meta keys', () => {
|
|
const payload = {
|
|
stations: [
|
|
{
|
|
city: 'Delhi',
|
|
country_code: 'IN',
|
|
lat: 28.61,
|
|
lng: 77.21,
|
|
pm25: 80.4,
|
|
aqi: 164,
|
|
risk_level: 'unhealthy',
|
|
pollutant: 'pm25',
|
|
measured_at: 123,
|
|
source: 'OpenAQ',
|
|
},
|
|
],
|
|
fetchedAt: 456,
|
|
};
|
|
|
|
const commands = buildMirrorWriteCommands(payload, 3600, 789, 'source-v1');
|
|
|
|
assert.equal(commands.length, 4);
|
|
assert.deepEqual(commands.map((command) => command[1]), [
|
|
HEALTH_AIR_QUALITY_KEY,
|
|
CLIMATE_AIR_QUALITY_KEY,
|
|
OPENAQ_META_KEY,
|
|
CLIMATE_META_KEY,
|
|
]);
|
|
assert.equal(commands[0][4], '3600');
|
|
assert.equal(commands[1][4], '3600');
|
|
assert.match(String(commands[2][2]), /"recordCount":1/);
|
|
assert.match(String(commands[3][2]), /"sourceVersion":"source-v1"/);
|
|
});
|
|
});
|