111 lines
3.6 KiB
JavaScript
111 lines
3.6 KiB
JavaScript
/**
|
||
* 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.");
|