Deux angles morts fermés. 1. Syntaxe SQL sans conteneur (apps/api/test/sqlSyntax.test.js) Le faux pool vérifiait la FORME du SQL mais ne l'exécutait jamais : une requête syntaxiquement invalide passait tous les tests et n'échouait qu'en production — c'est précisément ce qui s'était produit avec crawler_pending. Chaque requête réellement émise par les 28 routes est désormais parsée avec la grammaire PostgreSQL (node-sql-parser), y compris les SET dynamiques du PATCH et les casts par type de resolve-conflict. 48 tests, aucun conteneur. Limite déclarée explicitement : la sémantique n'est pas validée, et deux requêtes bâties sur jsonb_build_object ne sont pas parsables — le test échoue si une route cesse d'avoir la moindre requête vérifiable, pour éviter qu'il passe au vert à vide. 2. Supervision des services de fond (migration 015) Le crawler et les workers ne sont pas exposés par Traefik : aucune sonde HTTP ne peut les atteindre. Un crawler dont FlareSolverr était injoignable, ou un worker à court de quota Geoapify, restait muet — le seul symptôme était l'absence de données nouvelles, qu'il fallait remarquer soi-même. Chaque service écrit un battement de cœur horaire dans service_health : - crawler : base, FlareSolverr joignable, dernier run terminé < 12 h - geocoder : base, clé Geoapify présente, API joignable, progression < 6 h Une ligne par service, écrasée à chaque contrôle. L'API calcule `stale` en SQL (> 2 h sans écriture) : un service arrêté cesse d'écrire, et son dernier contrôle réussi le ferait sinon passer pour sain indéfiniment. Le Pilotage affiche une carte « Services » et remonte chaque service dégradé ou silencieux en alerte actionnable. Règle appliquée aux sondes : aucune ne peut interrompre le service qu'elle surveille. Toute exception devient un échec de sonde, l'écriture du résultat et la journalisation échouent en silence. Un contrôle de santé qui fait tomber le crawler serait pire que pas de contrôle. Tests : 580 JS (+51), 63 Python (+20), 85 Playwright (+3) Co-Authored-By: Claude <noreply@anthropic.com>
468 lines
20 KiB
JavaScript
468 lines
20 KiB
JavaScript
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
|
|
process.env.ADMIN_JWT_SECRET = "secret-de-test-suffisamment-long-pour-passer-32";
|
|
|
|
const { signAdminSession, ADMIN_COOKIE_NAME } = await import("../src/adminAuth.js");
|
|
const { rows } = await import("./helpers/fakePool.js");
|
|
const { createAdminApp } = await import("./helpers/testApp.js");
|
|
|
|
let auth, app, pool;
|
|
|
|
// Une seule construction d'app pour tout le fichier ; chaque test reprogramme
|
|
// le pool via buildApp(handlers).
|
|
beforeAll(async () => {
|
|
auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
|
|
({ app, pool } = await createAdminApp());
|
|
});
|
|
afterAll(async () => { await app.close(); });
|
|
|
|
/** Reprogramme le pool partagé et renvoie l'app. */
|
|
function buildApp(handlers) {
|
|
pool.reset(Array.isArray(handlers) ? handlers : handlers ?? []);
|
|
return app;
|
|
}
|
|
|
|
/** Pool permissif : n'importe quelle requête renvoie une ligne plausible. */
|
|
function anyPool(extra = []) {
|
|
return [...extra, { match: /./, result: rows({ n: 1, total: 1, id: 1, status: "done", count: 1 }) }];
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Routes de lecture : forme de la réponse + protection
|
|
// ------------------------------------------------------------------
|
|
describe("routes de lecture", () => {
|
|
const READ_ROUTES = [
|
|
"/admin/api/stats",
|
|
"/admin/api/queue",
|
|
"/admin/api/crawl-checkpoints",
|
|
"/admin/api/logs",
|
|
"/admin/api/geocoding",
|
|
"/admin/api/llm",
|
|
"/admin/api/job-triggers",
|
|
"/admin/api/live",
|
|
"/admin/api/activity",
|
|
"/admin/api/bands",
|
|
];
|
|
|
|
it.each(READ_ROUTES)("%s exige une session", async (url) => {
|
|
const handlers = ([]);
|
|
const app = buildApp(handlers);
|
|
expect((await app.inject({ method: "GET", url })).statusCode).toBe(401);
|
|
expect(pool.calls).toHaveLength(0);
|
|
});
|
|
|
|
it.each(READ_ROUTES)("%s répond 200 et ok:true avec une session", async (url) => {
|
|
const app = buildApp(anyPool());
|
|
const res = await app.inject({ method: "GET", url, headers: auth });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().ok).toBe(true);
|
|
});
|
|
|
|
it.each(READ_ROUTES)("%s renvoie 500 sans divulguer l'erreur si la base tombe", async (url) => {
|
|
const handlers = ([{ match: /./, throws: new Error("FATAL: password authentication failed for user bm") }]);
|
|
const app = buildApp(handlers);
|
|
const res = await app.inject({ method: "GET", url, headers: auth });
|
|
expect(res.statusCode).toBe(500);
|
|
expect(res.body).not.toMatch(/password|user bm/);
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/stats", () => {
|
|
it("agrège totaux, statuts, pays et genres", async () => {
|
|
const handlers = ([
|
|
{ match: "count(*)::int AS total,", result: rows({ total: 10, enriched: 4, geocoded: 6 }) },
|
|
{ match: "AS status, count", result: rows({ status: "Active", total: 7 }) },
|
|
{ match: "AS country, count", result: rows({ country: "NO", total: 3 }) },
|
|
{ match: "SELECT genre, count", result: rows({ genre: "Black Metal", total: 2 }) },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const body = (await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth })).json();
|
|
|
|
expect(body.totals.total).toBe(10);
|
|
expect(body.by_status[0].status).toBe("Active");
|
|
expect(body.by_country[0].country).toBe("NO");
|
|
expect(body.by_genre[0].genre).toBe("Black Metal");
|
|
});
|
|
|
|
it("les quatre agrégats sont lancés en parallèle, pas en cascade", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/stats", headers: auth });
|
|
expect(pool.calls.length).toBe(4);
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/bands/:ma_id", () => {
|
|
const detailPool = () =>
|
|
([
|
|
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Mayhem" }) },
|
|
{ match: "FROM band_locations", result: rows({ id: 9, step_order: 0, location_raw: "Oslo" }) },
|
|
{ match: "FROM llm_cache", result: rows({ id: 3, model: "llama-3.3-70b-versatile" }) },
|
|
]);
|
|
|
|
it("renvoie le groupe, ses localisations et ses appels LLM", async () => {
|
|
const app = buildApp(detailPool());
|
|
const body = (await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth })).json();
|
|
expect(body.item.name).toBe("Mayhem");
|
|
expect(body.locations).toHaveLength(1);
|
|
expect(body.llm).toHaveLength(1);
|
|
});
|
|
|
|
it("répond 400 pour un ma_id invalide", async () => {
|
|
const app = buildApp(detailPool());
|
|
for (const bad of ["abc", "-1"]) {
|
|
expect((await app.inject({ method: "GET", url: `/admin/api/bands/${bad}`, headers: auth })).statusCode).toBe(400);
|
|
}
|
|
});
|
|
|
|
it("répond 404 quand le groupe n'existe pas", async () => {
|
|
const handlers = ([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
|
|
const app = buildApp(handlers);
|
|
expect((await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth })).statusCode).toBe(404);
|
|
});
|
|
|
|
// Les tables du nouveau pipeline peuvent ne pas exister sur une base ancienne :
|
|
// le détail du groupe doit rester consultable malgré tout.
|
|
it("dégrade proprement si band_locations ou llm_cache sont absentes", async () => {
|
|
const handlers = ([
|
|
{ match: "SELECT * FROM bands WHERE ma_id", result: rows({ ma_id: 1, name: "Mayhem" }) },
|
|
{ match: "FROM band_locations", throws: new Error('relation "band_locations" does not exist') },
|
|
{ match: "FROM llm_cache", throws: new Error('relation "llm_cache" does not exist') },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const res = await app.inject({ method: "GET", url: "/admin/api/bands/1", headers: auth });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().locations).toEqual([]);
|
|
expect(res.json().llm).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/logs — filtres", () => {
|
|
it("filtre par niveau, run et curseur, en paramètres liés", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({
|
|
method: "GET",
|
|
url: "/admin/api/logs?level=error&run_id=12&min_id=100&pageSize=25",
|
|
headers: auth,
|
|
});
|
|
const call = pool.find("FROM crawl_log");
|
|
expect(call.sql).toContain("level = $1");
|
|
expect(call.sql).toContain("run_id = $2");
|
|
expect(call.sql).toContain("id > $3");
|
|
expect(call.values).toEqual(["error", 12, 100, 25, 0]);
|
|
});
|
|
|
|
it("sans filtre, aucune clause WHERE", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/logs", headers: auth });
|
|
expect(pool.find("FROM crawl_log").sql).not.toContain("WHERE");
|
|
});
|
|
|
|
// Régression : page/pageSize étaient calculés mais jamais renvoyés, le client
|
|
// ne pouvait donc pas savoir s'il restait des logs à charger.
|
|
it("renvoie les informations de pagination", async () => {
|
|
const app = buildApp(anyPool());
|
|
const body = (await app.inject({ method: "GET", url: "/admin/api/logs?page=3", headers: auth })).json();
|
|
expect(body.page).toBe(3);
|
|
expect(body.pageSize).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/llm — filtres", () => {
|
|
it("filtre par modèle, nullité et texte", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({
|
|
method: "GET",
|
|
url: "/admin/api/llm?model=llama-3.1-8b-instant&only_null=1&q=oslo",
|
|
headers: auth,
|
|
});
|
|
const call = pool.find("FROM llm_cache lc");
|
|
expect(call.sql).toContain("model = $1");
|
|
expect(call.sql).toContain("is_null = true");
|
|
expect(call.values).toContain("llama-3.1-8b-instant");
|
|
expect(call.values).toContain("%oslo%");
|
|
});
|
|
|
|
it("le count applique le même filtre que la page", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/llm?model=x", headers: auth });
|
|
const count = pool.find("count(*)::int AS total FROM llm_cache");
|
|
expect(count.sql).toContain("model = $1");
|
|
expect(count.values).toEqual(["x"]);
|
|
});
|
|
|
|
it("tronque une recherche exagérément longue", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: `/admin/api/llm?q=${"a".repeat(500)}`, headers: auth });
|
|
const bound = pool.find("FROM llm_cache lc").values.find((v) => String(v).startsWith("%aaa"));
|
|
expect(bound).toHaveLength(102); // 100 caractères + les deux %
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/activity — liste unifiée", () => {
|
|
it("réunit crawl_run et admin_audit_log", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/activity", headers: auth });
|
|
const call = pool.find("UNION ALL");
|
|
expect(call.sql).toContain("FROM crawl_run");
|
|
expect(call.sql).toContain("FROM admin_audit_log");
|
|
});
|
|
|
|
it("filtre par type et statut en paramètres liés", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/activity?type=run&status=running", headers: auth });
|
|
const data = pool.find(/SELECT \* FROM \([\s\S]*UNION ALL/);
|
|
expect(data.values).toEqual(["run", "running", 50, 0]);
|
|
});
|
|
|
|
it("trie par date décroissante", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/activity", headers: auth });
|
|
expect(pool.find(/SELECT \* FROM \(/).sql).toContain("ORDER BY ts DESC");
|
|
});
|
|
});
|
|
|
|
// ------------------------------------------------------------------
|
|
// Actions de maintenance du géocodage
|
|
// ------------------------------------------------------------------
|
|
describe("actions sur band_locations", () => {
|
|
const ACTIONS = [
|
|
["/admin/api/locations/reset-errors", "geocode_status = 'error'"],
|
|
["/admin/api/locations/reset-llm", "geocode_status IN ('llm_needed', 'manual')"],
|
|
["/admin/api/locations/reset-all", "is_country_only = FALSE"],
|
|
["/admin/api/locations/requeue-all", "geocode_status = ANY($1::text[])"],
|
|
];
|
|
|
|
it.each(ACTIONS.map(([url]) => url))("%s exige une session", async (url) => {
|
|
const handlers = ([]);
|
|
const app = buildApp(handlers);
|
|
expect((await app.inject({ method: "POST", url, payload: {} })).statusCode).toBe(401);
|
|
expect(pool.calls).toHaveLength(0);
|
|
});
|
|
|
|
it.each(ACTIONS)("%s cible les bonnes lignes", async (url, expectedClause) => {
|
|
const handlers = ([
|
|
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 12 } },
|
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const res = await app.inject({ method: "POST", url, headers: auth, payload: {} });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().count).toBe(12);
|
|
expect(pool.find("UPDATE band_locations").sql).toContain(expectedClause);
|
|
});
|
|
|
|
it.each(ACTIONS.map(([url]) => url))("%s remet les lignes en queue", async (url) => {
|
|
const handlers = ([
|
|
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
|
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "POST", url, headers: auth, payload: {} });
|
|
expect(pool.find("UPDATE band_locations").sql).toContain("geocode_status='queued'");
|
|
});
|
|
|
|
it.each(ACTIONS.map(([url]) => url))("%s trace l'action dans les logs avec l'auteur", async (url) => {
|
|
const handlers = ([
|
|
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
|
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "POST", url, headers: auth, payload: {} });
|
|
expect(pool.find("INSERT INTO crawl_log").values[1]).toContain("admin:nico");
|
|
});
|
|
|
|
// reset-all efface les coordonnées (le worker va tout re-géocoder) alors que
|
|
// reset-errors ne remet en file que ce qui avait échoué.
|
|
it("reset-all efface les coordonnées, reset-errors non", async () => {
|
|
const mk = async (url) => {
|
|
const handlers = ([
|
|
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
|
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "POST", url, headers: auth, payload: {} });
|
|
return pool.find("UPDATE band_locations").sql;
|
|
};
|
|
expect(await mk("/admin/api/locations/reset-all")).toContain("lat=NULL");
|
|
expect(await mk("/admin/api/locations/reset-errors")).not.toContain("lat=NULL");
|
|
});
|
|
|
|
it("requeue-all inclut 'done' seulement sur demande explicite", async () => {
|
|
const mk = async (payload) => {
|
|
const handlers = ([
|
|
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
|
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "POST", url: "/admin/api/locations/requeue-all", headers: auth, payload });
|
|
return pool.find("UPDATE band_locations").values[0];
|
|
};
|
|
expect(await mk({})).toEqual(["error", "llm_needed"]);
|
|
expect(await mk({ include_done: true })).toEqual(["error", "llm_needed", "manual", "done"]);
|
|
});
|
|
|
|
it("requeue-all épargne toujours les localisations pays-seul", async () => {
|
|
const handlers = ([
|
|
{ match: "UPDATE band_locations", result: { rows: [], rowCount: 1 } },
|
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
await app.inject({
|
|
method: "POST", url: "/admin/api/locations/requeue-all",
|
|
headers: auth, payload: { include_done: true },
|
|
});
|
|
expect(pool.find("UPDATE band_locations").sql).toContain("is_country_only = FALSE");
|
|
});
|
|
|
|
it("une action qui échoue renvoie 500 sans écrire de log de succès", async () => {
|
|
const handlers = ([{ match: "UPDATE band_locations", throws: new Error("deadlock") }]);
|
|
const app = buildApp(handlers);
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/api/locations/reset-errors", headers: auth, payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(500);
|
|
expect(pool.find("INSERT INTO crawl_log")).toBeUndefined();
|
|
});
|
|
});
|
|
|
|
describe("POST /admin/api/geocode-cache/purge-nominatim", () => {
|
|
it("ne supprime que les entrées de l'ancien pipeline", async () => {
|
|
const handlers = ([
|
|
{ match: "DELETE FROM geocode_cache", result: { rows: [], rowCount: 4 } },
|
|
{ match: "INSERT INTO crawl_log", result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/api/geocode-cache/purge-nominatim", headers: auth, payload: {},
|
|
});
|
|
expect(res.json().count).toBe(4);
|
|
// Le marqueur place_rank distingue Nominatim de Geoapify : sans ce WHERE,
|
|
// la purge viderait aussi le cache Geoapify (appels payants).
|
|
expect(pool.find("DELETE FROM geocode_cache").sql).toContain("place_rank");
|
|
});
|
|
|
|
it("exige une session", async () => {
|
|
const handlers = ([]);
|
|
const app = buildApp(handlers);
|
|
expect((await app.inject({
|
|
method: "POST", url: "/admin/api/geocode-cache/purge-nominatim", payload: {},
|
|
})).statusCode).toBe(401);
|
|
expect(pool.calls).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("POST /admin/api/crawl-runs/cleanup", () => {
|
|
const cleanupPool = () =>
|
|
([
|
|
{ match: "UPDATE crawl_run", result: rows({ id: 1, run_type: "enrich", started_at: "2026-01-01" }) },
|
|
{ match: "INSERT INTO admin_audit_log", result: rows() },
|
|
]);
|
|
|
|
it("utilise 30 minutes par défaut", async () => {
|
|
const handlers = cleanupPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: {} });
|
|
expect(pool.find("UPDATE crawl_run").values).toEqual([30]);
|
|
});
|
|
|
|
it("ne touche que les runs marqués running", async () => {
|
|
const handlers = cleanupPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: {} });
|
|
expect(pool.find("UPDATE crawl_run").sql).toContain("status = 'running'");
|
|
});
|
|
|
|
it("renvoie le nombre de runs nettoyés", async () => {
|
|
const app = buildApp(cleanupPool());
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/api/crawl-runs/cleanup", headers: auth, payload: { older_than_minutes: 60 },
|
|
});
|
|
expect(res.json().cleaned).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/geocoding", () => {
|
|
it("dégrade proprement quand les tables du nouveau pipeline manquent", async () => {
|
|
const handlers = ([
|
|
{ match: "FROM geocode_cache", result: rows({ n: 5 }) },
|
|
{ match: "FROM bands b", result: rows() },
|
|
{ match: "FROM band_locations", throws: new Error("relation does not exist") },
|
|
{ match: "FROM llm_cache", throws: new Error("relation does not exist") },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const res = await app.inject({ method: "GET", url: "/admin/api/geocoding", headers: auth });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().cache_size).toBe(5);
|
|
expect(res.json().locations).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("GET /admin/api/live", () => {
|
|
it("remonte runs actifs, jobs en attente et file de géocodage", async () => {
|
|
const handlers = ([
|
|
{ match: "WHERE status = 'running'", result: rows({ id: 1, run_type: "enrich" }) },
|
|
{ match: "WHERE status = 'pending'", result: rows({ id: 2, job_type: "enrich" }) },
|
|
{ match: "GROUP BY geocode_status", result: rows({ status: "queued", n: 42 }) },
|
|
{ match: "geocode_status = 'processing'", result: rows() },
|
|
{ match: "FROM crawl_checkpoint", result: rows({ key: "last_full_crawl_at" }) },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const body = (await app.inject({ method: "GET", url: "/admin/api/live", headers: auth })).json();
|
|
expect(body.active_runs).toHaveLength(1);
|
|
expect(body.pending_jobs).toHaveLength(1);
|
|
expect(body.geo_queue[0].n).toBe(42);
|
|
expect(body.checkpoints).toHaveLength(1);
|
|
});
|
|
|
|
it("expose la santé des services de fond", async () => {
|
|
const handlers = ([
|
|
{ match: "FROM service_health", result: rows(
|
|
{ service: "crawler", ok: true, checks: { database: true }, error: null, stale: false },
|
|
{ service: "geocoder", ok: false, checks: { geoapify_joignable: false }, error: "HTTP 503", stale: false }) },
|
|
{ match: /./, result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const body = (await app.inject({ method: "GET", url: "/admin/api/live", headers: auth })).json();
|
|
expect(body.services).toHaveLength(2);
|
|
expect(body.services[1].error).toBe("HTTP 503");
|
|
});
|
|
|
|
// Un service arrêté cesse simplement d'écrire : sans ce calcul, son dernier
|
|
// contrôle réussi le ferait passer pour sain indéfiniment.
|
|
it("marque comme silencieux un service qui n'écrit plus", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/live", headers: auth });
|
|
expect(pool.find("FROM service_health").sql).toContain("checked_at < now() - interval '2 hours'");
|
|
});
|
|
|
|
// La table est arrivée en migration 015 : une base plus ancienne ne doit pas
|
|
// faire tomber tout le tableau de bord.
|
|
it("dégrade proprement si service_health n'existe pas encore", async () => {
|
|
const handlers = ([
|
|
{ match: "FROM service_health", throws: new Error('relation "service_health" does not exist') },
|
|
{ match: /./, result: rows() },
|
|
]);
|
|
const app = buildApp(handlers);
|
|
const res = await app.inject({ method: "GET", url: "/admin/api/live", headers: auth });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().services).toEqual([]);
|
|
});
|
|
|
|
it("limite l'aperçu des localisations en cours de traitement", async () => {
|
|
const handlers = anyPool();
|
|
const app = buildApp(handlers);
|
|
await app.inject({ method: "GET", url: "/admin/api/live", headers: auth });
|
|
expect(pool.find("geocode_status = 'processing'").sql).toContain("LIMIT 3");
|
|
});
|
|
});
|