From 42a4b5ef330c8b51ee2adaaa4083d76e6d155dbd Mon Sep 17 00:00:00 2001 From: root Date: Tue, 2 Jun 2026 22:31:48 +1000 Subject: [PATCH] feat(weather): /api/weather Open-Meteo proxy with 15-min cache Co-Authored-By: Claude Sonnet 4.6 --- lib/api/index.js | 2 ++ lib/api/routes/weather.js | 5 +++++ lib/weather.js | 33 +++++++++++++++++++++++++++++++++ tests/api/weather.test.js | 27 +++++++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 lib/api/routes/weather.js create mode 100644 lib/weather.js create mode 100644 tests/api/weather.test.js diff --git a/lib/api/index.js b/lib/api/index.js index 31fdfa6..a301669 100644 --- a/lib/api/index.js +++ b/lib/api/index.js @@ -24,6 +24,7 @@ import { router as jobsRouter } from './routes/jobs.js'; import { router as captureRouter } from './routes/capture.js'; import { spacesScopedRouter as companionRouter } from './routes/companion.js'; import { router as dashboardRouter } from './routes/dashboard.js'; +import { router as weatherRouter } from './routes/weather.js'; export function mountApi(app) { const api = Router(); @@ -55,6 +56,7 @@ export function mountApi(app) { api.use('/jobs', jobsRouter); api.use('/capture', captureRouter); api.use('/dashboard', dashboardRouter); + api.use('/weather', weatherRouter); api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter); api.use((_req, _res, next) => next(new NotFoundError('route not found'))); diff --git a/lib/api/routes/weather.js b/lib/api/routes/weather.js new file mode 100644 index 0000000..d4d9e0b --- /dev/null +++ b/lib/api/routes/weather.js @@ -0,0 +1,5 @@ +import { Router } from 'express'; +import { asyncWrap } from '../errors.js'; +import * as weather from '../../weather.js'; +export const router = Router(); +router.get('/', asyncWrap(async (_req, res) => res.json(await weather.current()))); diff --git a/lib/weather.js b/lib/weather.js new file mode 100644 index 0000000..733bcc7 --- /dev/null +++ b/lib/weather.js @@ -0,0 +1,33 @@ +// lib/weather.js — Melbourne, Open-Meteo, no API key, 15-min cache. +const LAT = -37.81, LON = 144.96, TTL_MS = 15 * 60 * 1000; +const CODES = { 0:'Clear',1:'Mainly clear',2:'Partly cloudy',3:'Overcast',45:'Fog',48:'Rime fog', + 51:'Light drizzle',53:'Drizzle',55:'Heavy drizzle',61:'Light rain',63:'Rain',65:'Heavy rain', + 71:'Light snow',73:'Snow',75:'Heavy snow',80:'Showers',81:'Showers',82:'Violent showers', + 95:'Thunderstorm',96:'Thunderstorm',99:'Thunderstorm' }; + +let cache = null; // { at, data } +let fetcher = defaultFetcher; + +async function defaultFetcher() { + const url = `https://api.open-meteo.com/v1/forecast?latitude=${LAT}&longitude=${LON}` + + `¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m`; + const res = await fetch(url, { signal: AbortSignal.timeout(6000) }); + if (!res.ok) throw new Error(`open-meteo ${res.status}`); + return res.json(); +} + +export function _setFetcher(fn) { fetcher = fn; } +export function _resetCache() { cache = null; fetcher = defaultFetcher; } + +export async function current() { + if (cache && Date.now() - cache.at < TTL_MS) return cache.data; + const raw = await fetcher(); + const c = raw.current || {}; + const data = { + temp: c.temperature_2m, feels_like: c.apparent_temperature, + humidity: c.relative_humidity_2m, wind: c.wind_speed_10m, + code: c.weather_code, label: CODES[c.weather_code] || 'Unknown' + }; + cache = { at: Date.now(), data }; + return data; +} diff --git a/tests/api/weather.test.js b/tests/api/weather.test.js new file mode 100644 index 0000000..7505ebc --- /dev/null +++ b/tests/api/weather.test.js @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import { setup } from './helpers.js'; +import * as weather from '../../lib/weather.js'; + +let app, ownerHeaders; +beforeAll(async () => { ({ app, ownerHeaders } = await setup()); }); +beforeEach(() => weather._resetCache()); + +const SAMPLE = { current: { temperature_2m: 14.2, apparent_temperature: 12.1, relative_humidity_2m: 71, wind_speed_10m: 9, weather_code: 3 } }; + +describe('weather api', () => { + it('401 without auth', async () => { + expect((await request(app).get('/api/weather')).status).toBe(401); + }); + it('returns mapped weather and caches the upstream call', async () => { + const fetcher = vi.fn().mockResolvedValue(SAMPLE); + weather._setFetcher(fetcher); + const r1 = await request(app).get('/api/weather').set(ownerHeaders); + expect(r1.status).toBe(200); + expect(r1.body.temp).toBe(14.2); + expect(r1.body.humidity).toBe(71); + expect(typeof r1.body.label).toBe('string'); // weather_code → text + await request(app).get('/api/weather').set(ownerHeaders); // 2nd hit + expect(fetcher).toHaveBeenCalledTimes(1); // cached + }); +});