mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
feat(eia): gold-standard /api/eia/petroleum (Railway seed → Redis → Vercel reads only) (#3161)
* feat(eia): move /api/eia/petroleum to gold-standard (Railway seed → Redis → Vercel reads only)
Live api.eia.gov fetches from the Vercel edge function were causing
FUNCTION_INVOCATION_TIMEOUT 504s on /api/eia/petroleum (Sydney edge →
US origin with no timeout, no cache, no stale fallback — one EIA blip
blew the 25s budget).
- New seeder scripts/seed-eia-petroleum.mjs — fetches WTI/Brent/
production/inventory from api.eia.gov with per-fetch 15s timeouts,
writes energy:eia-petroleum:v1 with the {_seed, data} envelope.
Accepts 1-of-4 series; 0-of-4 routes to contract-mode RETRY so
seed-meta stays stale and the bundle retries on next cron.
- Bundled into seed-bundle-energy-sources.mjs (daily, 90s timeout) —
no new Railway service needed.
- Rewrote api/eia/[[...path]].js as a Redis-only reader via
readJsonFromUpstash. Same response shape for backward compat with
widgets/MCP/external callers. 503 + Retry-After on miss (never 504).
- Registered eiaPetroleum in api/health.js STANDALONE_KEYS + gated as
ON_DEMAND_KEYS for the deploy window; promote to SEED_META
(maxStaleMin: 4320) in a follow-up after ~7 days of clean cron.
- Tests: 14 seeder unit tests + 9 edge handler tests.
Audit result: /api/eia/petroleum was the only Vercel route fetching
dashboard data live. Every other fetch(https://…) in api/ is
auth/payments/notifications/user-initiated enrichment.
* fix(eia): close silent-stale window — add SEED_META + seed-health registration
Review finding on PR #3161: without a SEED_META entry, readSeedMeta
returns seedStale: null and classifyKey never reaches STALE_SEED.
That meant a broken Railway cron or missing EIA_API_KEY after the first
successful seed would keep /api/eia/petroleum serving stale data for
up to 7 days (TTL) while /api/health reported OK.
- api/health.js: add SEED_META.eiaPetroleum with maxStaleMin=4320
(72h = 3× daily bundle cadence). Keep eiaPetroleum in ON_DEMAND_KEYS
so the Vercel-instant / Railway-delayed deploy window doesn't CRIT
on first seed, but stale-after-seed now properly fires STALE_SEED.
- api/seed-health.js: register energy:eia-petroleum in SEED_DOMAINS
(intervalMin=1440) so the secondary health endpoint reports it too.
- Updated ON_DEMAND_KEYS comment to reflect freshness is now enforced.
This commit is contained in:
@@ -1,117 +1,61 @@
|
||||
// EIA (Energy Information Administration) API proxy
|
||||
// Keeps API key server-side
|
||||
// EIA (Energy Information Administration) passthrough.
|
||||
// Redis-only reader. Railway seeder `seed-eia-petroleum.mjs` (bundled in
|
||||
// `seed-bundle-energy-sources`) writes `energy:eia-petroleum:v1`; this
|
||||
// endpoint reads from Redis and never hits api.eia.gov at request time.
|
||||
// Gold standard per feedback_vercel_reads_only.md.
|
||||
|
||||
import { getCorsHeaders, isDisallowedOrigin } from '../_cors.js';
|
||||
import { readJsonFromUpstash } from '../_upstash-json.js';
|
||||
|
||||
export const config = { runtime: 'edge' };
|
||||
|
||||
const CANONICAL_KEY = 'energy:eia-petroleum:v1';
|
||||
|
||||
export default async function handler(req) {
|
||||
const cors = getCorsHeaders(req);
|
||||
if (isDisallowedOrigin(req)) {
|
||||
return new Response(JSON.stringify({ error: 'Origin not allowed' }), { status: 403, headers: cors });
|
||||
}
|
||||
|
||||
// Only allow GET and OPTIONS methods
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: cors });
|
||||
}
|
||||
if (req.method !== 'GET') {
|
||||
return Response.json({ error: 'Method not allowed' }, {
|
||||
status: 405,
|
||||
headers: cors,
|
||||
});
|
||||
return Response.json({ error: 'Method not allowed' }, { status: 405, headers: cors });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname.replace('/api/eia', '');
|
||||
|
||||
const apiKey = process.env.EIA_API_KEY;
|
||||
|
||||
if (!apiKey) {
|
||||
return Response.json({
|
||||
configured: false,
|
||||
skipped: true,
|
||||
reason: 'EIA_API_KEY not configured',
|
||||
}, {
|
||||
status: 200,
|
||||
headers: cors,
|
||||
});
|
||||
}
|
||||
|
||||
// Health check
|
||||
if (path === '/health' || path === '') {
|
||||
return Response.json({ configured: true }, {
|
||||
headers: cors,
|
||||
return Response.json({ configured: true }, { headers: cors });
|
||||
}
|
||||
|
||||
if (path === '/petroleum') {
|
||||
let data;
|
||||
try {
|
||||
data = await readJsonFromUpstash(CANONICAL_KEY, 3_000);
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
return Response.json(
|
||||
{ error: 'Data not yet seeded', hint: 'Retry in a few minutes' },
|
||||
{
|
||||
status: 503,
|
||||
headers: { ...cors, 'Cache-Control': 'no-store', 'Retry-After': '300' },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json(data, {
|
||||
headers: {
|
||||
...cors,
|
||||
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=86400',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Petroleum data endpoint
|
||||
if (path === '/petroleum') {
|
||||
try {
|
||||
const series = {
|
||||
wti: 'PET.RWTC.W',
|
||||
brent: 'PET.RBRTE.W',
|
||||
production: 'PET.WCRFPUS2.W',
|
||||
inventory: 'PET.WCESTUS1.W',
|
||||
};
|
||||
|
||||
const results = {};
|
||||
|
||||
// Fetch all series in parallel
|
||||
const fetchPromises = Object.entries(series).map(async ([key, seriesId]) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.eia.gov/v2/seriesid/${seriesId}?api_key=${apiKey}&num=2`,
|
||||
{ headers: { 'Accept': 'application/json' } }
|
||||
);
|
||||
|
||||
if (!response.ok) return null;
|
||||
|
||||
const data = await response.json();
|
||||
const values = data?.response?.data || [];
|
||||
|
||||
if (values.length >= 1) {
|
||||
return {
|
||||
key,
|
||||
data: {
|
||||
current: values[0]?.value,
|
||||
previous: values[1]?.value || values[0]?.value,
|
||||
date: values[0]?.period,
|
||||
unit: values[0]?.unit,
|
||||
}
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[EIA] Failed to fetch ${key}:`, e.message);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const fetchResults = await Promise.all(fetchPromises);
|
||||
|
||||
for (const result of fetchResults) {
|
||||
if (result) {
|
||||
results[result.key] = result.data;
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json(results, {
|
||||
headers: {
|
||||
...cors,
|
||||
'Cache-Control': 'public, max-age=1800, s-maxage=1800, stale-while-revalidate=300',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[EIA] Fetch error:', error);
|
||||
return Response.json({
|
||||
error: 'Failed to fetch EIA data',
|
||||
}, {
|
||||
status: 500,
|
||||
headers: cors,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return Response.json({ error: 'Not found' }, {
|
||||
status: 404,
|
||||
headers: cors,
|
||||
});
|
||||
return Response.json({ error: 'Not found' }, { status: 404, headers: cors });
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ const STANDALONE_KEYS = {
|
||||
energyIntelligence: 'energy:intelligence:feed:v1',
|
||||
ieaOilStocks: 'energy:iea-oil-stocks:v1:index',
|
||||
oilStocksAnalysis: 'energy:oil-stocks-analysis:v1',
|
||||
eiaPetroleum: 'energy:eia-petroleum:v1',
|
||||
jodiGas: 'energy:jodi-gas:v1:_countries',
|
||||
lngVulnerability: 'energy:lng-vulnerability:v1',
|
||||
jodiOil: 'energy:jodi-oil:v1:_countries',
|
||||
@@ -351,6 +352,7 @@ const SEED_META = {
|
||||
jodiOil: { key: 'seed-meta:energy:jodi-oil', maxStaleMin: 60 * 24 * 40 }, // monthly cron on 25th; 40d threshold matches 35d TTL + 5d buffer
|
||||
ieaOilStocks: { key: 'seed-meta:energy:iea-oil-stocks', maxStaleMin: 60 * 24 * 40 }, // monthly cron on 15th; 40d threshold = TTL_SECONDS
|
||||
oilStocksAnalysis: { key: 'seed-meta:energy:oil-stocks-analysis', maxStaleMin: 60 * 24 * 50 }, // afterPublish of ieaOilStocks; 50d = matches seed-meta TTL (exceeds 40d data TTL)
|
||||
eiaPetroleum: { key: 'seed-meta:energy:eia-petroleum', maxStaleMin: 4320 }, // daily bundle cron (seed-bundle-energy-sources); 72h = 3× interval, well under 7d data TTL
|
||||
jodiGas: { key: 'seed-meta:energy:jodi-gas', maxStaleMin: 60 * 24 * 40 }, // monthly cron on 25th; 40d threshold matches 35d TTL + 5d buffer
|
||||
lngVulnerability: { key: 'seed-meta:energy:jodi-gas', maxStaleMin: 60 * 24 * 40 }, // written by jodi-gas seeder afterPublish; shares seed-meta key
|
||||
chokepointBaselines: { key: 'seed-meta:energy:chokepoint-baselines', maxStaleMin: 60 * 24 * 400 }, // 400 days
|
||||
@@ -417,6 +419,14 @@ const ON_DEMAND_KEYS = new Set([
|
||||
'climateNewsRelayHeartbeat', // TRANSITIONAL (PR #3133): same deploy-order rationale.
|
||||
// 30min initial loop, so window is shorter but still present.
|
||||
// Remove after ~7 days alongside the chokepoint-flows entry.
|
||||
'eiaPetroleum', // TRANSITIONAL: gold-standard migration of /api/eia/petroleum
|
||||
// from live Vercel fetch to Redis-reader (seed-bundle-energy-sources
|
||||
// daily cron). SEED_META entry above enforces 72h staleness — this
|
||||
// ON_DEMAND gate only softens the absent-on-deploy case (Vercel
|
||||
// deploys instantly; Railway EIA_API_KEY + first daily tick ~24h
|
||||
// behind). STALE_SEED still fires if data goes stale after first seed.
|
||||
// Remove from this set after ~7 days of clean cron runs so
|
||||
// never-provisioned Railway promotes EMPTY_ON_DEMAND → EMPTY (CRIT).
|
||||
]);
|
||||
|
||||
// Keys where 0 records is a valid healthy state (e.g. no airports closed,
|
||||
|
||||
@@ -88,6 +88,7 @@ const SEED_DOMAINS = {
|
||||
'portwatch:chokepoints-ref': { key: 'seed-meta:portwatch:chokepoints-ref', intervalMin: 10080 }, // seed-bundle-portwatch runs this at WEEK cadence; intervalMin*2 = 14d matches api/health.js SEED_META.portwatchChokepointsRef
|
||||
'supply_chain:portwatch-ports': { key: 'seed-meta:supply_chain:portwatch-ports', intervalMin: 720 }, // 12h cron (0 */12 * * *); intervalMin = maxStaleMin / 3 (2160 / 3)
|
||||
'energy:chokepoint-flows': { key: 'seed-meta:energy:chokepoint-flows', intervalMin: 360 }, // 6h relay loop; intervalMin = maxStaleMin / 2 (720 / 2)
|
||||
'energy:eia-petroleum': { key: 'seed-meta:energy:eia-petroleum', intervalMin: 1440 }, // daily bundle cron; intervalMin*3 = health.js maxStaleMin (4320)
|
||||
'energy:spine': { key: 'seed-meta:energy:spine', intervalMin: 1440 }, // daily cron (0 6 * * *); intervalMin = maxStaleMin / 2 (2880 / 2)
|
||||
'energy:ember': { key: 'seed-meta:energy:ember', intervalMin: 1440 }, // daily cron (0 8 * * *); intervalMin = maxStaleMin / 2 (2880 / 2)
|
||||
'energy:spr-policies': { key: 'seed-meta:energy:spr-policies', intervalMin: 288000 }, // annual static registry; intervalMin = health.js maxStaleMin / 2 (576000 / 2)
|
||||
|
||||
@@ -8,6 +8,7 @@ await runBundle('energy-sources', [
|
||||
{ label: 'JODI-Oil', script: 'seed-jodi-oil.mjs', seedMetaKey: 'energy:jodi-oil', canonicalKey: 'energy:jodi-oil:v1:_countries', intervalMs: 35 * DAY, timeoutMs: 600_000 },
|
||||
{ label: 'OWID-Energy-Mix', script: 'seed-owid-energy-mix.mjs', seedMetaKey: 'economic:owid-energy-mix', intervalMs: 35 * DAY, timeoutMs: 600_000 },
|
||||
{ label: 'IEA-Oil-Stocks', script: 'seed-iea-oil-stocks.mjs', seedMetaKey: 'energy:iea-oil-stocks', canonicalKey: 'energy:iea-oil-stocks:v1:index', intervalMs: 40 * DAY, timeoutMs: 300_000 },
|
||||
{ label: 'EIA-Petroleum', script: 'seed-eia-petroleum.mjs', seedMetaKey: 'energy:eia-petroleum', canonicalKey: 'energy:eia-petroleum:v1', intervalMs: DAY, timeoutMs: 90_000 },
|
||||
{ label: 'IEA-Crisis-Policies', script: 'seed-energy-crisis-policies.mjs', seedMetaKey: 'energy:crisis-policies', canonicalKey: 'energy:crisis-policies:v1', intervalMs: 7 * DAY, timeoutMs: 120_000 },
|
||||
// SPR-Policies: static registry (data lives in scripts/data/spr-policies.json), TTL 400d
|
||||
// in api/health.js (maxStaleMin: 576000). Weekly cadence is generous — only needs to run
|
||||
|
||||
138
scripts/seed-eia-petroleum.mjs
Normal file
138
scripts/seed-eia-petroleum.mjs
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env node
|
||||
// @ts-check
|
||||
|
||||
import { loadEnvFile, runSeed } from './_seed-utils.mjs';
|
||||
|
||||
loadEnvFile(import.meta.url);
|
||||
|
||||
export const CANONICAL_KEY = 'energy:eia-petroleum:v1';
|
||||
const TTL_SECONDS = 7 * 86400; // 7d — covers one weekly EIA cycle + buffer
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
export const SERIES = /** @type {const} */ ({
|
||||
wti: 'PET.RWTC.W', // WTI spot price, weekly
|
||||
brent: 'PET.RBRTE.W', // Brent spot price, weekly
|
||||
production: 'PET.WCRFPUS2.W',// US crude oil production, weekly
|
||||
inventory: 'PET.WCESTUS1.W',// US commercial crude inventory, weekly
|
||||
});
|
||||
|
||||
/**
|
||||
* @typedef {{ current: number, previous: number | null, date: string, unit: string }} SeriesPoint
|
||||
* @typedef {Partial<Record<keyof typeof SERIES, SeriesPoint>>} EiaPetroleum
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse a single EIA `/v2/seriesid/:id?num=2` response into a SeriesPoint.
|
||||
* Returns null when the response has no usable current value.
|
||||
*
|
||||
* @param {unknown} payload
|
||||
* @returns {SeriesPoint | null}
|
||||
*/
|
||||
export function parseSeries(payload) {
|
||||
const values = /** @type {any} */ (payload)?.response?.data;
|
||||
if (!Array.isArray(values) || values.length === 0) return null;
|
||||
const current = Number(values[0]?.value);
|
||||
if (!Number.isFinite(current)) return null;
|
||||
const previousRaw = values[1]?.value;
|
||||
const previous = previousRaw == null ? null : (() => {
|
||||
const n = Number(previousRaw);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
})();
|
||||
return {
|
||||
current,
|
||||
previous,
|
||||
date: String(values[0]?.period ?? ''),
|
||||
unit: String(values[0]?.unit ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EiaPetroleum | null | undefined} agg
|
||||
* @returns {number}
|
||||
*/
|
||||
export function countSeries(agg) {
|
||||
if (!agg) return 0;
|
||||
return Object.values(agg).filter(v => v != null).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept when at least one of the four series returned a usable point.
|
||||
* Rejects only the fully-empty case (all 4 upstream calls failed).
|
||||
*
|
||||
* @param {EiaPetroleum | null | undefined} agg
|
||||
*/
|
||||
export function validatePetroleum(agg) {
|
||||
return countSeries(agg) >= 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} seriesId
|
||||
* @param {string} apiKey
|
||||
* @returns {Promise<SeriesPoint | null>}
|
||||
*/
|
||||
async function fetchOne(key, seriesId, apiKey) {
|
||||
const url = `https://api.eia.gov/v2/seriesid/${seriesId}?api_key=${apiKey}&num=2`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(` [EIA] ${key} HTTP ${resp.status}`);
|
||||
return null;
|
||||
}
|
||||
const body = await resp.json();
|
||||
const parsed = parseSeries(body);
|
||||
if (!parsed) console.warn(` [EIA] ${key} no usable values`);
|
||||
return parsed;
|
||||
} catch (err) {
|
||||
console.warn(` [EIA] ${key} fetch error: ${(err instanceof Error ? err.message : String(err))}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<EiaPetroleum>}
|
||||
*/
|
||||
async function fetchEiaPetroleum() {
|
||||
const apiKey = process.env.EIA_API_KEY;
|
||||
if (!apiKey) throw new Error('EIA_API_KEY not set');
|
||||
|
||||
const entries = /** @type {[keyof typeof SERIES, string][]} */ (Object.entries(SERIES));
|
||||
const pairs = await Promise.all(
|
||||
entries.map(async ([key, id]) => /** @type {const} */ ([key, await fetchOne(key, id, apiKey)])),
|
||||
);
|
||||
|
||||
/** @type {EiaPetroleum} */
|
||||
const agg = {};
|
||||
for (const [key, value] of pairs) {
|
||||
if (value) agg[key] = value;
|
||||
}
|
||||
return agg;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {EiaPetroleum} data
|
||||
*/
|
||||
export function declareRecords(data) {
|
||||
return countSeries(data);
|
||||
}
|
||||
|
||||
const isMain = process.argv[1]?.endsWith('seed-eia-petroleum.mjs');
|
||||
|
||||
if (isMain) {
|
||||
runSeed('energy', 'eia-petroleum', CANONICAL_KEY, fetchEiaPetroleum, {
|
||||
validateFn: validatePetroleum,
|
||||
ttlSeconds: TTL_SECONDS,
|
||||
sourceVersion: 'eia-petroleum-v1',
|
||||
recordCount: (data) => countSeries(data),
|
||||
declareRecords,
|
||||
schemaVersion: 1,
|
||||
maxStaleMin: 4320, // 72h — daily bundle; tolerates 3 missed ticks
|
||||
}).catch((err) => {
|
||||
const cause = err.cause ? ` (cause: ${err.cause.message || err.cause.code || err.cause})` : '';
|
||||
console.error('FATAL:', (err.message || err) + cause);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
113
tests/api-eia-petroleum.test.mjs
Normal file
113
tests/api-eia-petroleum.test.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import { strict as assert } from 'node:assert';
|
||||
import { describe, it, beforeEach, afterEach } from 'node:test';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
const SAMPLE_PAYLOAD = {
|
||||
wti: { current: 76.23, previous: 75.10, date: '2026-04-11', unit: 'dollars per barrel' },
|
||||
brent: { current: 81.02, previous: 80.44, date: '2026-04-11', unit: 'dollars per barrel' },
|
||||
production: { current: 13100, previous: 13050, date: '2026-04-11', unit: 'MBBL' },
|
||||
inventory: { current: 458_100, previous: 459_200, date: '2026-04-11', unit: 'MBBL' },
|
||||
};
|
||||
|
||||
const ENVELOPE = {
|
||||
_seed: {
|
||||
fetchedAt: 1_700_000_000_000,
|
||||
recordCount: 4,
|
||||
sourceVersion: 'eia-petroleum-v1',
|
||||
schemaVersion: 1,
|
||||
state: 'OK',
|
||||
},
|
||||
data: SAMPLE_PAYLOAD,
|
||||
};
|
||||
|
||||
function makeRequest(path, opts = {}) {
|
||||
return new Request(`https://worldmonitor.app/api/eia${path}`, {
|
||||
method: opts.method || 'GET',
|
||||
headers: { origin: 'https://worldmonitor.app', ...(opts.headers || {}) },
|
||||
});
|
||||
}
|
||||
|
||||
let handler;
|
||||
|
||||
describe('api/eia/[[...path]] — petroleum reader', () => {
|
||||
beforeEach(async () => {
|
||||
process.env.UPSTASH_REDIS_REST_URL = 'https://fake-upstash.io';
|
||||
process.env.UPSTASH_REDIS_REST_TOKEN = 'fake-token';
|
||||
const mod = await import(`../api/eia/%5B%5B...path%5D%5D.js?t=${Date.now()}`);
|
||||
handler = mod.default;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
Object.keys(process.env).forEach(k => {
|
||||
if (!(k in originalEnv)) delete process.env[k];
|
||||
});
|
||||
Object.assign(process.env, originalEnv);
|
||||
});
|
||||
|
||||
it('OPTIONS returns 204 with CORS headers', async () => {
|
||||
const res = await handler(makeRequest('/petroleum', { method: 'OPTIONS' }));
|
||||
assert.equal(res.status, 204);
|
||||
assert.ok(res.headers.get('access-control-allow-origin'));
|
||||
});
|
||||
|
||||
it('disallowed origin returns 403', async () => {
|
||||
const res = await handler(makeRequest('/petroleum', { headers: { origin: 'https://evil.example' } }));
|
||||
assert.equal(res.status, 403);
|
||||
});
|
||||
|
||||
it('non-GET returns 405', async () => {
|
||||
const res = await handler(makeRequest('/petroleum', { method: 'POST' }));
|
||||
assert.equal(res.status, 405);
|
||||
});
|
||||
|
||||
it('/health returns configured:true', async () => {
|
||||
const res = await handler(makeRequest('/health'));
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.configured, true);
|
||||
});
|
||||
|
||||
it('/petroleum returns 200 with data on Upstash hit (envelope unwrapped)', async () => {
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.match(String(url), /fake-upstash\.io\/get\/energy%3Aeia-petroleum%3Av1/);
|
||||
return new Response(JSON.stringify({ result: JSON.stringify(ENVELOPE) }), { status: 200 });
|
||||
};
|
||||
const res = await handler(makeRequest('/petroleum'));
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.deepEqual(body, SAMPLE_PAYLOAD);
|
||||
assert.match(res.headers.get('cache-control') || '', /max-age=1800/);
|
||||
assert.match(res.headers.get('cache-control') || '', /stale-while-revalidate=86400/);
|
||||
});
|
||||
|
||||
it('/petroleum returns 503 with hint when Redis key is missing', async () => {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({ result: null }), { status: 200 });
|
||||
const res = await handler(makeRequest('/petroleum'));
|
||||
assert.equal(res.status, 503);
|
||||
const body = await res.json();
|
||||
assert.match(body.error, /not yet seeded/i);
|
||||
assert.ok(body.hint);
|
||||
assert.equal(res.headers.get('cache-control'), 'no-store');
|
||||
assert.equal(res.headers.get('retry-after'), '300');
|
||||
});
|
||||
|
||||
it('/petroleum returns 503 (not 504) when Upstash itself errors', async () => {
|
||||
globalThis.fetch = async () => new Response('bad gateway', { status: 502 });
|
||||
const res = await handler(makeRequest('/petroleum'));
|
||||
assert.equal(res.status, 503);
|
||||
});
|
||||
|
||||
it('/petroleum returns 503 when Upstash throws', async () => {
|
||||
globalThis.fetch = async () => { throw new Error('connection refused'); };
|
||||
const res = await handler(makeRequest('/petroleum'));
|
||||
assert.equal(res.status, 503);
|
||||
});
|
||||
|
||||
it('unknown path returns 404', async () => {
|
||||
const res = await handler(makeRequest('/unknown'));
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
});
|
||||
111
tests/seed-eia-petroleum.test.mjs
Normal file
111
tests/seed-eia-petroleum.test.mjs
Normal file
@@ -0,0 +1,111 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
CANONICAL_KEY,
|
||||
SERIES,
|
||||
parseSeries,
|
||||
countSeries,
|
||||
validatePetroleum,
|
||||
declareRecords,
|
||||
} from '../scripts/seed-eia-petroleum.mjs';
|
||||
|
||||
describe('seed-eia-petroleum constants', () => {
|
||||
it('CANONICAL_KEY is versioned under energy:', () => {
|
||||
assert.equal(CANONICAL_KEY, 'energy:eia-petroleum:v1');
|
||||
});
|
||||
|
||||
it('SERIES maps the four expected indicators to EIA series ids', () => {
|
||||
assert.deepEqual(Object.keys(SERIES).sort(), ['brent', 'inventory', 'production', 'wti']);
|
||||
assert.equal(SERIES.wti, 'PET.RWTC.W');
|
||||
assert.equal(SERIES.brent, 'PET.RBRTE.W');
|
||||
assert.equal(SERIES.production, 'PET.WCRFPUS2.W');
|
||||
assert.equal(SERIES.inventory, 'PET.WCESTUS1.W');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSeries', () => {
|
||||
const shape = (values) => ({ response: { data: values } });
|
||||
|
||||
it('returns current/previous/date/unit from a 2-row response', () => {
|
||||
const parsed = parseSeries(shape([
|
||||
{ value: '76.23', period: '2026-04-11', unit: 'dollars per barrel' },
|
||||
{ value: '75.10', period: '2026-04-04', unit: 'dollars per barrel' },
|
||||
]));
|
||||
assert.deepEqual(parsed, {
|
||||
current: 76.23,
|
||||
previous: 75.10,
|
||||
date: '2026-04-11',
|
||||
unit: 'dollars per barrel',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to null previous when only one value is returned', () => {
|
||||
const parsed = parseSeries(shape([
|
||||
{ value: '76.23', period: '2026-04-11', unit: 'dollars per barrel' },
|
||||
]));
|
||||
assert.equal(parsed?.current, 76.23);
|
||||
assert.equal(parsed?.previous, null);
|
||||
});
|
||||
|
||||
it('coerces numeric values expressed as strings', () => {
|
||||
const parsed = parseSeries(shape([{ value: '13100', period: '2026-04-11', unit: 'MBBL' }]));
|
||||
assert.equal(parsed?.current, 13100);
|
||||
assert.equal(typeof parsed?.current, 'number');
|
||||
});
|
||||
|
||||
it('returns null when response.data is missing', () => {
|
||||
assert.equal(parseSeries(undefined), null);
|
||||
assert.equal(parseSeries({}), null);
|
||||
assert.equal(parseSeries({ response: {} }), null);
|
||||
});
|
||||
|
||||
it('returns null when response.data is empty', () => {
|
||||
assert.equal(parseSeries(shape([])), null);
|
||||
});
|
||||
|
||||
it('returns null when the first value is non-numeric', () => {
|
||||
assert.equal(parseSeries(shape([{ value: 'N/A', period: '2026-04-11', unit: 'x' }])), null);
|
||||
});
|
||||
|
||||
it('tolerates a non-numeric previous and returns null for it', () => {
|
||||
const parsed = parseSeries(shape([
|
||||
{ value: '76.23', period: '2026-04-11', unit: 'u' },
|
||||
{ value: 'withheld', period: '2026-04-04', unit: 'u' },
|
||||
]));
|
||||
assert.equal(parsed?.current, 76.23);
|
||||
assert.equal(parsed?.previous, null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countSeries + validatePetroleum + declareRecords', () => {
|
||||
const point = { current: 1, previous: 0, date: '2026-04-11', unit: 'u' };
|
||||
|
||||
it('countSeries returns 0 for null/undefined/empty', () => {
|
||||
assert.equal(countSeries(null), 0);
|
||||
assert.equal(countSeries(undefined), 0);
|
||||
assert.equal(countSeries({}), 0);
|
||||
});
|
||||
|
||||
it('countSeries counts only present series', () => {
|
||||
assert.equal(countSeries({ wti: point }), 1);
|
||||
assert.equal(countSeries({ wti: point, brent: point }), 2);
|
||||
assert.equal(countSeries({ wti: point, brent: point, production: point, inventory: point }), 4);
|
||||
});
|
||||
|
||||
it('validatePetroleum accepts any non-empty aggregate (1-of-4 is OK)', () => {
|
||||
assert.equal(validatePetroleum({ wti: point }), true);
|
||||
assert.equal(validatePetroleum({ wti: point, brent: point, production: point, inventory: point }), true);
|
||||
});
|
||||
|
||||
it('validatePetroleum rejects fully-empty aggregates', () => {
|
||||
assert.equal(validatePetroleum({}), false);
|
||||
assert.equal(validatePetroleum(null), false);
|
||||
assert.equal(validatePetroleum(undefined), false);
|
||||
});
|
||||
|
||||
it('declareRecords returns the series count (drives contract-mode OK vs RETRY)', () => {
|
||||
assert.equal(declareRecords({}), 0);
|
||||
assert.equal(declareRecords({ wti: point }), 1);
|
||||
assert.equal(declareRecords({ wti: point, brent: point, production: point, inventory: point }), 4);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user