Le site public charge l'intégralité du corpus une seconde après l'ouverture de la page (loadAllHeatPoints), pour la heatmap et la recherche. Rien ne compressait dans la chaîne — ni Fastify, ni Traefik. Ce n'était donc pas un risque théorique mais la facture de chaque visiteur, à chaque visite. Mesuré sur 100 000 groupes avec des données réalistes, en-tête Accept-Encoding d'un vrai navigateur : 21,5 Mo deviennent 1,9 Mo. gzip est préféré à brotli, contre l'intuition : br sort 2,3 Mo là où gzip fait 1,9 Mo. La qualité brotli par défaut est réglée pour la vitesse, et sur ce JSON très répétitif elle perd sur les deux tableaux à la fois — taille ET CPU. Seuil à 1 Ko, en dessous duquel compresser coûte plus que ça ne rapporte. /api/clusters plafonnait par ailleurs sa réponse à 2000 groupes ou 1000 clusters sans le dire, ni côté API ni côté client : dans une zone dense, des groupes disparaissaient de la carte sans aucun signal. La réponse porte désormais `truncated` et `limit`, et l'interface affiche un avertissement invitant à zoomer. Clé de traduction ajoutée dans les 21 langues, la convention du fichier étant une couverture complète. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
462 lines
19 KiB
JavaScript
462 lines
19 KiB
JavaScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
|
|
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
|
|
|
|
const { rows } = await import("./helpers/fakePool.js");
|
|
const { createFullApp, createFullAppWith } = await import("./helpers/testApp.js");
|
|
|
|
const TOKEN = "c4b99106fcd09badce25810e974ec4d6";
|
|
|
|
// Deux apps pour tout le fichier (cf. helpers/testApp.js : construire une app
|
|
// coûte ~14 ms, multipliées par des milliers d'exécutions en tests de mutation).
|
|
// Chaque cas se contente de reprogrammer le pool associé.
|
|
let app, pool, tokenApp, tokenPool;
|
|
|
|
beforeAll(async () => {
|
|
({ app, pool } = await createFullApp());
|
|
({ app: tokenApp, pool: tokenPool } = await createFullApp({ importToken: TOKEN }));
|
|
});
|
|
afterAll(async () => {
|
|
await app.close();
|
|
await tokenApp.close();
|
|
});
|
|
|
|
/** Handlers par défaut : chaque requête renvoie quelque chose de plausible. */
|
|
function defaultHandlers() {
|
|
return [
|
|
{ match: "FROM band_locations bl", result: rows({ ma_id: 1, name: "Mayhem", lat: 59.9, lon: 10.7 }) },
|
|
{ match: "grid_cells", result: rows({ lat: 59.9, lon: 10.7, count: 3, sample_bands: [] }) },
|
|
{ match: "FROM bands", result: rows({ ma_id: 1, name: "Mayhem", total: 1 }) },
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Reprogramme le pool partagé et renvoie l'app publique.
|
|
* @param {import("./helpers/fakePool.js").Handler[]} [handlers]
|
|
*/
|
|
function build(handlers = defaultHandlers()) {
|
|
pool.reset(handlers);
|
|
return app;
|
|
}
|
|
|
|
/** Idem pour l'app configurée avec un jeton d'import. */
|
|
function buildToken(handlers = []) {
|
|
tokenPool.reset(handlers);
|
|
return tokenApp;
|
|
}
|
|
|
|
describe("/api/health et /", () => {
|
|
it("health répond 200 sans toucher la base", async () => {
|
|
const res = await build([]).inject({ method: "GET", url: "/api/health" });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json()).toEqual({ ok: true });
|
|
expect(pool.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("la racine identifie le service", async () => {
|
|
expect((await build([]).inject({ method: "GET", url: "/" })).json().ok).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("/api/clusters — troncature", () => {
|
|
it("signale la troncature quand le plafond est atteint", async () => {
|
|
// La réponse était plafonnée sans le dire : dans une zone dense, des
|
|
// groupes disparaissaient de la carte sans aucun signal.
|
|
const pleine = Array.from({ length: 2000 }, (_, i) => ({ ma_id: i }));
|
|
const res = await build([{ match: "FROM band_locations", result: { rows: pleine, rowCount: 2000 } }])
|
|
.inject({ method: "GET", url: "/api/clusters?bbox=0,0,10,10&zoom=13" });
|
|
const body = JSON.parse(res.payload);
|
|
expect(body.truncated).toBe(true);
|
|
expect(body.limit).toBe(2000);
|
|
});
|
|
|
|
it("ne signale rien quand tout tient", async () => {
|
|
const res = await build([{ match: "FROM band_locations", result: { rows: [{ ma_id: 1 }], rowCount: 1 } }])
|
|
.inject({ method: "GET", url: "/api/clusters?bbox=0,0,10,10&zoom=13" });
|
|
expect(JSON.parse(res.payload).truncated).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("CORS", () => {
|
|
it("renvoie l'en-tête pour une origine autorisée", async () => {
|
|
const res = await build().inject({
|
|
method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu" },
|
|
});
|
|
expect(res.headers["access-control-allow-origin"]).toBe("https://metalfrom.eu");
|
|
});
|
|
|
|
it("n'écho PAS une origine non autorisée", async () => {
|
|
const res = await build().inject({
|
|
method: "GET", url: "/api/health", headers: { origin: "https://evil.example" },
|
|
});
|
|
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
|
|
});
|
|
|
|
it("annonce Vary: Origin, y compris quand l'origine est refusée", async () => {
|
|
// L'en-tete Allow-Origin DEPEND de l'origine demandee. Sans Vary, un cache
|
|
// intermediaire peut servir a une origine la reponse mise en cache pour une
|
|
// autre — ce qui revient a autoriser une origine qui ne l'est pas, ou a
|
|
// faire refuser une origine legitime.
|
|
for (const origin of ["https://metalfrom.eu", "https://evil.example", undefined]) {
|
|
const res = await build().inject({
|
|
method: "GET", url: "/api/health", headers: origin ? { origin } : {},
|
|
});
|
|
expect(res.headers["vary"]).toContain("Origin");
|
|
}
|
|
});
|
|
|
|
it("ne fait pas de match par préfixe sur l'origine", async () => {
|
|
const res = await build().inject({
|
|
method: "GET", url: "/api/health", headers: { origin: "https://metalfrom.eu.evil.example" },
|
|
});
|
|
expect(res.headers["access-control-allow-origin"]).toBeUndefined();
|
|
});
|
|
|
|
// Régression : sans `return reply`, Fastify poursuivait le cycle de vie et le
|
|
// préflight repartait dans le handler de route (ou un 404).
|
|
it("le préflight OPTIONS répond 204 et court-circuite le routage", async () => {
|
|
const res = await build().inject({ method: "OPTIONS", url: "/api/bands" });
|
|
expect(res.statusCode).toBe(204);
|
|
expect(res.body).toBe("");
|
|
});
|
|
|
|
it("OPTIONS répond 204 même sur une route inexistante", async () => {
|
|
expect((await build().inject({ method: "OPTIONS", url: "/nimporte/quoi" })).statusCode).toBe(204);
|
|
});
|
|
});
|
|
|
|
describe("en-têtes de sécurité (helmet)", () => {
|
|
it.each([
|
|
"x-content-type-options",
|
|
"x-frame-options",
|
|
"strict-transport-security",
|
|
])("pose %s", async (header) => {
|
|
const res = await build().inject({ method: "GET", url: "/api/health" });
|
|
expect(res.headers[header]).toBeDefined();
|
|
});
|
|
|
|
it("ne divulgue pas la stack ni les identifiants en cas d'erreur serveur", async () => {
|
|
const res = await build([
|
|
{ match: "FROM bands", throws: new Error("connexion perdue: host=10.0.1.7 user=bm") },
|
|
]).inject({ method: "GET", url: "/api/stats" });
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body).not.toMatch(/10\.0\.1\.7|user=bm|at Object/);
|
|
});
|
|
});
|
|
|
|
describe("/api/bands — validation", () => {
|
|
it("répond 200 sans paramètre", async () => {
|
|
const res = await build().inject({ method: "GET", url: "/api/bands" });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().limit).toBe(1000);
|
|
});
|
|
|
|
// Régression : Number("abc") = NaN partait dans LIMIT $n → erreur pg → 500.
|
|
it.each([
|
|
["limit=abc", "/api/bands?limit=abc"],
|
|
["limit=0", "/api/bands?limit=0"],
|
|
["limit=-5", "/api/bands?limit=-5"],
|
|
["offset=abc", "/api/bands?offset=abc"],
|
|
["offset=-1", "/api/bands?offset=-1"],
|
|
["offset>1M", "/api/bands?offset=1000001"],
|
|
])("répond 400 (pas 500) pour %s", async (_label, url) => {
|
|
const res = await build().inject({ method: "GET", url });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().ok).toBe(false);
|
|
});
|
|
|
|
it("plafonne le limit sans erreur", async () => {
|
|
expect((await build().inject({ method: "GET", url: "/api/bands?limit=999999" })).json().limit).toBe(150000);
|
|
});
|
|
|
|
it("rejette une recherche d'un seul caractère", async () => {
|
|
expect((await build().inject({ method: "GET", url: "/api/bands?q=a" })).statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejette les patterns ILIKE pathologiques", async () => {
|
|
expect((await build().inject({ method: "GET", url: "/api/bands?q=%25%25%25" })).statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejette plus de 100 pays", async () => {
|
|
const many = Array.from({ length: 101 }, (_, i) => `C${i}`).join(",");
|
|
const res = await build().inject({ method: "GET", url: `/api/bands?countries=${many}` });
|
|
expect(res.statusCode).toBe(400);
|
|
expect(res.json().error).toMatch(/Max 100/);
|
|
});
|
|
|
|
it("rejette plus de 50 statuts", async () => {
|
|
const many = Array.from({ length: 51 }, (_, i) => `s${i}`).join(",");
|
|
expect((await build().inject({ method: "GET", url: `/api/bands?status=${many}` })).statusCode).toBe(400);
|
|
});
|
|
|
|
it("passe la recherche en paramètre lié", async () => {
|
|
await build().inject({
|
|
method: "GET", url: "/api/bands?q=" + encodeURIComponent("'; DROP TABLE bands--"),
|
|
});
|
|
const call = pool.find("FROM bands");
|
|
expect(call.sql).not.toContain("DROP TABLE");
|
|
expect(call.values.some((v) => String(v).includes("DROP TABLE"))).toBe(true);
|
|
});
|
|
|
|
it("normalise les codes pays en majuscules", async () => {
|
|
await build().inject({ method: "GET", url: "/api/bands?countries=fr,de" });
|
|
expect(pool.find("FROM bands").values).toContainEqual(["FR", "DE"]);
|
|
});
|
|
});
|
|
|
|
describe("/api/clusters — validation géographique", () => {
|
|
it.each([
|
|
["bbox absente", "/api/clusters?zoom=5"],
|
|
["zoom absent", "/api/clusters?bbox=-5,40,10,55"],
|
|
["bbox à 3 valeurs", "/api/clusters?bbox=1,2,3&zoom=5"],
|
|
["bbox non numérique", "/api/clusters?bbox=a,b,c,d&zoom=5"],
|
|
["latitude > 90", "/api/clusters?bbox=-5,40,10,95&zoom=5"],
|
|
["longitude < -180", "/api/clusters?bbox=-181,40,10,55&zoom=5"],
|
|
["bbox inversée", "/api/clusters?bbox=10,40,-5,55&zoom=5"],
|
|
["zoom négatif", "/api/clusters?bbox=-5,40,10,55&zoom=-1"],
|
|
["zoom > 22", "/api/clusters?bbox=-5,40,10,55&zoom=23"],
|
|
])("répond 400 pour %s", async (_label, url) => {
|
|
expect((await build().inject({ method: "GET", url })).statusCode).toBe(400);
|
|
});
|
|
|
|
it("accepte une bbox valide", async () => {
|
|
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
// Le seuil de zoom 12 fait basculer entre agrégation en grille et points bruts.
|
|
it("agrège en clusters en dessous du zoom 12", async () => {
|
|
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=11" });
|
|
expect(res.json().type).toBe("clusters");
|
|
expect(pool.find("grid_cells")).toBeDefined();
|
|
});
|
|
|
|
it("renvoie les groupes bruts à partir du zoom 12", async () => {
|
|
const res = await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=12" });
|
|
expect(res.json().type).toBe("bands");
|
|
expect(pool.find("grid_cells")).toBeUndefined();
|
|
});
|
|
|
|
it("ne lit que les localisations réellement géocodées", async () => {
|
|
await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
|
|
const sql = pool.find("grid_cells").sql;
|
|
expect(sql).toContain("bl.geom IS NOT NULL");
|
|
expect(sql).toContain("geocode_status IN ('done','country_only')");
|
|
});
|
|
|
|
it("utilise l'index spatial via ST_MakeEnvelope avec des paramètres liés", async () => {
|
|
await build().inject({ method: "GET", url: "/api/clusters?bbox=-5,40,10,55&zoom=5" });
|
|
const call = pool.find("grid_cells");
|
|
expect(call.sql).toContain("bl.geom && ST_MakeEnvelope($1, $2, $3, $4, 4326)");
|
|
expect(call.values.slice(0, 4)).toEqual([-5, 40, 10, 55]);
|
|
});
|
|
});
|
|
|
|
describe("/api/band/:ma_id", () => {
|
|
it("répond 400 pour un identifiant non numérique", async () => {
|
|
expect((await build().inject({ method: "GET", url: "/api/band/abc" })).statusCode).toBe(400);
|
|
});
|
|
|
|
it("répond 404 quand le groupe n'existe pas", async () => {
|
|
const res = await build([{ match: "FROM bands", result: rows() }])
|
|
.inject({ method: "GET", url: "/api/band/42" });
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("accepte un ma_id au-delà de 2^31 (colonne BIGINT)", async () => {
|
|
const res = await build([{ match: "FROM bands", result: rows({ ma_id: 3500000000 }) }])
|
|
.inject({ method: "GET", url: "/api/band/3500000000" });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(pool.find("FROM bands").values).toEqual([3500000000]);
|
|
});
|
|
});
|
|
|
|
describe("/admin/import — jeton Bearer", () => {
|
|
it.each([
|
|
["sans en-tête", undefined],
|
|
["jeton vide", "Bearer "],
|
|
["jeton faux de même longueur", `Bearer ${"0".repeat(TOKEN.length)}`],
|
|
["jeton faux plus court", "Bearer abc"],
|
|
["schéma Basic", "Basic " + Buffer.from("a:b").toString("base64")],
|
|
])("répond 401 %s", async (_label, authorization) => {
|
|
const res = await buildToken().inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: authorization ? { authorization } : {},
|
|
payload: { bands: [] },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("ne touche pas la base quand le jeton est invalide", async () => {
|
|
await buildToken().inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: "Bearer faux" },
|
|
payload: { bands: [{ ma_id: 1 }] },
|
|
});
|
|
expect(tokenPool.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("accepte le bon jeton", async () => {
|
|
const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${TOKEN}` },
|
|
payload: { bands: [{ ma_id: 1, name: "Mayhem" }] },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().upserted).toBe(1);
|
|
});
|
|
|
|
// Un jeton multi-octets de même longueur en caractères mais pas en octets
|
|
// faisait lever timingSafeEqual → 500 au lieu d'un 401 propre.
|
|
it("répond 401 (pas 500) pour un jeton multi-octets", async () => {
|
|
const res = await buildToken().inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${"é".repeat(TOKEN.length)}` },
|
|
payload: { bands: [] },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
it("refuse un corps sans tableau bands", async () => {
|
|
for (const payload of [{}, { bands: "pas-un-tableau" }, { bands: null }]) {
|
|
const res = await buildToken().inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${TOKEN}` }, payload,
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
}
|
|
});
|
|
|
|
it("refuse plus de 1000 groupes par lot", async () => {
|
|
const bands = Array.from({ length: 1001 }, (_, i) => ({ ma_id: i }));
|
|
const res = await buildToken().inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${TOKEN}` }, payload: { bands },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("ignore les entrées sans ma_id numérique sans planter", async () => {
|
|
const res = await buildToken([{ match: "INSERT INTO bands", result: rows() }]).inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: `Bearer ${TOKEN}` },
|
|
payload: { bands: [{ ma_id: "abc" }, null, "chaine", { ma_id: 7 }] },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().upserted).toBe(1);
|
|
});
|
|
|
|
// Cas nécessitant une fabrique différente : app sans jeton configuré.
|
|
it("quand aucun jeton n'est configuré, tout est refusé", async () => {
|
|
const noTokenApp = await createFullAppWith({ importToken: "", pool: null });
|
|
const res = await noTokenApp.inject({
|
|
method: "POST", url: "/admin/import",
|
|
headers: { authorization: "Bearer nimportequoi" }, payload: { bands: [] },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
await noTokenApp.close();
|
|
});
|
|
});
|
|
|
|
describe("/admin/auth/login", () => {
|
|
it("refuse un corps mal formé sans requête SQL", async () => {
|
|
const a = build([]);
|
|
for (const payload of [{}, { username: "a" }, { username: 1, password: 2 }]) {
|
|
expect((await a.inject({ method: "POST", url: "/admin/auth/login", payload })).statusCode).toBe(400);
|
|
}
|
|
expect(pool.calls).toHaveLength(0);
|
|
});
|
|
|
|
it("répond 429 quand le compte est verrouillé, sans vérifier le mot de passe", async () => {
|
|
const res = await build([
|
|
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 99, par_username: 0 }) },
|
|
{ match: "FROM admin_users", result: rows({ password_hash: "x" }) },
|
|
]).inject({
|
|
method: "POST", url: "/admin/auth/login",
|
|
payload: { username: "nico", password: "x" },
|
|
});
|
|
expect(res.statusCode).toBe(429);
|
|
expect(pool.find("FROM admin_users")).toBeUndefined();
|
|
});
|
|
|
|
it("enregistre la tentative échouée et ne pose pas de cookie", async () => {
|
|
const bcrypt = (await import("bcryptjs")).default;
|
|
// Hash à coût 4 : le DUMMY_HASH de production est à coût 12 (~330 ms).
|
|
const hash = bcrypt.hashSync("autre-chose", 4);
|
|
const res = await build([
|
|
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 0 }) },
|
|
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
|
|
{ match: "INSERT INTO admin_login_attempts", result: rows() },
|
|
]).inject({
|
|
method: "POST", url: "/admin/auth/login",
|
|
payload: { username: "nico", password: "mauvais" },
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
expect(res.headers["set-cookie"]).toBeUndefined();
|
|
expect(pool.find("INSERT INTO admin_login_attempts").values[2]).toBe(false);
|
|
});
|
|
|
|
it("le cookie de session est httpOnly, secure, sameSite=strict", async () => {
|
|
const bcrypt = (await import("bcryptjs")).default;
|
|
const hash = bcrypt.hashSync("bon", 4);
|
|
const res = await build([
|
|
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 0 }) },
|
|
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
|
|
{ match: "INSERT INTO admin_login_attempts", result: rows() },
|
|
{ match: "UPDATE admin_users", result: rows() },
|
|
]).inject({
|
|
method: "POST", url: "/admin/auth/login",
|
|
payload: { username: "nico", password: "bon" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const cookie = String(res.headers["set-cookie"]);
|
|
expect(cookie).toMatch(/HttpOnly/i);
|
|
expect(cookie).toMatch(/Secure/i);
|
|
expect(cookie).toMatch(/SameSite=Strict/i);
|
|
// Le jeton ne doit jamais apparaître dans le corps de la réponse
|
|
expect(res.json()).toEqual({ ok: true, username: "nico" });
|
|
});
|
|
|
|
it("tronque un username exagérément long avant la requête", async () => {
|
|
const bcrypt = (await import("bcryptjs")).default;
|
|
const hash = bcrypt.hashSync("x", 4);
|
|
await build([
|
|
{ match: "FROM admin_login_attempts", result: rows({ par_ip: 0, par_username: 0 }) },
|
|
{ match: "FROM admin_users", result: rows({ password_hash: hash }) },
|
|
{ match: "INSERT INTO admin_login_attempts", result: rows() },
|
|
]).inject({
|
|
method: "POST", url: "/admin/auth/login",
|
|
payload: { username: "a".repeat(5000), password: "x" },
|
|
});
|
|
expect(pool.find("FROM admin_users").values[0]).toHaveLength(100);
|
|
});
|
|
});
|
|
|
|
describe("gestionnaire d'erreurs global", () => {
|
|
// Régression : setErrorHandler écrasait TOUS les statuts en 500, y compris
|
|
// les 4xx légitimes (payload trop gros, JSON invalide, rate-limit).
|
|
it("préserve un 400 sur JSON invalide", async () => {
|
|
const res = await build([]).inject({
|
|
method: "POST", url: "/admin/auth/login",
|
|
headers: { "content-type": "application/json" },
|
|
payload: "{ceci n'est pas du json",
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("renvoie 500 générique pour une erreur inattendue", async () => {
|
|
const res = await build([{ match: "FROM bands", throws: new Error("boom") }])
|
|
.inject({ method: "GET", url: "/api/stats" });
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.json().error).not.toContain("boom");
|
|
});
|
|
});
|
|
|
|
describe("sans base de données configurée", () => {
|
|
it("les routes de données répondent 500 mais le service reste debout", async () => {
|
|
const noDbApp = await createFullAppWith({ pool: null });
|
|
expect((await noDbApp.inject({ method: "GET", url: "/api/stats" })).statusCode).toBe(500);
|
|
expect((await noDbApp.inject({ method: "GET", url: "/api/health" })).statusCode).toBe(200);
|
|
await noDbApp.close();
|
|
});
|
|
});
|