La liste des mutants survivants est la carte de ce qui n'est pas vérifié. Une
passe dessus a montré que TOUS les filtres de /admin/api/bands étaient non
testés (sept mutants survivants par drapeau booléen) — précisément ceux dont
dépendent les filtres rapides du dashboard. Ils pouvaient être inopérants sans
que rien ne le signale.
Bugs trouvés et corrigés
1. crawler_pending détruit silencieusement (backend)
Le PATCH reconstruisait crawler_pending avec jsonb_build_object() sur les
seuls champs édités. Il gardait donc le conflit qu'on venait de trancher ET
supprimait ceux des champs non touchés : éditer le nom d'un groupe effaçait
ses conflits de genre, de pays et de localisation, sans trace.
Corrigé en suivant la convention de /resolve-conflict : retrait des clés
arbitrées (`crawler_pending - ARRAY[...]`).
2. Filtre genre non borné (backend)
q, location_q et themes_q tronquaient à 100 caractères ; genre non. Un motif
ILIKE de taille arbitraire partait vers Postgres, qui ne peut pas l'indexer.
country et status sont bornés au passage, par cohérence.
3. Chips inutilisables après une alerte (frontend)
Le paramètre de statut de l'URL était relu à CHAQUE rendu. Arrivé depuis une
alerte du Pilotage, cliquer un autre chip n'avait aucun effet : le filtre
revenait aussitôt à celui de l'URL. Le paramètre est désormais consommé
(replaceState, qui ne déclenche pas hashchange).
4. Minuteur d'auto-refresh orphelin (frontend)
Le minuteur était armé APRÈS le chargement des données. Quitter la vue
pendant celui-ci le laissait s'armer après le clearTimer() du routeur : le
Pilotage continuait d'interroger quatre endpoints toutes les 15 s depuis un
autre onglet, indéfiniment. Résolu par un jeton de rendu.
Commentaire dangereux corrigé
La saisie manuelle de coordonnées écrit geocode_status='done'. Le commentaire
annonçait 'manual', ce qui aurait conduit à une « correction » aux conséquences
invisibles : /api/clusters ne retient que ('done','country_only') — le point
n'apparaîtrait pas sur la carte — et /locations/reset-llm remet en file
('llm_needed','manual') — le bouton effacerait la saisie. Invariant verrouillé
par un test.
Test instable supprimé
Les interceptions Playwright lisaient la réponse après une possible navigation
(« Response has been disposed ») : un échec sur trois exécutions, sans rapport
avec ce qui était vérifié. Un test instable finit par être ignoré, ce qui est
pire qu'un test absent. Helper patchJson() ; stable sur 4 exécutions.
Tests ajoutés : 529 JS (+78), 43 Python, 82 Playwright (+27)
- bandFilters.test.js : les 3 drapeaux × 2 polarités × présence/absence,
bornes de q, tri, pagination, alignement des paramètres liés
- conflicts.test.js : effet du PATCH sur crawler_pending, casts par type,
allowlist, cohérence avec resolve-conflict
- tools.spec.js : tri, pagination, arbitrage de conflit, annulations,
filtres LLM, cycle de vie des vues
Les trois tests de régression ont été vérifiés NON VACUOUS : chaque bug
réintroduit les fait échouer.
Mutation : 64,00 % -> 68,29 % (seuil 55), obtenu en écrivant des tests et non
en réduisant le périmètre muté.
Co-Authored-By: Claude <noreply@anthropic.com>
195 lines
8.1 KiB
JavaScript
195 lines
8.1 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;
|
|
beforeAll(async () => {
|
|
auth = { cookie: `${ADMIN_COOKIE_NAME}=${signAdminSession("nico")}` };
|
|
({ app, pool } = await createAdminApp());
|
|
});
|
|
afterAll(async () => { await app.close(); });
|
|
|
|
/** Un groupe dont le crawler propose d'autres valeurs pour trois champs. */
|
|
const WITH_CONFLICTS = {
|
|
ma_id: 1, name: "Mayhem", genre: "Black Metal", country: "NO",
|
|
crawler_pending: { name: "MAYHEM", genre: "True Black Metal", country: "SE" },
|
|
locked_fields: { name: true },
|
|
};
|
|
|
|
function patchHandlers() {
|
|
pool.reset([
|
|
{ match: "SELECT * FROM bands WHERE ma_id", result: rows(WITH_CONFLICTS) },
|
|
{ match: "UPDATE bands SET", result: rows(WITH_CONFLICTS) },
|
|
{ match: "INSERT INTO admin_audit_log", result: rows() },
|
|
]);
|
|
return app;
|
|
}
|
|
|
|
/**
|
|
* `crawler_pending` contient les conflits NON RÉSOLUS. La convention est posée
|
|
* par /resolve-conflict, qui retire la clé du champ arbitré
|
|
* (`crawler_pending - $2`). Le PATCH doit suivre la même règle.
|
|
*/
|
|
describe("PATCH — effet sur crawler_pending", () => {
|
|
it("éditer un champ retire SA clé des conflits", async () => {
|
|
const a = patchHandlers();
|
|
await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Corrigé" } });
|
|
|
|
const upd = pool.find("UPDATE bands SET");
|
|
expect(upd.sql).toContain("crawler_pending = COALESCE(crawler_pending, '{}'::jsonb) - $");
|
|
expect(upd.values.at(-1)).toEqual(["name"]);
|
|
});
|
|
|
|
// Régression : la reconstruction par jsonb_build_object() ne conservait que
|
|
// les champs édités. Éditer le nom effaçait donc en silence les conflits de
|
|
// genre et de pays, sans trace ni possibilité de les retrouver.
|
|
it("éditer un champ NE DÉTRUIT PAS les conflits des autres champs", async () => {
|
|
const a = patchHandlers();
|
|
await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "Corrigé" } });
|
|
|
|
const upd = pool.find("UPDATE bands SET");
|
|
// L'opérateur de suppression ne cible que les clés listées ; tout le reste
|
|
// de l'objet est conservé. Une reconstruction par jsonb_build_object, elle,
|
|
// repartait d'un objet vide.
|
|
expect(upd.sql).not.toContain("jsonb_build_object");
|
|
expect(upd.values.at(-1)).not.toContain("genre");
|
|
expect(upd.values.at(-1)).not.toContain("country");
|
|
});
|
|
|
|
it("éditer plusieurs champs retire toutes leurs clés d'un coup", async () => {
|
|
const a = patchHandlers();
|
|
await a.inject({
|
|
method: "PATCH", url: "/admin/api/bands/1", headers: auth,
|
|
payload: { name: "X", genre: "Y" },
|
|
});
|
|
expect(pool.find("UPDATE bands SET").values.at(-1)).toEqual(["name", "genre"]);
|
|
});
|
|
|
|
it("fonctionne quand crawler_pending est NULL", async () => {
|
|
const a = patchHandlers();
|
|
await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { name: "X" } });
|
|
// Sans COALESCE, `NULL - ARRAY[...]` vaut NULL : inoffensif ici, mais on
|
|
// garantit un objet vide plutôt qu'un NULL qui ferait diverger le filtre
|
|
// has_conflict (`crawler_pending IS NOT NULL AND <> '{}'`).
|
|
expect(pool.find("UPDATE bands SET").sql).toContain("COALESCE(crawler_pending, '{}'::jsonb)");
|
|
});
|
|
|
|
it("les champs édités sont verrouillés sans écraser les verrous existants", async () => {
|
|
const a = patchHandlers();
|
|
await a.inject({ method: "PATCH", url: "/admin/api/bands/1", headers: auth, payload: { genre: "Y" } });
|
|
const upd = pool.find("UPDATE bands SET");
|
|
// `||` fusionne : les verrous précédents (name) survivent.
|
|
expect(upd.sql).toContain("locked_fields = locked_fields ||");
|
|
expect(JSON.parse(upd.values.at(-2))).toEqual({ genre: true });
|
|
});
|
|
});
|
|
|
|
describe("resolve-conflict — cohérence avec le PATCH", () => {
|
|
const conflictHandlers = () => {
|
|
pool.reset([
|
|
{ match: "SELECT * FROM bands WHERE ma_id", result: rows(WITH_CONFLICTS) },
|
|
{ match: "UPDATE bands SET", result: rows(WITH_CONFLICTS) },
|
|
{ match: "INSERT INTO admin_audit_log", result: rows() },
|
|
]);
|
|
return app;
|
|
};
|
|
|
|
it("keep_mine retire la clé sans toucher aux autres ni au verrou", async () => {
|
|
const a = conflictHandlers();
|
|
await a.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field: "genre", action: "keep_mine" },
|
|
});
|
|
const upd = pool.find("UPDATE bands SET");
|
|
expect(upd.sql).toContain("crawler_pending = crawler_pending - $2");
|
|
expect(upd.values).toEqual([1, "genre"]);
|
|
// Garder sa propre valeur maintient le verrou : le crawler ne doit pas
|
|
// revenir l'écraser au prochain passage.
|
|
expect(upd.sql).not.toContain("locked_fields");
|
|
});
|
|
|
|
it("accept_crawler applique la valeur, déverrouille et retire la clé", async () => {
|
|
const a = conflictHandlers();
|
|
await a.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field: "genre", action: "accept_crawler" },
|
|
});
|
|
const upd = pool.find("UPDATE bands SET");
|
|
expect(upd.sql).toContain("genre = $2::text");
|
|
expect(upd.sql).toContain("locked_fields = locked_fields - $3");
|
|
expect(upd.sql).toContain("crawler_pending = crawler_pending - $3");
|
|
expect(upd.values[1]).toBe("True Black Metal");
|
|
});
|
|
|
|
it.each(["name", "country", "status", "genre", "themes", "location_text"])(
|
|
"accepte le champ texte %s avec le bon cast",
|
|
async (field) => {
|
|
const a = conflictHandlers();
|
|
const res = await a.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field, action: "accept_crawler" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(pool.find("UPDATE bands SET").sql).toContain(`${field} = $2::text`);
|
|
}
|
|
);
|
|
|
|
it.each([
|
|
["formed_year", "::int"],
|
|
["lat", "::double precision"],
|
|
["lon", "::double precision"],
|
|
])("%s utilise un cast numérique", async (field, cast) => {
|
|
const a = conflictHandlers();
|
|
await a.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field, action: "accept_crawler" },
|
|
});
|
|
expect(pool.find("UPDATE bands SET").sql).toContain(`${field} = $2${cast}`);
|
|
});
|
|
|
|
it.each([
|
|
"enriched", "locked_fields", "crawler_pending", "data", "ma_id",
|
|
"geom", "password_hash", "name = 'pwned'",
|
|
])("refuse le champ non modifiable %s", async (field) => {
|
|
const a = conflictHandlers();
|
|
const res = await a.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field, action: "accept_crawler" },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
expect(pool.find("UPDATE bands SET")).toBeUndefined();
|
|
});
|
|
|
|
it("accepter une valeur absente des pending écrit NULL sans planter", async () => {
|
|
const a = conflictHandlers();
|
|
// 'themes' n'est pas dans crawler_pending du groupe de test.
|
|
const res = await a.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field: "themes", action: "accept_crawler" },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
expect(pool.find("UPDATE bands SET").values[1]).toBeUndefined();
|
|
});
|
|
|
|
it("404 quand le groupe n'existe pas", async () => {
|
|
pool.reset([{ match: "SELECT * FROM bands WHERE ma_id", result: rows() }]);
|
|
const res = await app.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field: "name", action: "keep_mine" },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it("trace l'arbitrage avec l'action choisie", async () => {
|
|
const a = conflictHandlers();
|
|
await a.inject({
|
|
method: "POST", url: "/admin/api/bands/1/resolve-conflict",
|
|
headers: auth, payload: { field: "genre", action: "keep_mine" },
|
|
});
|
|
expect(pool.find("INSERT INTO admin_audit_log").values[1]).toBe("resolve_conflict:keep_mine");
|
|
});
|
|
});
|