/** * Lasttest mot lokal API (körs mot dist-bygget): * node apps/api/dist/index.js & # starta API:t först * node scripts/loadtest.mjs * * Tre scenarier: oautentiserad healthz, autentiserad receptlista (DB + översättnings- * upplösning) och rekommendationer (tyngsta läsvägen: lager + motor + förklaringar). * Trösklar (encore-server, 1 CPU-klass): p99 < 500 ms, inga fel, > 100 req/s på recept. */ import autocannon from "autocannon"; const BASE = process.env.API_BASE_URL ?? "http://localhost:4000"; async function json(path, options = {}) { const res = await fetch(`${BASE}${path}`, { ...options, headers: { "content-type": "application/json", ...(options.headers ?? {}) }, body: options.body ? JSON.stringify(options.body) : undefined, }); if (!res.ok) throw new Error(`${path} -> ${res.status}`); return res.json(); } // --- Setup: användare med hushåll + lite lager, så läsvägarna är realistiska --- const email = `loadtest-${process.pid}@example.com`; let auth; try { auth = await json("/v1/auth/register", { method: "POST", body: { email, password: "Lasttest1234!", displayName: "Lasttest" }, }); } catch { auth = await json("/v1/auth/login", { method: "POST", body: { email, password: "Lasttest1234!" }, }); } const token = auth.accessToken; const headers = { authorization: `Bearer ${token}` }; const hh = await json("/v1/households", { method: "POST", headers, body: { name: "Lasttest" } }); const full = await json(`/v1/households/${hh.id}`, { headers }); const locId = full.storageLocations[0].id; for (const [ing, qty] of [["pasta_spaghetti", 500], ["minced_beef", 400], ["onion_yellow", 3]]) { await json("/v1/inventory/items", { method: "POST", headers, body: { ingredientId: ing, displayName: ing, storageLocationId: locId, quantity: qty, unit: qty > 10 ? "GRAM" : "COUNT", }, }).catch(() => {}); } // --- Scenarier --- const scenarios = [ { name: "healthz (baslinje)", path: "/healthz", auth: false }, { name: "GET /v1/recipes (lista + i18n-upplösning)", path: "/v1/recipes?limit=20", auth: true }, { name: "GET /v1/recommendations/what-to-eat (tyngsta läsvägen)", path: "/v1/recommendations/what-to-eat?meal=dinner", auth: true, }, ]; const results = []; for (const s of scenarios) { const r = await autocannon({ url: `${BASE}${s.path}`, connections: 25, duration: 10, headers: s.auth ? headers : {}, }); results.push({ scenario: s.name, reqPerSec: Math.round(r.requests.average), latencyP50: r.latency.p50, latencyP99: r.latency.p99, errors: r.errors + r.non2xx, }); console.log( `${s.name}: ${Math.round(r.requests.average)} req/s, p50 ${r.latency.p50} ms, p99 ${r.latency.p99} ms, fel ${r.errors + r.non2xx}`, ); } // --- Trösklar --- const failures = []; for (const r of results) { if (r.errors > 0) failures.push(`${r.scenario}: ${r.errors} fel`); if (r.latencyP99 > 500) failures.push(`${r.scenario}: p99 ${r.latencyP99} ms > 500 ms`); } const recipes = results[1]; if (recipes && recipes.reqPerSec < 100) { failures.push(`receptlistan: ${recipes.reqPerSec} req/s < 100`); } console.log("\n| Scenario | req/s | p50 | p99 | fel |"); console.log("| -------- | ----- | --- | --- | --- |"); for (const r of results) { console.log( `| ${r.scenario} | ${r.reqPerSec} | ${r.latencyP50} ms | ${r.latencyP99} ms | ${r.errors} |`, ); } if (failures.length) { console.error("\nUNDERKÄND:\n" + failures.map((f) => ` ✗ ${f}`).join("\n")); process.exit(1); } console.log("\nGODKÄND – alla trösklar klarade.");