From a2d6461b52e5217908291da133b07ba6e9d2df44 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 11 Sep 2026 21:57:51 -0700 Subject: [PATCH] style: format terrain traffic fire and bike-share modules --- scripts/format-scope.json | 14 +- server/providers/firms.js | 54 ++++- server/providers/gbfs.js | 93 ++++++-- server/providers/terrain.js | 53 +++-- server/providers/traffic.js | 67 ++++-- src/data/firmsCsv.js | 37 ++- src/data/firmsCsv.test.mjs | 27 ++- src/data/gbfsSource.js | 4 +- src/data/terrainHeightsProxy.js | 64 +++-- src/data/terrainHeightsProxy.test.mjs | 66 ++++-- src/data/tomtomTiles.js | 22 +- src/data/tomtomTiles.test.mjs | 73 ++++-- src/tooling/environmentProviders.test.mjs | 277 ++++++++++++++++------ 13 files changed, 638 insertions(+), 213 deletions(-) diff --git a/scripts/format-scope.json b/scripts/format-scope.json index aff90961f..e822ab5a2 100644 --- a/scripts/format-scope.json +++ b/scripts/format-scope.json @@ -57,5 +57,17 @@ "server/providers/space/celestrak.js", "server/providers/space/launch-library.js", "src/data/spaceProviderRequests.js", - "src/tooling/spaceProviders.test.mjs" + "src/tooling/spaceProviders.test.mjs", + "server/providers/terrain.js", + "server/providers/traffic.js", + "server/providers/firms.js", + "server/providers/gbfs.js", + "src/data/gbfsSource.js", + "src/data/terrainHeightsProxy.js", + "src/data/terrainHeightsProxy.test.mjs", + "src/data/tomtomTiles.js", + "src/data/tomtomTiles.test.mjs", + "src/data/firmsCsv.js", + "src/data/firmsCsv.test.mjs", + "src/tooling/environmentProviders.test.mjs" ] diff --git a/server/providers/firms.js b/server/providers/firms.js index 8326a612d..afed2a20c 100644 --- a/server/providers/firms.js +++ b/server/providers/firms.js @@ -49,10 +49,16 @@ export function firmsProxy() { diskChecked = true; try { const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); - if (Number.isFinite(parsed?.at) && Array.isArray(parsed?.sources) && Array.isArray(parsed?.fires)) { + if ( + Number.isFinite(parsed?.at) && + Array.isArray(parsed?.sources) && + Array.isArray(parsed?.fires) + ) { mem = parsed; } - } catch { /* no disk cache yet */ } + } catch { + /* no disk cache yet */ + } } async function writeDisk(entry) { @@ -97,7 +103,10 @@ export function firmsProxy() { for (const record of records) fires.push(record); sources.push({ source, count: records.length, ok: true }); } catch (err) { - console.warn(`[firms-proxy] ${source} fetch failed:`, err?.message || err); + console.warn( + `[firms-proxy] ${source} fetch failed:`, + err?.message || err, + ); sources.push({ source, count: 0, ok: false }); } } @@ -136,9 +145,14 @@ export function firmsProxy() { const body = await res.json(); const used = Number(body?.current_transactions); const limit = Number(body?.transaction_limit); - return Number.isFinite(used) && Number.isFinite(limit) ? { used, limit } : null; + return Number.isFinite(used) && Number.isFinite(limit) + ? { used, limit } + : null; } catch (err) { - console.warn('[firms-proxy] mapkey status failed:', err?.message || err); + console.warn( + '[firms-proxy] mapkey status failed:', + err?.message || err, + ); return null; } })() @@ -146,7 +160,9 @@ export function firmsProxy() { statusCache = { at: Date.now(), transactions }; return transactions; }) - .finally(() => { statusInflight = null; }); + .finally(() => { + statusInflight = null; + }); } return statusInflight; } @@ -157,7 +173,10 @@ export function firmsProxy() { server.middlewares.use('/api/firms', async (req, res) => { const sendJson = (status, obj) => { if (res.headersSent) return; - res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify(obj)); }; try { @@ -167,7 +186,14 @@ export function firmsProxy() { if (subPath === '/status') { if (!key) { - sendJson(200, { hasKey: false, lastFetch: null, count: null, stale: false, ttlMs: TTL_MS, transactions: null }); + sendJson(200, { + hasKey: false, + lastFetch: null, + count: null, + stale: false, + ttlMs: TTL_MS, + transactions: null, + }); return; } const transactions = await getTransactions(key); @@ -203,10 +229,14 @@ export function firmsProxy() { return fresh; }) .catch((err) => { - console.warn(`[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`); + console.warn( + `[firms-proxy] refresh failed (${err?.message || err}) — serving cache if any`, + ); return null; }) - .finally(() => { inflight = null; }); + .finally(() => { + inflight = null; + }); } const pending = inflight; const fresh = await pending; @@ -215,7 +245,9 @@ export function firmsProxy() { } else if (entry) { sendJson(200, buildPayload(entry, true)); // upstream down — stale beats empty } else { - sendJson(502, { error: 'firms fetch failed and no cache available' }); + sendJson(502, { + error: 'firms fetch failed and no cache available', + }); } } catch (err) { console.warn('[firms-proxy] error:', err?.message || err); diff --git a/server/providers/gbfs.js b/server/providers/gbfs.js index bc9bb0ed6..4e1457fce 100644 --- a/server/providers/gbfs.js +++ b/server/providers/gbfs.js @@ -1,4 +1,8 @@ -import { isAllowedGbfsHost, isAllowedGbfsPath, gbfsCacheControl } from '../../src/data/gbfsSource.js'; +import { + isAllowedGbfsHost, + isAllowedGbfsPath, + gbfsCacheControl, +} from '../../src/data/gbfsSource.js'; // --------------------------------------------------------------------------- // GBFS (General Bikeshare Feed Specification) proxy constants @@ -23,7 +27,10 @@ export function gbfsProxy() { server.middlewares.use('/api/gbfs', async (req, res) => { try { if (req.method !== 'GET') { - res.writeHead(405, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(405, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'Method Not Allowed' })); return; } @@ -31,7 +38,10 @@ export function gbfsProxy() { const url = new URL(req.url || '/', 'http://localhost'); const encodedTarget = url.pathname.replace(/^\/+/, ''); if (!encodedTarget) { - res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'Missing GBFS upstream target' })); return; } @@ -40,7 +50,10 @@ export function gbfsProxy() { try { decodedTarget = decodeURIComponent(encodedTarget); } catch { - res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'Invalid GBFS target encoding' })); return; } @@ -49,31 +62,53 @@ export function gbfsProxy() { try { upstreamUrl = new URL(decodedTarget); } catch { - res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'Invalid GBFS upstream URL' })); return; } if (upstreamUrl.protocol !== 'https:') { - res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ error: 'Only https GBFS targets are allowed' })); + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ error: 'Only https GBFS targets are allowed' }), + ); return; } if (!isAllowedGbfsHost(upstreamUrl.hostname)) { - res.writeHead(403, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(403, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'GBFS host not allowed' })); return; } if (!isAllowedGbfsPath(upstreamUrl.pathname)) { - res.writeHead(400, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ error: 'Only station_information/station_status endpoints are allowed' })); + res.writeHead(400, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ + error: + 'Only station_information/station_status endpoints are allowed', + }), + ); return; } const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), GBFS_PROXY_TIMEOUT_MS); + const timeoutId = setTimeout( + () => controller.abort(), + GBFS_PROXY_TIMEOUT_MS, + ); let upstream; try { upstream = await fetch(upstreamUrl.toString(), { @@ -91,18 +126,32 @@ export function gbfsProxy() { // Limit response size to prevent memory exhaustion from malicious upstream const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB const contentLength = Number(upstream.headers.get('content-length')); - if (Number.isFinite(contentLength) && contentLength > GBFS_MAX_BODY_BYTES) { - res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ error: 'GBFS upstream response too large' })); + if ( + Number.isFinite(contentLength) && + contentLength > GBFS_MAX_BODY_BYTES + ) { + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ error: 'GBFS upstream response too large' }), + ); return; } const body = await upstream.text(); if (Buffer.byteLength(body, 'utf8') > GBFS_MAX_BODY_BYTES) { - res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); - res.end(JSON.stringify({ error: 'GBFS upstream response too large' })); + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); + res.end( + JSON.stringify({ error: 'GBFS upstream response too large' }), + ); return; } - const contentType = upstream.headers.get('content-type') || 'application/json'; + const contentType = + upstream.headers.get('content-type') || 'application/json'; res.writeHead(upstream.status, { 'Content-Type': contentType, 'Cache-Control': gbfsCacheControl(upstreamUrl.pathname), @@ -112,12 +161,18 @@ export function gbfsProxy() { res.end(body); } catch (error) { if (error?.name === 'AbortError') { - res.writeHead(504, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(504, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'GBFS upstream timeout' })); return; } console.error('[GBFS Proxy]', error?.message || String(error)); - res.writeHead(502, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); + res.writeHead(502, { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-store', + }); res.end(JSON.stringify({ error: 'GBFS proxy error' })); } }); diff --git a/server/providers/terrain.js b/server/providers/terrain.js index fa015cc0e..5c7e6c609 100644 --- a/server/providers/terrain.js +++ b/server/providers/terrain.js @@ -39,12 +39,19 @@ export function terrainHeightsProxy() { diskLoaded = true; try { const parsed = JSON.parse(await fsp.readFile(CACHE_PATH, 'utf8')); - const pointEntries = parsed?.version === 2 && parsed.points && typeof parsed.points === 'object' - ? parsed.points - : null; + const pointEntries = + parsed?.version === 2 && + parsed.points && + typeof parsed.points === 'object' + ? parsed.points + : null; if (pointEntries) { for (const [key, entry] of Object.entries(pointEntries)) { - if (entry && Number.isFinite(entry.at) && validTerrainResult(entry.result)) { + if ( + entry && + Number.isFinite(entry.at) && + validTerrainResult(entry.result) + ) { mem.set(key, entry); } } @@ -53,18 +60,27 @@ export function terrainHeightsProxy() { // positionally present results; an absent value never becomes height 0. for (const [rawPoints, entry] of Object.entries(parsed)) { const points = parseTerrainPoints(rawPoints); - if (!points || !entry || !Number.isFinite(entry.at) || !Array.isArray(entry.results)) continue; + if ( + !points || + !entry || + !Number.isFinite(entry.at) || + !Array.isArray(entry.results) + ) + continue; for (let i = 0; i < points.length; i += 1) { const result = entry.results[i]; if (!validTerrainResult(result)) continue; const key = terrainPointKey(points[i]); const existing = mem.get(key); - if (!existing || entry.at > existing.at) mem.set(key, { at: entry.at, result }); + if (!existing || entry.at > existing.at) + mem.set(key, { at: entry.at, result }); } } diskDirty = mem.size > 0; } - } catch { /* no disk cache yet */ } + } catch { + /* no disk cache yet */ + } // Periodic flush, same shape as adsbdbProxy: coalesce writes instead of // hitting disk on every request. setInterval(async () => { @@ -97,7 +113,8 @@ export function terrainHeightsProxy() { const chunkResults = await fetchTerrainChunkWithRetry(chunk); // Keep later chunks aligned even if a malformed upstream response omits // trailing positions. The resolver will reject each null individually. - for (let j = 0; j < chunk.length; j += 1) results.push(chunkResults[j] ?? null); + for (let j = 0; j < chunk.length; j += 1) + results.push(chunkResults[j] ?? null); } return results; } @@ -106,10 +123,9 @@ export function terrainHeightsProxy() { function fetchMissingSingleFlight(points) { const key = points.map(terrainPointKey).join(';'); if (!inflight.has(key)) { - const request = fetchUpstreamAll(points) - .finally(() => { - if (inflight.get(key) === request) inflight.delete(key); - }); + const request = fetchUpstreamAll(points).finally(() => { + if (inflight.get(key) === request) inflight.delete(key); + }); inflight.set(key, request); } return inflight.get(key); @@ -130,11 +146,16 @@ export function terrainHeightsProxy() { const rawPoints = parsedUrl.searchParams.get('points'); const points = parseTerrainPoints(rawPoints); if (!points) { - send(400, { error: 'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers' }); + send(400, { + error: + 'invalid points parameter — expected "lon,lat;lon,lat;…" with finite numbers', + }); return; } if (points.length > MAX_POINTS) { - send(500, { error: `too many points (${points.length}); max ${MAX_POINTS} per request` }); + send(500, { + error: `too many points (${points.length}); max ${MAX_POINTS} per request`, + }); return; } @@ -147,8 +168,8 @@ export function terrainHeightsProxy() { if (outcome.cacheChanged) diskDirty = true; if (outcome.upstreamError) { console.warn( - '[terrain-heights-proxy] refresh incomplete' - + ' — serving stale points when available' + '[terrain-heights-proxy] refresh incomplete' + + ' — serving stale points when available', ); } send(outcome.status, outcome.body); diff --git a/server/providers/traffic.js b/server/providers/traffic.js index f11a32c1b..a259ccaa3 100644 --- a/server/providers/traffic.js +++ b/server/providers/traffic.js @@ -60,10 +60,16 @@ export function tomtomProxy() { budgetLoaded = true; try { const parsed = JSON.parse(await fsp.readFile(BUDGET_PATH, 'utf8')); - if (parsed && typeof parsed.date === 'string' && Number.isFinite(parsed.count)) { + if ( + parsed && + typeof parsed.date === 'string' && + Number.isFinite(parsed.count) + ) { budget = parsed; } - } catch { /* no budget file yet */ } + } catch { + /* no budget file yet */ + } } async function persistBudget() { @@ -87,7 +93,8 @@ export function tomtomProxy() { void persistBudget(); } - const tilePath = (key) => path.join(CACHE_DIR, `flow-${key.replaceAll('/', '-')}.pbf`); + const tilePath = (key) => + path.join(CACHE_DIR, `flow-${key.replaceAll('/', '-')}.pbf`); /** Disk-cache read; tile age comes from the file's mtime. */ async function readDiskTile(key) { @@ -97,7 +104,9 @@ export function tomtomProxy() { fsp.readFile(tilePath(key)), ]); return { at: stat.mtimeMs, buf }; - } catch { return null; } + } catch { + return null; + } } async function writeDiskTile(key, buf) { @@ -105,7 +114,10 @@ export function tomtomProxy() { await fsp.mkdir(CACHE_DIR, { recursive: true }); await fsp.writeFile(tilePath(key), buf); } catch (err) { - console.warn(`[tomtom-proxy] tile cache write failed for ${key}:`, err?.message || err); + console.warn( + `[tomtom-proxy] tile cache write failed for ${key}:`, + err?.message || err, + ); } } @@ -119,10 +131,13 @@ export function tomtomProxy() { } async function fetchUpstream(z, x, y) { - const url = 'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' - + `${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`; + const url = + 'https://api.tomtom.com/traffic/map/4/tile/flow/relative/' + + `${z}/${x}/${y}.pbf?key=${encodeURIComponent(process.env.TOMTOM_API_KEY)}`; recordUpstreamFetch(); // attempts count — upstream bills the request either way - const res = await fetch(url, { signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS) }); + const res = await fetch(url, { + signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), + }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const buf = Buffer.from(await res.arrayBuffer()); if (buf.length === 0) throw new Error('empty tile body'); @@ -161,7 +176,12 @@ export function tomtomProxy() { if (urlPath === '/status') { const hasKey = Boolean(process.env.TOMTOM_API_KEY); const b = currentBudget(); - sendJson(200, { hasKey, dailyCount: b.count, budget: dailyBudgetLimit(), date: b.date }); + sendJson(200, { + hasKey, + dailyCount: b.count, + budget: dailyBudgetLimit(), + date: b.date, + }); return; } @@ -208,18 +228,23 @@ export function tomtomProxy() { // Stale or missing → refresh, single-flight per tile. if (!inflight.has(key)) { - inflight.set(key, fetchUpstream(z, x, y) - .then(async (buf) => { - const fresh = { at: Date.now(), buf }; - memSet(key, fresh); - await writeDiskTile(key, buf); - return fresh; - }) - .catch((err) => { - console.warn(`[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`); - return null; - }) - .finally(() => inflight.delete(key))); + inflight.set( + key, + fetchUpstream(z, x, y) + .then(async (buf) => { + const fresh = { at: Date.now(), buf }; + memSet(key, fresh); + await writeDiskTile(key, buf); + return fresh; + }) + .catch((err) => { + console.warn( + `[tomtom-proxy] ${key} fetch failed (${err?.message || err}) — serving stale if any`, + ); + return null; + }) + .finally(() => inflight.delete(key)), + ); } const fresh = await inflight.get(key); if (fresh) { diff --git a/src/data/firmsCsv.js b/src/data/firmsCsv.js index 2eeb0c307..138b1a4db 100644 --- a/src/data/firmsCsv.js +++ b/src/data/firmsCsv.js @@ -17,7 +17,14 @@ */ /** Header fields that must all be present for a payload to count as FIRMS CSV. */ -const REQUIRED_HEADER_FIELDS = ['latitude', 'longitude', 'acq_date', 'acq_time', 'confidence', 'frp']; +const REQUIRED_HEADER_FIELDS = [ + 'latitude', + 'longitude', + 'acq_date', + 'acq_time', + 'confidence', + 'frp', +]; const HOUR_MS = 3600_000; /** Trailing window size for {@link filterTrailing24h}. */ @@ -36,8 +43,10 @@ export function isLikelyCsv(text) { if (typeof text !== 'string') return false; const trimmed = text.trimStart(); if (!trimmed || trimmed[0] === '<') return false; - const headerLine = trimmed.slice(0, trimmed.indexOf('\n') === -1 ? undefined : trimmed.indexOf('\n')) - .trim().toLowerCase(); + const headerLine = trimmed + .slice(0, trimmed.indexOf('\n') === -1 ? undefined : trimmed.indexOf('\n')) + .trim() + .toLowerCase(); const fields = headerLine.split(',').map((f) => f.trim()); return REQUIRED_HEADER_FIELDS.every((required) => fields.includes(required)); } @@ -62,8 +71,13 @@ export function parseFirmsCsv(text) { // Locate the header (first non-empty line) and build a column index so the // parser survives column reordering across FIRMS product versions. let headerIndex = 0; - while (headerIndex < lines.length && !lines[headerIndex].trim()) headerIndex += 1; - const header = lines[headerIndex].trim().toLowerCase().split(',').map((f) => f.trim()); + while (headerIndex < lines.length && !lines[headerIndex].trim()) + headerIndex += 1; + const header = lines[headerIndex] + .trim() + .toLowerCase() + .split(',') + .map((f) => f.trim()); const col = new Map(header.map((name, i) => [name, i])); const iLat = col.get('latitude'); const iLon = col.get('longitude'); @@ -114,7 +128,8 @@ export function parseFirmsCsv(text) { * @returns {number} Epoch ms, or NaN when unparseable. */ export function acquisitionMsUtc(acqDate, acqTime) { - if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) return NaN; + if (typeof acqDate !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(acqDate)) + return NaN; const timeText = String(acqTime ?? '').trim(); if (!/^\d{1,4}$/.test(timeText)) return NaN; const hhmm = timeText.padStart(4, '0'); @@ -123,7 +138,15 @@ export function acquisitionMsUtc(acqDate, acqTime) { const day = Number(acqDate.slice(8, 10)); const hours = Number(hhmm.slice(0, 2)); const minutes = Number(hhmm.slice(2, 4)); - if (month < 1 || month > 12 || day < 1 || day > 31 || hours > 23 || minutes > 59) return NaN; + if ( + month < 1 || + month > 12 || + day < 1 || + day > 31 || + hours > 23 || + minutes > 59 + ) + return NaN; return Date.UTC(year, month - 1, day, hours, minutes); } diff --git a/src/data/firmsCsv.test.mjs b/src/data/firmsCsv.test.mjs index 8dcdc9c29..438f728cf 100644 --- a/src/data/firmsCsv.test.mjs +++ b/src/data/firmsCsv.test.mjs @@ -14,10 +14,11 @@ import { const __dirname = path.dirname(fileURLToPath(import.meta.url)); const FIXTURE = fs.readFileSync( path.join(__dirname, 'fixtures', 'firms-viirs-noaa20-sample.csv'), - 'utf8' + 'utf8', ); -const HEADER = 'latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_ti5,frp,daynight'; +const HEADER = + 'latitude,longitude,bright_ti4,scan,track,acq_date,acq_time,satellite,instrument,confidence,version,bright_ti5,frp,daynight'; test('fixture parses to 45 records with finite lat/lon/frp', () => { const records = parseFirmsCsv(FIXTURE); @@ -48,15 +49,24 @@ test('fixture first row maps every field, categorical confidence preserved raw', }); test('acquisitionMsUtc: "1006" = 10:06 UTC', () => { - assert.equal(acquisitionMsUtc('2026-07-16', '1006'), Date.UTC(2026, 6, 16, 10, 6)); + assert.equal( + acquisitionMsUtc('2026-07-16', '1006'), + Date.UTC(2026, 6, 16, 10, 6), + ); }); test('acquisitionMsUtc: non-zero-padded "45" = 00:45 UTC', () => { - assert.equal(acquisitionMsUtc('2026-07-16', '45'), Date.UTC(2026, 6, 16, 0, 45)); + assert.equal( + acquisitionMsUtc('2026-07-16', '45'), + Date.UTC(2026, 6, 16, 0, 45), + ); }); test('acquisitionMsUtc: "0" = midnight UTC; garbage → NaN', () => { - assert.equal(acquisitionMsUtc('2026-07-16', '0'), Date.UTC(2026, 6, 16, 0, 0)); + assert.equal( + acquisitionMsUtc('2026-07-16', '0'), + Date.UTC(2026, 6, 16, 0, 0), + ); assert.ok(Number.isNaN(acquisitionMsUtc('not-a-date', '1006'))); assert.ok(Number.isNaN(acquisitionMsUtc('2026-07-16', 'xx'))); }); @@ -118,7 +128,12 @@ test('filterTrailing24h: window is [now − 24 h, now + 2 h] inclusive', () => { const kept = filterTrailing24h(records, now); assert.deepEqual( kept.map((r) => `${r.acqDate} ${r.acqTime}`), - ['2026-07-15 1200', '2026-07-15 1201', '2026-07-16 1130', '2026-07-16 1400'] + [ + '2026-07-15 1200', + '2026-07-15 1201', + '2026-07-16 1130', + '2026-07-16 1400', + ], ); }); diff --git a/src/data/gbfsSource.js b/src/data/gbfsSource.js index d44aadac9..a15a806d9 100644 --- a/src/data/gbfsSource.js +++ b/src/data/gbfsSource.js @@ -19,7 +19,9 @@ const GBFS_ALLOWED_HOSTS = new Set([ * @returns {boolean} */ export function isAllowedGbfsHost(hostname) { - const host = String(hostname || '').trim().toLowerCase(); + const host = String(hostname || '') + .trim() + .toLowerCase(); if (!host) return false; if (GBFS_ALLOWED_HOSTS.has(host)) return true; return host.endsWith('.publicbikesystem.net'); diff --git a/src/data/terrainHeightsProxy.js b/src/data/terrainHeightsProxy.js index 525f4d489..7e6eb406f 100644 --- a/src/data/terrainHeightsProxy.js +++ b/src/data/terrainHeightsProxy.js @@ -24,7 +24,10 @@ function defaultSleep(ms) { export function parseTerrainPoints(raw) { const text = String(raw || '').trim(); if (!text) return null; - const pairs = text.split(';').map((part) => part.trim()).filter(Boolean); + const pairs = text + .split(';') + .map((part) => part.trim()) + .filter(Boolean); if (pairs.length === 0) return null; const points = []; for (const pair of pairs) { @@ -83,16 +86,19 @@ export function terrainRetryAfterMs(value, nowMs = Date.now()) { * @param {object} [options] * @returns {Promise>} */ -export async function fetchTerrainChunkWithRetry(points, { - fetchImpl = globalThis.fetch, - sleep = defaultSleep, - random = Math.random, - now = Date.now, - makeSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), - attemptTimeoutMs = 30_000, - retryBudgetMs = TERRAIN_RETRY_BUDGET_MS, - maxAttempts = TERRAIN_MAX_ATTEMPTS, -} = {}) { +export async function fetchTerrainChunkWithRetry( + points, + { + fetchImpl = globalThis.fetch, + sleep = defaultSleep, + random = Math.random, + now = Date.now, + makeSignal = (timeoutMs) => AbortSignal.timeout(timeoutMs), + attemptTimeoutMs = 30_000, + retryBudgetMs = TERRAIN_RETRY_BUDGET_MS, + maxAttempts = TERRAIN_MAX_ATTEMPTS, + } = {}, +) { const pointsParam = points.map(terrainPointKey).join(';'); const url = `https://terrain.reearth.land/heights.json?points=${encodeURIComponent(pointsParam)}`; let retryStartedAt = null; @@ -117,7 +123,9 @@ export async function fetchTerrainChunkWithRetry(points, { } const json = await res.json(); if (!Array.isArray(json?.results)) { - const error = new Error('malformed upstream response (no results array)'); + const error = new Error( + 'malformed upstream response (no results array)', + ); error.retryable = false; throw error; } @@ -131,14 +139,18 @@ export async function fetchTerrainChunkWithRetry(points, { if (remaining <= 0) break; const retryAfterMs = terrainRetryAfterMs(error?.retryAfter, now()); - const backoffMs = Math.round(350 * (2 ** attempt) * (0.75 + random() * 0.5)); + const backoffMs = Math.round( + 350 * 2 ** attempt * (0.75 + random() * 0.5), + ); const requestedDelay = retryAfterMs ?? backoffMs; const delayMs = Math.min(requestedDelay, remaining); if (delayMs > 0) await sleep(delayMs); } } - throw lastError || new Error('terrain heights upstream retry budget exhausted'); + throw ( + lastError || new Error('terrain heights upstream retry budget exhausted') + ); } /** @@ -171,7 +183,12 @@ export async function resolveTerrainHeightRequest({ const missing = []; for (const [key, point] of unique) { const entry = cache.get(key); - if (entry && validTerrainResult(entry.result) && requestStartedAt - entry.at < ttlMs) continue; + if ( + entry && + validTerrainResult(entry.result) && + requestStartedAt - entry.at < ttlMs + ) + continue; missing.push({ key, point }); } @@ -180,7 +197,8 @@ export async function resolveTerrainHeightRequest({ if (missing.length > 0) { try { const fetched = await fetchMissing(missing.map((item) => item.point)); - if (!Array.isArray(fetched)) throw new Error('malformed upstream response (no results array)'); + if (!Array.isArray(fetched)) + throw new Error('malformed upstream response (no results array)'); const fetchedAt = now(); for (let i = 0; i < missing.length; i += 1) { const result = fetched[i]; @@ -188,8 +206,13 @@ export async function resolveTerrainHeightRequest({ cache.set(missing[i].key, { at: fetchedAt, result }); cacheChanged = true; } - if (fetched.length !== missing.length || missing.some((_, i) => !validTerrainResult(fetched[i]))) { - upstreamError = new Error('upstream omitted one or more terrain heights'); + if ( + fetched.length !== missing.length || + missing.some((_, i) => !validTerrainResult(fetched[i])) + ) { + upstreamError = new Error( + 'upstream omitted one or more terrain heights', + ); } } catch (error) { upstreamError = error; @@ -203,7 +226,10 @@ export async function resolveTerrainHeightRequest({ if (results.some((result) => result == null)) { return { status: 502, - body: { error: 'terrain heights fetch failed and no cache available for every point' }, + body: { + error: + 'terrain heights fetch failed and no cache available for every point', + }, cacheChanged, upstreamError, }; diff --git a/src/data/terrainHeightsProxy.test.mjs b/src/data/terrainHeightsProxy.test.mjs index 3e9ed38c5..cd05dca8b 100644 --- a/src/data/terrainHeightsProxy.test.mjs +++ b/src/data/terrainHeightsProxy.test.mjs @@ -18,7 +18,13 @@ test('per-point cache reconstruction preserves exact request order with mixed hi [terrainPointKey([10, 1]), { at: now, result: result('cached-a', 101) }], [terrainPointKey([30, 3]), { at: now, result: result('cached-c', 303) }], ]); - const points = [[30, 3], [20, 2], [10, 1], [40, 4], [20, 2]]; + const points = [ + [30, 3], + [20, 2], + [10, 1], + [40, 4], + [20, 2], + ]; let fetchedPoints = null; const response = await resolveTerrainHeightRequest({ @@ -33,11 +39,18 @@ test('per-point cache reconstruction preserves exact request order with mixed hi }); assert.equal(response.status, 200); - assert.deepEqual(fetchedPoints, [[20, 2], [40, 4]], 'only unique cache misses go upstream'); + assert.deepEqual( + fetchedPoints, + [ + [20, 2], + [40, 4], + ], + 'only unique cache misses go upstream', + ); assert.deepEqual( response.body.results.map((item) => item.id), ['cached-c', 'fresh-b', 'cached-a', 'fresh-d', 'fresh-b'], - 'each output position must map back to its own input point' + 'each output position must map back to its own input point', ); }); @@ -59,7 +72,9 @@ test('429 retry honors Retry-After before the next attempt', async () => { return { ok: false, status: 429, - headers: { get: (name) => name.toLowerCase() === 'retry-after' ? '3' : null }, + headers: { + get: (name) => (name.toLowerCase() === 'retry-after' ? '3' : null), + }, }; } return { @@ -83,21 +98,28 @@ test('permanently failing upstream exhausts retries and returns 502 without fake cache: new Map(), ttlMs: 10_000, now: () => clock, - fetchMissing: (missing) => fetchTerrainChunkWithRetry(missing, { - now: () => clock, - random: () => 0.5, - makeSignal: () => undefined, - sleep: async (ms) => { clock += ms; }, - fetchImpl: async () => { - attempts += 1; - return { ok: false, status: 503, headers: { get: () => null } }; - }, - }), + fetchMissing: (missing) => + fetchTerrainChunkWithRetry(missing, { + now: () => clock, + random: () => 0.5, + makeSignal: () => undefined, + sleep: async (ms) => { + clock += ms; + }, + fetchImpl: async () => { + attempts += 1; + return { ok: false, status: 503, headers: { get: () => null } }; + }, + }), }); assert.equal(attempts, 4, 'one initial attempt plus three retries'); assert.equal(response.status, 502); - assert.equal('results' in response.body, false, 'failure body must not fabricate positional heights'); + assert.equal( + 'results' in response.body, + false, + 'failure body must not fabricate positional heights', + ); }); test('stale per-point entries serve through a failed refresh only when every point is real', async () => { @@ -106,12 +128,20 @@ test('stale per-point entries serve through a failed refresh only when every poi [terrainPointKey([2, 2]), { at: 0, result: result('stale-b', 22) }], ]); const response = await resolveTerrainHeightRequest({ - points: [[2, 2], [1, 1]], + points: [ + [2, 2], + [1, 1], + ], cache, ttlMs: 100, now: () => 1000, - fetchMissing: async () => { throw new Error('offline'); }, + fetchMissing: async () => { + throw new Error('offline'); + }, }); assert.equal(response.status, 200); - assert.deepEqual(response.body.results.map((item) => item.id), ['stale-b', 'stale-a']); + assert.deepEqual( + response.body.results.map((item) => item.id), + ['stale-b', 'stale-a'], + ); }); diff --git a/src/data/tomtomTiles.js b/src/data/tomtomTiles.js index b78550ed4..f0da57e46 100644 --- a/src/data/tomtomTiles.js +++ b/src/data/tomtomTiles.js @@ -29,7 +29,8 @@ const MERCATOR_LAT_LIMIT = 85.05112878; * @returns {boolean} True when the coordinate is a fetchable tile. */ export function isValidTileCoord(z, x, y) { - if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) return false; + if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) + return false; if (z < MIN_TILE_ZOOM || z > MAX_TILE_ZOOM) return false; const n = 2 ** z; return x >= 0 && x < n && y >= 0 && y < n; @@ -47,11 +48,14 @@ export function isValidTileCoord(z, x, y) { */ export function lonLatToTile(lon, lat, z) { const n = 2 ** z; - const clampedLat = Math.max(-MERCATOR_LAT_LIMIT, Math.min(MERCATOR_LAT_LIMIT, lat)); + const clampedLat = Math.max( + -MERCATOR_LAT_LIMIT, + Math.min(MERCATOR_LAT_LIMIT, lat), + ); const latRad = (clampedLat * Math.PI) / 180; const x = Math.floor(((lon + 180) / 360) * n); const y = Math.floor( - ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n + ((1 - Math.log(Math.tan(latRad) + 1 / Math.cos(latRad)) / Math.PI) / 2) * n, ); return { x: Math.max(0, Math.min(n - 1, x)), @@ -70,7 +74,8 @@ export function lonLatToTile(lon, lat, z) { export function tileToBBox(z, x, y) { const n = 2 ** z; const lonAt = (col) => (col / n) * 360 - 180; - const latAt = (row) => (Math.atan(Math.sinh(Math.PI * (1 - (2 * row) / n))) * 180) / Math.PI; + const latAt = (row) => + (Math.atan(Math.sinh(Math.PI * (1 - (2 * row) / n))) * 180) / Math.PI; return { west: lonAt(x), east: lonAt(x + 1), @@ -132,10 +137,11 @@ export function utcDayKey(epochMs = Date.now()) { * @returns {{date:string, count:number}} Valid state for `dayKey`. */ export function normalizeBudget(state, dayKey) { - const valid = Boolean(state) - && state.date === dayKey - && Number.isFinite(state.count) - && state.count >= 0; + const valid = + Boolean(state) && + state.date === dayKey && + Number.isFinite(state.count) && + state.count >= 0; return valid ? state : { date: dayKey, count: 0 }; } diff --git a/src/data/tomtomTiles.test.mjs b/src/data/tomtomTiles.test.mjs index 523ce54b8..1c9d289c0 100644 --- a/src/data/tomtomTiles.test.mjs +++ b/src/data/tomtomTiles.test.mjs @@ -53,22 +53,39 @@ test('lonLatToTile: downtown Austin @ z12 -> x935 y1686 (matches fixture)', () = test('tileToBBox: fixture tile bbox contains downtown Austin', () => { const bbox = tileToBBox(12, 935, 1686); - assert.ok(bbox.west <= AUSTIN.lon && AUSTIN.lon <= bbox.east, `lon outside [${bbox.west}, ${bbox.east}]`); - assert.ok(bbox.south <= AUSTIN.lat && AUSTIN.lat <= bbox.north, `lat outside [${bbox.south}, ${bbox.north}]`); + assert.ok( + bbox.west <= AUSTIN.lon && AUSTIN.lon <= bbox.east, + `lon outside [${bbox.west}, ${bbox.east}]`, + ); + assert.ok( + bbox.south <= AUSTIN.lat && AUSTIN.lat <= bbox.north, + `lat outside [${bbox.south}, ${bbox.north}]`, + ); assert.ok(bbox.north > bbox.south && bbox.east > bbox.west); }); test('tileToBBox <-> lonLatToTile roundtrip at the bbox center', () => { const bbox = tileToBBox(12, 935, 1686); - const center = { lon: (bbox.west + bbox.east) / 2, lat: (bbox.south + bbox.north) / 2 }; + const center = { + lon: (bbox.west + bbox.east) / 2, + lat: (bbox.south + bbox.north) / 2, + }; const t = lonLatToTile(center.lon, center.lat, 12); assert.deepEqual({ x: t.x, y: t.y }, { x: 935, y: 1686 }); }); test('lonLatToTile clamps poles and antimeridian into valid range', () => { - for (const [lon, lat] of [[-180, 89.9], [180, -89.9], [179.9999, 0]]) { + for (const [lon, lat] of [ + [-180, 89.9], + [180, -89.9], + [179.9999, 0], + ]) { const t = lonLatToTile(lon, lat, 12); - assert.equal(isValidTileCoord(12, t.x, t.y), true, `invalid tile for ${lon},${lat}: ${t.x},${t.y}`); + assert.equal( + isValidTileCoord(12, t.x, t.y), + true, + `invalid tile for ${lon},${lat}: ${t.x},${t.y}`, + ); } }); @@ -76,14 +93,19 @@ test('lonLatToTile clamps poles and antimeridian into valid range', () => { test('tilesForBounds: clamped Austin viewport covers the fixture tile', () => { const bounds = { - south: AUSTIN.lat - 0.025, north: AUSTIN.lat + 0.025, - west: AUSTIN.lon - 0.025, east: AUSTIN.lon + 0.025, + south: AUSTIN.lat - 0.025, + north: AUSTIN.lat + 0.025, + west: AUSTIN.lon - 0.025, + east: AUSTIN.lon + 0.025, }; const tiles = tilesForBounds(bounds, 12); - assert.ok(tiles.length >= 1 && tiles.length <= 4, `tile count ${tiles.length}`); + assert.ok( + tiles.length >= 1 && tiles.length <= 4, + `tile count ${tiles.length}`, + ); assert.ok( tiles.some((t) => t.z === 12 && t.x === 935 && t.y === 1686), - `fixture tile missing from ${JSON.stringify(tiles)}` + `fixture tile missing from ${JSON.stringify(tiles)}`, ); }); @@ -102,7 +124,7 @@ test('tilesForBounds: bounds straddling a tile edge return both tiles', () => { }); test('tilesForBounds: default zoom is 12 and every tile is valid', () => { - const bounds = { south: 51.49, north: 51.52, west: -0.14, east: -0.10 }; // London + const bounds = { south: 51.49, north: 51.52, west: -0.14, east: -0.1 }; // London const tiles = tilesForBounds(bounds); assert.ok(tiles.length >= 1); for (const t of tiles) { @@ -112,7 +134,11 @@ test('tilesForBounds: default zoom is 12 and every tile is valid', () => { }); test('tilesForBounds: runaway bounds are truncated by the safety cap', () => { - const tiles = tilesForBounds({ south: -60, north: 60, west: -170, east: 170 }, 12, { maxTiles: 16 }); + const tiles = tilesForBounds( + { south: -60, north: 60, west: -170, east: 170 }, + 12, + { maxTiles: 16 }, + ); assert.ok(tiles.length <= 16, `got ${tiles.length}`); }); @@ -129,15 +155,30 @@ test('normalizeBudget: same-day state passes through untouched', () => { }); test('normalizeBudget: day rollover resets the counter', () => { - const rolled = normalizeBudget({ date: '2026-07-16', count: 39999 }, '2026-07-17'); + const rolled = normalizeBudget( + { date: '2026-07-16', count: 39999 }, + '2026-07-17', + ); assert.deepEqual(rolled, { date: '2026-07-17', count: 0 }); }); test('normalizeBudget: missing/corrupt state starts fresh', () => { - assert.deepEqual(normalizeBudget(null, '2026-07-16'), { date: '2026-07-16', count: 0 }); - assert.deepEqual(normalizeBudget({ date: '2026-07-16', count: NaN }, '2026-07-16'), { date: '2026-07-16', count: 0 }); - assert.deepEqual(normalizeBudget({ count: 5 }, '2026-07-16'), { date: '2026-07-16', count: 0 }); - assert.deepEqual(normalizeBudget({ date: '2026-07-16', count: -3 }, '2026-07-16'), { date: '2026-07-16', count: 0 }); + assert.deepEqual(normalizeBudget(null, '2026-07-16'), { + date: '2026-07-16', + count: 0, + }); + assert.deepEqual( + normalizeBudget({ date: '2026-07-16', count: NaN }, '2026-07-16'), + { date: '2026-07-16', count: 0 }, + ); + assert.deepEqual(normalizeBudget({ count: 5 }, '2026-07-16'), { + date: '2026-07-16', + count: 0, + }); + assert.deepEqual( + normalizeBudget({ date: '2026-07-16', count: -3 }, '2026-07-16'), + { date: '2026-07-16', count: 0 }, + ); }); test('isOverBudget: at or above the limit is over, below is not', () => { diff --git a/src/tooling/environmentProviders.test.mjs b/src/tooling/environmentProviders.test.mjs index 5f1340e10..38e87c6f6 100644 --- a/src/tooling/environmentProviders.test.mjs +++ b/src/tooling/environmentProviders.test.mjs @@ -9,111 +9,248 @@ import { localProviderPlugins } from '../../server/providers/local.js'; function install(plugin) { const routes = new Map(); - plugin.configureServer({ middlewares: { use(route, handler) { routes.set(route, handler); } } }); + plugin.configureServer({ + middlewares: { + use(route, handler) { + routes.set(route, handler); + }, + }, + }); assert.equal(routes.size, 1); return async (url = '/', method = 'GET') => { - const res = { headersSent: false, writeHead(status, headers) { Object.assign(this, { status, headers, headersSent: true }); }, end(body) { this.body = body; } }; + const res = { + headersSent: false, + writeHead(status, headers) { + Object.assign(this, { status, headers, headersSent: true }); + }, + end(body) { + this.body = body; + }, + }; await [...routes.values()][0]({ url, method }, res); return res; }; } function isolate(t, env = {}) { - for(const [name,value] of Object.entries(env)) { + for (const [name, value] of Object.entries(env)) { const previous = process.env[name]; - t.after(() => { if(previous === undefined) delete process.env[name]; else process.env[name] = previous; }); + t.after(() => { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + }); process.env[name] = value; } - t.mock.method(fsp, 'readFile', async () => { throw Error('no disk cache'); }); - t.mock.method(fsp, 'stat', async () => { throw Error('no disk cache'); }); + t.mock.method(fsp, 'readFile', async () => { + throw Error('no disk cache'); + }); + t.mock.method(fsp, 'stat', async () => { + throw Error('no disk cache'); + }); t.mock.method(fsp, 'mkdir', async () => {}); t.mock.method(fsp, 'writeFile', async () => {}); t.mock.method(globalThis, 'setInterval', () => ({ unref() {} })); t.mock.method(console, 'warn', () => {}); } -const json = res => JSON.parse(res.body); +const json = (res) => JSON.parse(res.body); test('standalone composition mounts every extracted provider exactly once without acquisition', (t) => { - t.mock.method(globalThis, 'fetch', () => { throw Error('construction must not fetch'); }); + t.mock.method(globalThis, 'fetch', () => { + throw Error('construction must not fetch'); + }); const plugins = localProviderPlugins(); - for(const factory of [terrainHeightsProxy,tomtomProxy,firmsProxy,gbfsProxy]) assert.equal(plugins.filter(p=>p.name===factory().name).length,1); + for (const factory of [ + terrainHeightsProxy, + tomtomProxy, + firmsProxy, + gbfsProxy, + ]) + assert.equal(plugins.filter((p) => p.name === factory().name).length, 1); }); test('terrain middleware chunks missing points and reconstructs repeated/reordered requests from cache', async (t) => { - isolate(t);let calls=0;const sizes=[]; + isolate(t); + let calls = 0; + const sizes = []; t.mock.method(globalThis, 'fetch', async (raw) => { - calls++;const url=new URL(raw);assert.equal(url.origin,'https://terrain.reearth.land'); - const points=url.searchParams.get('points').split(';').map(p=>p.split(',').map(Number));sizes.push(points.length); - return Response.json({results:points.map(([lon])=>({ellipsoid:lon+100}))}); + calls++; + const url = new URL(raw); + assert.equal(url.origin, 'https://terrain.reearth.land'); + const points = url.searchParams + .get('points') + .split(';') + .map((p) => p.split(',').map(Number)); + sizes.push(points.length); + return Response.json({ + results: points.map(([lon]) => ({ ellipsoid: lon + 100 })), + }); }); - const request=install(terrainHeightsProxy()); - const points=Array.from({length:257},(_,i)=>`${i/100},1`); - const res=await request('/?points='+points.join(';')); - assert.equal(res.status,200);assert.deepEqual(sizes,[256,1]); - const reordered=await request('/?points=2.56,1;0,1;2.56,1'); - assert.deepEqual(json(reordered),{results:[{ellipsoid:102.56},{ellipsoid:100},{ellipsoid:102.56}]});assert.equal(calls,2); - assert.equal((await request('/?points=invalid')).status,400); - assert.equal((await request('/?points='+Array(2001).fill('0,1').join(';'))).status,500); - assert.equal(calls,2); + const request = install(terrainHeightsProxy()); + const points = Array.from({ length: 257 }, (_, i) => `${i / 100},1`); + const res = await request('/?points=' + points.join(';')); + assert.equal(res.status, 200); + assert.deepEqual(sizes, [256, 1]); + const reordered = await request('/?points=2.56,1;0,1;2.56,1'); + assert.deepEqual(json(reordered), { + results: [{ ellipsoid: 102.56 }, { ellipsoid: 100 }, { ellipsoid: 102.56 }], + }); + assert.equal(calls, 2); + assert.equal((await request('/?points=invalid')).status, 400); + assert.equal( + (await request('/?points=' + Array(2001).fill('0,1').join(';'))).status, + 500, + ); + assert.equal(calls, 2); }); test('terrain middleware migrates valid legacy disk points without fabricating omitted heights', async (t) => { isolate(t); - t.mock.method(fsp,'readFile',async()=>JSON.stringify({'1,2;3,4':{at:Date.now(),results:[{ellipsoid:77}]}})); - let calls=0; - t.mock.method(globalThis,'fetch',async(raw)=>{calls++;assert.equal(new URL(raw).searchParams.get('points'),'3.00000,4.00000');return Response.json({results:[{ellipsoid:88}]});}); - const res=await install(terrainHeightsProxy())('/?points=3,4;1,2'); - assert.deepEqual(json(res),{results:[{ellipsoid:88},{ellipsoid:77}]});assert.equal(calls,1); + t.mock.method(fsp, 'readFile', async () => + JSON.stringify({ + '1,2;3,4': { at: Date.now(), results: [{ ellipsoid: 77 }] }, + }), + ); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (raw) => { + calls++; + assert.equal(new URL(raw).searchParams.get('points'), '3.00000,4.00000'); + return Response.json({ results: [{ ellipsoid: 88 }] }); + }); + const res = await install(terrainHeightsProxy())('/?points=3,4;1,2'); + assert.deepEqual(json(res), { + results: [{ ellipsoid: 88 }, { ellipsoid: 77 }], + }); + assert.equal(calls, 1); }); test('traffic middleware preserves keyless mode, caching, stale budget fallback and UTC rollover', async (t) => { - isolate(t,{TOMTOM_API_KEY:'',TOMTOM_DAILY_TILE_BUDGET:'1'}); - let now=Date.UTC(2026,8,12,12);t.mock.method(Date,'now',()=>now); - let calls=0;t.mock.method(globalThis,'fetch',async(raw)=>{calls++;assert.equal(new URL(raw).searchParams.get('key'),'fixture-key');return new Response(new Uint8Array([1,2,3]));}); - const request=install(tomtomProxy()); - assert.equal(json(await request('/status')).hasKey,false); - assert.equal((await request('/flow/8/1/1.pbf')).status,503); - assert.equal((await request('/flow/7/1/1.pbf')).status,400);assert.equal(calls,0); - process.env.TOMTOM_API_KEY='fixture-key'; - assert.equal((await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],'MISS'); - assert.equal((await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],'HIT'); - assert.equal(json(await request('/status')).dailyCount,1);assert.equal(calls,1); - now+=120001; - assert.equal((await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],'STALE-BUDGET'); - assert.equal((await request('/flow/8/2/1.pbf')).status,429);assert.equal(calls,1); - now+=86400000; - assert.equal(json(await request('/status')).dailyCount,0); - assert.equal((await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'],'MISS');assert.equal(calls,2); + isolate(t, { TOMTOM_API_KEY: '', TOMTOM_DAILY_TILE_BUDGET: '1' }); + let now = Date.UTC(2026, 8, 12, 12); + t.mock.method(Date, 'now', () => now); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (raw) => { + calls++; + assert.equal(new URL(raw).searchParams.get('key'), 'fixture-key'); + return new Response(new Uint8Array([1, 2, 3])); + }); + const request = install(tomtomProxy()); + assert.equal(json(await request('/status')).hasKey, false); + assert.equal((await request('/flow/8/1/1.pbf')).status, 503); + assert.equal((await request('/flow/7/1/1.pbf')).status, 400); + assert.equal(calls, 0); + process.env.TOMTOM_API_KEY = 'fixture-key'; + assert.equal( + (await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'], + 'MISS', + ); + assert.equal( + (await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'], + 'HIT', + ); + assert.equal(json(await request('/status')).dailyCount, 1); + assert.equal(calls, 1); + now += 120001; + assert.equal( + (await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'], + 'STALE-BUDGET', + ); + assert.equal((await request('/flow/8/2/1.pbf')).status, 429); + assert.equal(calls, 1); + now += 86400000; + assert.equal(json(await request('/status')).dailyCount, 0); + assert.equal( + (await request('/flow/8/1/1.pbf')).headers['x-tomtom-cache'], + 'MISS', + ); + assert.equal(calls, 2); }); test('FIRMS retains a large successful source during partial failure and filters stale data at serve time', async (t) => { - isolate(t,{FIRMS_MAP_KEY:''});let now=Date.UTC(2026,8,12,12);t.mock.method(Date,'now',()=>now); - let calls=0; - const header='latitude,longitude,acq_date,acq_time,confidence,frp\n'; - const csv=header+'30,-97,2026-09-12,1200,h,10\n'.repeat(130001); - t.mock.method(globalThis,'fetch',async(raw)=>{ - calls++;const url=new URL(raw);assert.equal(url.hostname,'firms.modaps.eosdis.nasa.gov'); - if(url.pathname.includes('mapkey_status')) return Response.json({current_transactions:3,transaction_limit:5000}); - return url.pathname.includes('VIIRS_NOAA20')?new Response(csv):new Response('offline',{status:503}); + isolate(t, { FIRMS_MAP_KEY: '' }); + let now = Date.UTC(2026, 8, 12, 12); + t.mock.method(Date, 'now', () => now); + let calls = 0; + const header = 'latitude,longitude,acq_date,acq_time,confidence,frp\n'; + const csv = header + '30,-97,2026-09-12,1200,h,10\n'.repeat(130001); + t.mock.method(globalThis, 'fetch', async (raw) => { + calls++; + const url = new URL(raw); + assert.equal(url.hostname, 'firms.modaps.eosdis.nasa.gov'); + if (url.pathname.includes('mapkey_status')) + return Response.json({ + current_transactions: 3, + transaction_limit: 5000, + }); + return url.pathname.includes('VIIRS_NOAA20') + ? new Response(csv) + : new Response('offline', { status: 503 }); }); - const request=install(firmsProxy()); - assert.equal((await request()).status,503);assert.equal(json(await request('/status')).hasKey,false);assert.equal(calls,0); - process.env.FIRMS_MAP_KEY='fixture-key'; - const first=json(await request());assert.equal(first.count,130001);assert.equal(first.sources.filter(s=>s.ok).length,1);assert.equal(calls,3); - assert.equal(json(await request()).count,130001);assert.equal(calls,3); - assert.deepEqual(json(await request('/status')).transactions,{used:3,limit:5000}); - now+=25*3600000;t.mock.method(globalThis,'fetch',async()=>new Response('offline',{status:503})); - const stale=json(await request());assert.equal(stale.stale,true);assert.equal(stale.count,0); + const request = install(firmsProxy()); + assert.equal((await request()).status, 503); + assert.equal(json(await request('/status')).hasKey, false); + assert.equal(calls, 0); + process.env.FIRMS_MAP_KEY = 'fixture-key'; + const first = json(await request()); + assert.equal(first.count, 130001); + assert.equal(first.sources.filter((s) => s.ok).length, 1); + assert.equal(calls, 3); + assert.equal(json(await request()).count, 130001); + assert.equal(calls, 3); + assert.deepEqual(json(await request('/status')).transactions, { + used: 3, + limit: 5000, + }); + now += 25 * 3600000; + t.mock.method( + globalThis, + 'fetch', + async () => new Response('offline', { status: 503 }), + ); + const stale = json(await request()); + assert.equal(stale.stale, true); + assert.equal(stale.count, 0); }); test('GBFS keeps host/path/method guards, response caps and distinct information/status cache headers', async (t) => { - let calls=0;t.mock.method(globalThis,'fetch',async(raw)=>{calls++;assert.equal(new URL(raw).hostname,'gbfs.lyft.com');return Response.json({data:{stations:[]}});}); - const request=install(gbfsProxy());const target=p=>'/'+encodeURIComponent('https://gbfs.lyft.com/'+p); - assert.equal((await request(target('station_status.json'),'POST')).status,405); - assert.equal((await request('/'+encodeURIComponent('https://example.com/station_status.json'))).status,403); - assert.equal((await request(target('gbfs.json'))).status,400); - assert.equal((await request('/%zz')).status,400);assert.equal(calls,0); - assert.equal((await request(target('station_information.json'))).headers['Cache-Control'],'public, max-age=300'); - assert.equal((await request(target('station_status.json'))).headers['Cache-Control'],'no-store'); - t.mock.method(globalThis,'fetch',async()=>new Response('x',{headers:{'content-length':String(6*1024*1024)}})); - assert.equal((await request(target('station_status.json'))).status,502); + let calls = 0; + t.mock.method(globalThis, 'fetch', async (raw) => { + calls++; + assert.equal(new URL(raw).hostname, 'gbfs.lyft.com'); + return Response.json({ data: { stations: [] } }); + }); + const request = install(gbfsProxy()); + const target = (p) => '/' + encodeURIComponent('https://gbfs.lyft.com/' + p); + assert.equal( + (await request(target('station_status.json'), 'POST')).status, + 405, + ); + assert.equal( + ( + await request( + '/' + encodeURIComponent('https://example.com/station_status.json'), + ) + ).status, + 403, + ); + assert.equal((await request(target('gbfs.json'))).status, 400); + assert.equal((await request('/%zz')).status, 400); + assert.equal(calls, 0); + assert.equal( + (await request(target('station_information.json'))).headers[ + 'Cache-Control' + ], + 'public, max-age=300', + ); + assert.equal( + (await request(target('station_status.json'))).headers['Cache-Control'], + 'no-store', + ); + t.mock.method( + globalThis, + 'fetch', + async () => + new Response('x', { + headers: { 'content-length': String(6 * 1024 * 1024) }, + }), + ); + assert.equal((await request(target('station_status.json'))).status, 502); });