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"); }); });