mirror of
https://github.com/TautvydasDerzinskas/Thingport.git
synced 2026-10-18 20:59:55 -05:00
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:
1 parent
163d73f2a7
commit
b13d0bf597
10 files changed
+599
-109
No files matched your search
@@ -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;
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user