refactor(dashboard): server-side weather, render priorities, and model query optimization

Move weather endpoints to the server with caching and rate-limiting. Prioritize
3D viewer renders over background thumbnails. Query featured models from database
instead of loading all into memory.
This commit is contained in:
Tautvydas Derzinskas committed 2026-10-11 00:08:23 +03:00
1 parent 163d73f2a7
commit b13d0bf597
10 files changed
+599 -109

No files matched your search

+44
View File
@@ -3,7 +3,9 @@ import { z } from "zod";
import { requireAuth } from "../auth";
import { asyncHandler } from "../utils/asyncHandler";
import { parseBody } from "../utils/validate";
import { HttpError } from "../utils/fileUtils";
import { printOutById } from "../services/printLoader";
import { getForecast, getPlaceName, searchPlaces } from "../services/weatherService";
import {
getDashboardSummary,
getFeaturedModelId,
@@ -99,4 +101,46 @@ router.get(
}),
);
const coordinates = {
lat: z.coerce.number().min(-90).max(90),
lon: z.coerce.number().min(-180).max(180),
};
// A language tag like "en" or "lt", passed on so place names come back in it.
const language = z
.string()
.regex(/^[a-z]{2,3}(-[A-Za-z]{2,4})?$/)
.default("en");
async function fromWeatherService<T>(call: () => Promise<T>): Promise<T> {
try {
return await call();
} catch {
throw new HttpError(502, "The weather service didn't answer", "WEATHER_UNAVAILABLE");
}
}
router.get(
"/dashboard/weather",
asyncHandler(async (req, res) => {
const { lat, lon } = parseBody(z.object(coordinates), req.query);
res.json(await fromWeatherService(() => getForecast(lat, lon)));
}),
);
router.get(
"/dashboard/weather/places",
asyncHandler(async (req, res) => {
const { q, lang } = parseBody(z.object({ q: z.string().trim().min(2).max(100), lang: language }), req.query);
res.json({ places: await fromWeatherService(() => searchPlaces(q, lang)) });
}),
);
router.get(
"/dashboard/weather/place-name",
asyncHandler(async (req, res) => {
const { lat, lon, lang } = parseBody(z.object({ ...coordinates, lang: language }), req.query);
res.json({ name: await getPlaceName(lat, lon, lang) });
}),
);
export default router;
+5 -1
View File
@@ -212,7 +212,11 @@ router.get(
const srcPath = resolvePlateFilePath(plate);
if (!srcPath) throw new HttpError(404, "Model file is missing", "PREVIEW_FAILED");
void generateModelImage(plate.id, srcPath, plate.filename);
if (modelImageState(plate.id) === "failed") {
const state = modelImageState(plate.id);
if (state === "unsupported") {
throw new HttpError(404, "This file's layout can't be drawn by the server", "PREVIEW_UNSUPPORTED");
}
if (state === "failed") {
throw new HttpError(404, "No picture could be made for this model", "PREVIEW_FAILED");
}
throw new HttpError(404, "Picture is being made", "PREVIEW_GENERATING");
+19 -16
View File
@@ -1,5 +1,4 @@
import fs from "node:fs";
import path from "node:path";
import { createHash } from "node:crypto";
import { prisma } from "../db";
import { RENDERABLE_MODEL_EXTS } from "../config";
@@ -219,20 +218,24 @@ export function getTopAuthorsList(userId: string): Promise<DashboardAuthor[]> {
* caller's own date, so the pick changes at the user's midnight rather than the server's.
*/
export async function getFeaturedModelId(userId: string, day: string): Promise<string | null> {
const plates = await prisma.plate.findMany({
where: { print: { userId } },
select: { printId: true, filename: true },
orderBy: [{ printId: "asc" }, { position: "asc" }],
});
const candidates: string[] = [];
const seen = new Set<string>();
for (const plate of plates) {
// The first plate is the one a preview shows.
if (seen.has(plate.printId)) continue;
seen.add(plate.printId);
if (RENDERABLE_MODEL_EXTS.has(path.extname(plate.filename).toLowerCase())) candidates.push(plate.printId);
}
if (!candidates.length) return null;
// Plates are renumbered from 0 on every delete and reorder, so position 0 is the one a preview shows.
const where = {
userId,
plates: {
some: {
position: 0,
OR: [...RENDERABLE_MODEL_EXTS].map((ext) => ({ filename: { endsWith: ext, mode: "insensitive" as const } })),
},
},
};
const count = await prisma.print.count({ where });
if (!count) return null;
const seed = createHash("sha256").update(`${userId}:${day}`).digest().readUInt32BE(0);
return candidates[seed % candidates.length];
const pick = await prisma.print.findFirst({
where,
orderBy: { id: "asc" },
skip: seed % count,
select: { id: true },
});
return pick?.id ?? null;
}
+56 -21
View File
@@ -73,7 +73,10 @@ type RenderOutcome =
/** Resolves once the thread is gone, so its memory is freed before the next render. Never
* rejects. Mesh data lives outside the V8 heap cap, so a watchdog also polls process RSS. */
function renderInWorker(input: ModelPreviewWorkerInput): Promise<RenderOutcome> {
function renderInWorker(
input: ModelPreviewWorkerInput,
timeoutSeconds = MODEL_PREVIEW_TIMEOUT_SECONDS,
): Promise<RenderOutcome> {
return new Promise((resolve) => {
const maxGrowthBytes = MODEL_PREVIEW_MAX_MEMORY_MB * 1024 * 1024;
const baselineRss = process.memoryUsage.rss();
@@ -105,8 +108,8 @@ function renderInWorker(input: ModelPreviewWorkerInput): Promise<RenderOutcome>
}
}, MEMORY_POLL_MS);
const timeout = setTimeout(
() => finish({ status: "limit", reason: `took longer than ${MODEL_PREVIEW_TIMEOUT_SECONDS}s` }),
MODEL_PREVIEW_TIMEOUT_SECONDS * 1000,
() => finish({ status: "limit", reason: `took longer than ${timeoutSeconds}s` }),
timeoutSeconds * 1000,
);
worker.once("message", (result: ModelPreviewWorkerResult) => finish(result));
@@ -124,8 +127,32 @@ function renderInWorker(input: ModelPreviewWorkerInput): Promise<RenderOutcome>
const inFlight = new Set<string>();
const crashWarned = new Set<string>();
// One render at a time: parallel renders would multiply peak memory past the limit.
let queue: Promise<void> = Promise.resolve();
// One render at a time: parallel renders would multiply peak memory past the limit. A GLB someone is
// waiting on in the 3D viewer goes ahead of any dashboard picture, however many of those are queued.
type RenderJob = () => Promise<void>;
const glbJobs: RenderJob[] = [];
const imageJobs: RenderJob[] = [];
let rendering = false;
function scheduleRender(kind: "glb" | "image", job: RenderJob): Promise<void> {
return new Promise((resolve) => {
(kind === "glb" ? glbJobs : imageJobs).push(() => job().finally(resolve));
void drainRenders();
});
}
async function drainRenders(): Promise<void> {
if (rendering) return;
rendering = true;
try {
for (let job = glbJobs.shift() ?? imageJobs.shift(); job; job = glbJobs.shift() ?? imageJobs.shift()) {
// A job settles its own promise; one failing mustn't stop the rest.
await job().catch(() => undefined);
}
} finally {
rendering = false;
}
}
/** Never throws. Concurrent calls for the same plate no-op. */
export async function generateModelPreviewGlb(plateId: string, srcPath: string): Promise<void> {
@@ -141,9 +168,7 @@ export async function generateModelPreviewGlb(plateId: string, srcPath: string):
return;
}
inFlight.add(plateId);
const run = queue.then(() => runGeneration(plateId, srcPath));
queue = run;
await run;
await scheduleRender("glb", () => runGeneration(plateId, srcPath));
}
/** "failed" won't retry soon; "unsupported" is left to the browser's loaders. */
@@ -212,7 +237,7 @@ async function runGeneration(plateId: string, srcPath: string): Promise<void> {
}
// Pictures of a model for small slots like the dashboard lists: near-black on transparent.
// They share the GLB queue and limits, so the two never render at once.
// They share the GLB queue and memory limit, so the two never render at once, but wait behind GLBs.
/** Server-side picture formats; STEP needs a CAD kernel the server doesn't have. */
export const MODEL_IMAGE_EXTS = new Set([".stl", ".obj", ".3mf"]);
@@ -222,6 +247,8 @@ const MODEL_IMAGE_SIZE = 128;
// Shading scales this down, so it comes out between near-black in shadow and charcoal in the light.
// The dashboard inverts it for the dark theme.
const MODEL_IMAGE_COLOR = "#5a5a5a";
// A thumbnail isn't worth holding up the queue the way a model's own 3D preview is.
const MODEL_IMAGE_TIMEOUT_SECONDS = 20;
export function modelImagePath(plateId: string): string {
return path.join(MODEL_PREVIEWS, `${plateId}.image.v${MODEL_IMAGE_VERSION}.png`);
@@ -242,17 +269,20 @@ export async function generateModelImage(plateId: string, srcPath: string, filen
if (!canRenderModelImage(filename) || fsSync.existsSync(modelImagePath(plateId))) return;
if (imagesInFlight.has(plateId) || recentlyFailed(plateId, errorPath)) return;
imagesInFlight.add(plateId);
const run = queue.then(async () => {
await scheduleRender("image", async () => {
const dest = modelImagePath(plateId);
const tmp = `${dest}.${process.pid}.${Date.now()}.tmp`;
try {
const outcome = await renderInWorker({
kind: "image",
srcPath,
destPath: tmp,
ext: path.extname(filename).toLowerCase(),
image: { size: MODEL_IMAGE_SIZE, color: MODEL_IMAGE_COLOR },
});
const outcome = await renderInWorker(
{
kind: "image",
srcPath,
destPath: tmp,
ext: path.extname(filename).toLowerCase(),
image: { size: MODEL_IMAGE_SIZE, color: MODEL_IMAGE_COLOR },
},
MODEL_IMAGE_TIMEOUT_SECONDS,
);
if (outcome.status === "ok") {
await fs.rename(tmp, dest);
await fs.rm(errorPath, { force: true });
@@ -271,14 +301,19 @@ export async function generateModelImage(plateId: string, srcPath: string, filen
imagesInFlight.delete(plateId);
}
});
queue = run;
await run;
}
export function modelImageState(plateId: string): "ready" | "generating" | "failed" {
/** "unsupported" is a file the renderer read but couldn't draw, e.g. a 3MF layout it doesn't know. */
export function modelImageState(plateId: string): "ready" | "generating" | "failed" | "unsupported" {
if (fsSync.existsSync(modelImagePath(plateId))) return "ready";
if (imagesInFlight.has(plateId)) return "generating";
return recentlyFailed(plateId, modelImageErrorPath(plateId)) ? "failed" : "generating";
const errorPath = modelImageErrorPath(plateId);
if (!recentlyFailed(plateId, errorPath)) return "generating";
try {
return fsSync.readFileSync(errorPath, "utf-8") === UNSUPPORTED_MARKER ? "unsupported" : "failed";
} catch {
return "failed";
}
}
type GlbSummary = { triangles: number; simplified: boolean };
+160
View File
@@ -0,0 +1,160 @@
import { IMPORT_USER_AGENT } from "../config";
// The dashboard's weather, through the server like every other outside service: one place to
// cache, rate-limit and identify ourselves. Open-Meteo gives the forecast and place search,
// OpenStreetMap's Nominatim names a detected location.
export type Forecast = {
temperature: number;
code: number;
high: number;
low: number;
/** A few hours from now. */
later: { hours: number; temperature: number; code: number };
};
export type Place = { name: string; admin1?: string; country?: string; latitude: number; longitude: number };
const LATER_HOURS = 6;
const REQUEST_TIMEOUT_MS = 10_000;
const FORECAST_TTL_MS = 30 * 60 * 1000;
const SEARCH_TTL_MS = 24 * 60 * 60 * 1000;
// Plenty for one household's devices and searches; the oldest entries go first.
const CACHE_LIMIT = 500;
// Nominatim's usage policy: at most one request a second, with an identifying User-Agent.
const NOMINATIM_INTERVAL_MS = 1100;
const USER_AGENT = `${IMPORT_USER_AGENT} (self-hosted; +https://github.com/TautvydasDerzinskas/Thingport)`;
let fetchImpl: typeof fetch = (...args) => fetch(...args);
/** Lets tests answer for the outside services. */
export function setWeatherFetchForTest(impl?: typeof fetch): void {
fetchImpl = impl ?? ((...args) => fetch(...args));
forecasts.clear();
searches.clear();
placeNames.clear();
}
class TtlCache<T> {
private entries = new Map<string, { at: number; value: T }>();
constructor(private ttlMs: number) {}
get(key: string): T | undefined {
const entry = this.entries.get(key);
if (!entry || Date.now() - entry.at > this.ttlMs) return undefined;
return entry.value;
}
set(key: string, value: T): void {
this.entries.delete(key);
this.entries.set(key, { at: Date.now(), value });
if (this.entries.size > CACHE_LIMIT) this.entries.delete(this.entries.keys().next().value!);
}
clear(): void {
this.entries.clear();
}
}
const forecasts = new TtlCache<Forecast>(FORECAST_TTL_MS);
const searches = new TtlCache<Place[]>(SEARCH_TTL_MS);
// A town doesn't move, so names are kept as long as the cache has room.
const placeNames = new TtlCache<string | null>(Number.POSITIVE_INFINITY);
async function getJson(url: string, headers: Record<string, string> = {}): Promise<unknown> {
const res = await fetchImpl(url, {
headers: { "User-Agent": USER_AGENT, Accept: "application/json", ...headers },
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
if (!res.ok) throw new Error(`Weather service returned HTTP ${res.status}`);
return res.json();
}
// About 1 km apart: close enough to share a forecast, and it keeps exact coordinates out of the cache.
const round = (value: number, places: number) => Number(value.toFixed(places));
export async function getForecast(lat: number, lon: number): Promise<Forecast> {
const key = `${round(lat, 2)},${round(lon, 2)}`;
const cached = forecasts.get(key);
if (cached) return cached;
const params = new URLSearchParams({
latitude: String(round(lat, 2)),
longitude: String(round(lon, 2)),
current: "temperature_2m,weather_code",
hourly: "temperature_2m,weather_code",
daily: "temperature_2m_max,temperature_2m_min",
forecast_hours: String(LATER_HOURS + 1),
forecast_days: "1",
timezone: "auto",
});
const data = (await getJson(`https://api.open-meteo.com/v1/forecast?${params}`)) as {
current: { temperature_2m: number; weather_code: number };
hourly: { temperature_2m: number[]; weather_code: number[] };
daily: { temperature_2m_max: number[]; temperature_2m_min: number[] };
};
const forecast: Forecast = {
temperature: Math.round(data.current.temperature_2m),
code: data.current.weather_code,
high: Math.round(data.daily.temperature_2m_max[0]),
low: Math.round(data.daily.temperature_2m_min[0]),
later: {
hours: LATER_HOURS,
temperature: Math.round(data.hourly.temperature_2m[LATER_HOURS]),
code: data.hourly.weather_code[LATER_HOURS],
},
};
forecasts.set(key, forecast);
return forecast;
}
export async function searchPlaces(query: string, language: string): Promise<Place[]> {
const key = `${language}:${query.trim().toLowerCase()}`;
const cached = searches.get(key);
if (cached) return cached;
const params = new URLSearchParams({ name: query.trim(), count: "5", language, format: "json" });
const data = (await getJson(`https://geocoding-api.open-meteo.com/v1/search?${params}`)) as {
results?: Place[];
};
const places = (data.results ?? []).map(({ name, admin1, country, latitude, longitude }) => ({
name,
admin1,
country,
latitude,
longitude,
}));
searches.set(key, places);
return places;
}
let nominatimTurn: Promise<void> = Promise.resolve();
/** Waits its turn so the server never asks Nominatim more than once a second. */
function nominatimSlot(): Promise<void> {
const turn = nominatimTurn.then(() => new Promise<void>((resolve) => setTimeout(resolve, NOMINATIM_INTERVAL_MS)));
const mine = nominatimTurn;
nominatimTurn = turn;
return mine;
}
/** The town or city at a point, or null when it can't be named. */
export async function getPlaceName(lat: number, lon: number, language: string): Promise<string | null> {
const key = `${language}:${round(lat, 3)},${round(lon, 3)}`;
const cached = placeNames.get(key);
if (cached !== undefined) return cached;
await nominatimSlot();
const params = new URLSearchParams({
lat: String(round(lat, 3)),
lon: String(round(lon, 3)),
format: "json",
zoom: "10",
});
try {
const data = (await getJson(`https://nominatim.openstreetmap.org/reverse?${params}`, {
"Accept-Language": language,
})) as { address?: Record<string, string | undefined> };
const address = data.address ?? {};
const name = address.city || address.town || address.village || address.county || null;
placeNames.set(key, name);
return name;
} catch {
// Not cached: the next detection gets another try.
return null;
}
}
+5 -3
View File
@@ -150,9 +150,11 @@ describe("featured model", () => {
const today = await featured("2026-10-10");
expect(models.map((m) => m.id)).toContain(today.id);
expect((await featured("2026-10-10")).id).toBe(today.id);
// Over a few days the pick moves between models.
const days = await Promise.all(["11", "12", "13", "14", "15", "16"].map((d) => featured(`2026-10-${d}`)));
expect(new Set(days.map((print) => print.id)).size).toBeGreaterThan(1);
// Over a month the pick moves between models. Thirty days all landing on one of three would be a
// 1 in 10^14 chance, so this can't fail by bad luck.
const days = Array.from({ length: 30 }, (_, i) => `2026-11-${String(i + 1).padStart(2, "0")}`);
const picks = await Promise.all(days.map((day) => featured(day)));
expect(new Set(picks.map((print) => print.id)).size).toBeGreaterThan(1);
});
it("rejects a malformed date", async () => {
+156
View File
@@ -0,0 +1,156 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import fs from "node:fs/promises";
import fsSync from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeZip } from "../src/utils/zipWriter";
import {
generateModelImage,
generateModelPreviewGlb,
modelImagePath,
modelImageState,
modelPreviewGlbPath,
} from "../src/services/modelPreviewCache";
// A box as ASCII STL.
const v = (x: number, y: number, z: number) => `vertex ${x * 10} ${y * 10} ${z * 10}`;
const facet = (a: number[], b: number[], c: number[]) =>
`facet normal 0 0 0\nouter loop\n${v(a[0], a[1], a[2])}\n${v(b[0], b[1], b[2])}\n${v(c[0], c[1], c[2])}\nendloop\nendfacet`;
const quads: [number, number, number][][] = [
[
[0, 0, 0],
[1, 0, 0],
[1, 1, 0],
[0, 1, 0],
],
[
[0, 0, 1],
[0, 1, 1],
[1, 1, 1],
[1, 0, 1],
],
[
[0, 0, 0],
[0, 0, 1],
[1, 0, 1],
[1, 0, 0],
],
[
[0, 1, 0],
[1, 1, 0],
[1, 1, 1],
[0, 1, 1],
],
[
[0, 0, 0],
[0, 1, 0],
[0, 1, 1],
[0, 0, 1],
],
[
[1, 0, 0],
[1, 0, 1],
[1, 1, 1],
[1, 1, 0],
],
];
const BOX_STL = `solid box\n${quads.flatMap(([a, b, c, d]) => [facet(a, b, c), facet(a, c, d)]).join("\n")}\nendsolid box\n`;
const ONE_TRIANGLE_3MF_XML = `<?xml version="1.0" encoding="UTF-8"?>
<model xmlns="http://schemas.microsoft.com/3dmanufacturing/core/2015/02" unit="millimeter">
<resources><object id="1" type="model"><mesh><vertices><vertex x="0" y="0" z="0" /><vertex x="10" y="0" z="0" /><vertex x="0" y="10" z="0" /></vertices><triangles><triangle v1="0" v2="1" v3="2" /></triangles></mesh></object></resources>
<build><item objectid="1" /></build>
</model>
`;
let dir: string;
const plateIds: string[] = [];
const newPlateId = (label: string) => {
const id = `test-image-${label}-${Date.now()}-${plateIds.length}`;
plateIds.push(id);
return id;
};
async function file(name: string, content: string): Promise<string> {
const target = path.join(dir, name);
await fs.writeFile(target, content);
return target;
}
const cacheDir = () => path.dirname(modelImagePath("x"));
beforeAll(async () => {
// Needs no database, only the cache directory db.ts normally creates.
await fs.mkdir(cacheDir(), { recursive: true });
dir = await fs.mkdtemp(path.join(os.tmpdir(), "thingport-image-cache-"));
});
afterAll(async () => {
const names = await fs.readdir(cacheDir());
for (const name of names) {
if (plateIds.some((id) => name.startsWith(id))) await fs.rm(path.join(cacheDir(), name), { force: true });
}
await fs.rm(dir, { recursive: true, force: true });
});
describe("model picture cache", () => {
it("says generating while queued, then ready, and doesn't redraw an existing picture", async () => {
const plateId = newPlateId("box");
const src = await file("box.stl", BOX_STL);
const run = generateModelImage(plateId, src, "box.stl");
expect(modelImageState(plateId)).toBe("generating");
await run;
expect(modelImageState(plateId)).toBe("ready");
const drawnAt = (await fs.stat(modelImagePath(plateId))).mtimeMs;
await generateModelImage(plateId, src, "box.stl");
expect((await fs.stat(modelImagePath(plateId))).mtimeMs).toBe(drawnAt);
});
it("asks for one picture only once while it's being drawn", async () => {
const plateId = newPlateId("twice");
const src = await file("twice.stl", BOX_STL);
await Promise.all([generateModelImage(plateId, src, "twice.stl"), generateModelImage(plateId, src, "twice.stl")]);
expect(modelImageState(plateId)).toBe("ready");
});
it("calls a 3MF layout it can't read unsupported, and doesn't try it again", async () => {
const plateId = newPlateId("elsewhere");
const modelPath = await file("model.model", ONE_TRIANGLE_3MF_XML);
const src = path.join(dir, "elsewhere.3mf");
// Valid 3MF (the path comes from _rels/.rels), but not where the parser looks.
await writeZip(src, [{ arcname: "3D/model.model", filePath: modelPath }]);
await generateModelImage(plateId, src, "elsewhere.3mf");
expect(modelImageState(plateId)).toBe("unsupported");
const marker = path.join(cacheDir(), `${plateId}.image.v2.error`);
const markedAt = (await fs.stat(marker)).mtimeMs;
await generateModelImage(plateId, src, "elsewhere.3mf");
expect((await fs.stat(marker)).mtimeMs).toBe(markedAt);
});
it("calls a broken file failed rather than drawing nothing", async () => {
const plateId = newPlateId("broken");
await generateModelImage(plateId, await file("broken.stl", "solid empty\nendsolid empty\n"), "broken.stl");
expect(modelImageState(plateId)).toBe("failed");
});
it("ignores formats it can't draw", async () => {
const plateId = newPlateId("step");
await generateModelImage(plateId, await file("part.step", "ISO-10303-21;"), "part.step");
expect(fsSync.existsSync(modelImagePath(plateId))).toBe(false);
});
it("lets a 3D preview go ahead of pictures already waiting", async () => {
const pictures = await Promise.all(
[0, 1, 2].map(async (i) => ({ plateId: newPlateId(`queued${i}`), src: await file(`queued${i}.stl`, BOX_STL) })),
);
const modelPath = await file("3dmodel.model", ONE_TRIANGLE_3MF_XML);
const fixture = path.join(dir, "one.3mf");
await writeZip(fixture, [{ arcname: "3D/3dmodel.model", filePath: modelPath }]);
const glbPlate = newPlateId("glb");
// The first picture starts at once; the GLB, queued after the other two, still runs before them.
const drawing = pictures.map(({ plateId, src }) => generateModelImage(plateId, src, "box.stl"));
await generateModelPreviewGlb(glbPlate, fixture);
expect(fsSync.existsSync(modelPreviewGlbPath(glbPlate))).toBe(true);
expect(fsSync.existsSync(modelImagePath(pictures[2].plateId))).toBe(false);
await Promise.all(drawing);
expect(pictures.every(({ plateId }) => modelImageState(plateId) === "ready")).toBe(true);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import { createApp } from "../src/app";
import { getForecast, getPlaceName, searchPlaces, setWeatherFetchForTest } from "../src/services/weatherService";
const app = createApp();
let token: string;
const FORECAST = {
current: { temperature_2m: 10.6, weather_code: 3 },
hourly: { temperature_2m: [10, 10, 10, 10, 10, 10, 8.4], weather_code: [3, 3, 3, 3, 3, 3, 61] },
daily: { temperature_2m_max: [11.4], temperature_2m_min: [6.6] },
};
type Call = { url: URL; headers: Record<string, string> };
/** Answers like the outside services would, and records every request. */
function fakeServices(answer: (url: URL) => unknown = () => FORECAST, status = 200): Call[] {
const calls: Call[] = [];
setWeatherFetchForTest(async (input, init) => {
const url = new URL(String(input));
calls.push({ url, headers: (init?.headers ?? {}) as Record<string, string> });
return new Response(JSON.stringify(answer(url)), { status, headers: { "content-type": "application/json" } });
});
return calls;
}
beforeAll(async () => {
token = (
await request(app)
.post("/api/register")
.send({ displayName: "Weather", email: `weather-${Date.now()}@example.com`, password: "password123" })
).body.token;
});
afterEach(() => setWeatherFetchForTest());
describe("weather service", () => {
it("asks for a forecast once per area and keeps it for a while", async () => {
const calls = fakeServices();
const forecast = await getForecast(54.68916, 25.2798);
expect(forecast).toEqual({
temperature: 11,
code: 3,
high: 11,
low: 7,
later: { hours: 6, temperature: 8, code: 61 },
});
// A few hundred metres away is the same area.
await getForecast(54.6912, 25.2774);
expect(calls).toHaveLength(1);
// Coordinates are rounded before they leave the server.
expect(calls[0].url.searchParams.get("latitude")).toBe("54.69");
expect(calls[0].headers["User-Agent"]).toMatch(/^Thingport\//);
});
it("caches place searches per language", async () => {
const calls = fakeServices(() => ({
results: [{ name: "Vilnius", country: "Lietuva", latitude: 54.69, longitude: 25.28, population: 1 }],
}));
expect(await searchPlaces("Vilnius", "lt")).toEqual([
{ name: "Vilnius", admin1: undefined, country: "Lietuva", latitude: 54.69, longitude: 25.28 },
]);
await searchPlaces(" vilnius ", "lt");
await searchPlaces("Vilnius", "en");
expect(calls).toHaveLength(2);
});
it("names a place with an identifying User-Agent, at most once a second", async () => {
const calls = fakeServices(() => ({ address: { city: "Šiauliai" } }));
const started = Date.now();
expect(await getPlaceName(55.93, 23.31, "lt")).toBe("Šiauliai");
expect(await getPlaceName(56.1, 24.0, "lt")).toBe("Šiauliai");
expect(Date.now() - started).toBeGreaterThanOrEqual(1000);
expect(calls[0].headers["User-Agent"]).toMatch(/^Thingport\//);
expect(calls[0].headers["Accept-Language"]).toBe("lt");
// Already named: no third request.
await getPlaceName(55.93, 23.31, "lt");
expect(calls).toHaveLength(2);
});
});
describe("weather routes", () => {
const auth = () => ({ Authorization: `Bearer ${token}` });
it("returns the forecast, and 502 when the weather service is down", async () => {
fakeServices();
const ok = await request(app).get("/api/dashboard/weather?lat=54.69&lon=25.28").set(auth());
expect(ok.status).toBe(200);
expect(ok.body.temperature).toBe(11);
fakeServices(() => ({}), 503);
const down = await request(app).get("/api/dashboard/weather?lat=10&lon=10").set(auth());
expect(down.status).toBe(502);
expect(down.body.code).toBe("WEATHER_UNAVAILABLE");
});
it("rejects coordinates off the globe and too-short searches", async () => {
fakeServices();
expect((await request(app).get("/api/dashboard/weather?lat=91&lon=0").set(auth())).status).toBe(400);
expect((await request(app).get("/api/dashboard/weather/places?q=a").set(auth())).status).toBe(400);
});
it("needs a signed-in user", async () => {
expect((await request(app).get("/api/dashboard/weather?lat=1&lon=1")).status).toBe(401);
});
});