Verifica che gli upsert dell'app scrivano sulla riga giusta.

Ogni salvataggio di CrAPP è un upsert con un onConflict scritto a mano nei hook
di src/lib/. Se quella chiave non corrisponde al vincolo UNIQUE della tabella
non arriva nessun errore: il database sovrascrive la riga sbagliata, e il difetto
si vede settimane dopo in una media che non torna. Nessuna delle sedici
scritture era mai stata eseguita da un test.

test/integration/scritture.test.ts ripete le stesse chiamate dei hook contro il
database locale e conta cosa resta nella tabella. Le due regole opposte che
nessuno verificava: le pagelle tengono un voto per ogni votato — se il conflitto
fosse su (match, votante) ogni voto cancellerebbe il precedente — mentre l'MVP
ne tiene uno solo per votante e partita. Più badge social per categoria, cacche,
turni palloni, risposte presenze (con l'istante che alimenta la serie di
conferme) e lo stato jsonb dello scout, che viene sostituito e non fuso. In più
i due CHECK su cui l'app conta: niente autovoto, voto fra 1 e 10.

Le righe usano il prefisso test-scritture e spariscono in un finally. La lettura
delle credenziali locali passa da test/helpers/locale.ts, ora che la usano due
file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 11:41:26 +02:00
co-authored by Claude Opus 5
parent 0addafa76f
commit fe0c938358
4 changed files with 326 additions and 32 deletions
+1 -19
View File
@@ -13,27 +13,9 @@
* reclamato in `giocatori_squadra` e il telefono del profilo g1.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
/** Credenziali dello stack locale. `null` se non è in esecuzione. */
function statoLocale(): { url: string; anon: string; servizio: string } | null {
const esito = spawnSync("npx", ["supabase", "status", "-o", "env"], {
encoding: "utf8",
timeout: 60_000,
});
if (esito.status !== 0) return null;
const leggi = (nome: string) =>
new RegExp(`^${nome}="?([^"\\n]+)"?$`, "m").exec(esito.stdout)?.[1] ?? "";
const url = leggi("API_URL");
const anon = leggi("ANON_KEY");
const servizio = leggi("SERVICE_ROLE_KEY");
if (!url || !anon || !servizio) return null;
// Cintura di sicurezza: questo file scrive, e su un database non locale non deve girare.
if (!/^https?:\/\/(127\.0\.0\.1|localhost)/.test(url)) return null;
return { url, anon, servizio };
}
const locale = statoLocale();
if (!locale) {
+281
View File
@@ -0,0 +1,281 @@
/**
* Le scritture dell'app contro il database locale: `bun test/integration/scritture.test.ts`.
*
* Ogni salvataggio di CrAPP è un `upsert` con un `onConflict` scritto a mano nei hook di
* `src/lib/`. Se quella chiave non corrisponde al vincolo UNIQUE della tabella non arriva
* nessun errore: il database sovrascrive la riga sbagliata, e il bug si vede solo settimane
* dopo in una media che non torna. Qui si ripetono le stesse chiamate dei hook e si conta
* cosa resta nella tabella.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-scritture`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("scritture sul database", "stack locale non attivo (npx supabase start)");
riepilogo("scritture");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`scritture su ${URL_BASE}`);
const PREFISSO = "test-scritture";
const TABELLE = [
"pagelle_voti",
"mvp_voti",
"cacche_partita",
"badge_social_voti",
"turni_palloni",
"risposte_presenze",
"scout_live",
] as const;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
/**
* Lo stesso `upsert` che fa supabase-js: `onConflict` diventa `on_conflict` nella query
* e `resolution=merge-duplicates` nell'header.
*/
async function upsert(tabella: string, onConflict: string, riga: Record<string, unknown>) {
const res = await rest(`${tabella}?on_conflict=${onConflict}`, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su ${tabella}: ${res.status} ${await res.text()}`);
return (await res.json()) as Array<Record<string, unknown>>;
}
const leggi = async (tabella: string, filtro: string) =>
(await (await rest(`${tabella}?${filtro}`)).json()) as Array<Record<string, unknown>>;
try {
// --- pagelle: la chiave più facile da sbagliare -------------------------------
// Un votante dà un voto a ogni compagno nella stessa partita: se il conflitto
// fosse su (match, votante) ogni voto cancellerebbe il precedente.
await prova("le pagelle tengono un voto per ogni votato, non uno per votante", async () => {
const match = `${PREFISSO}-m1`;
const chiave = "match_id,votante_id,votato_id";
await upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
voto: 6,
});
await upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g3",
voto: 8,
});
const righe = await leggi("pagelle_voti", `match_id=eq.${match}&select=votato_id,voto`);
assert.equal(righe.length, 2, "due votati, due righe");
await upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
voto: 9,
});
const dopo = await leggi("pagelle_voti", `match_id=eq.${match}&votato_id=eq.g2&select=voto`);
assert.equal(dopo.length, 1, "cambiare voto non crea una riga nuova");
assert.equal(dopo[0]?.["voto"], 9, "il voto è quello aggiornato");
});
await prova("le pagelle rifiutano l'autovoto e i voti fuori scala", async () => {
const match = `${PREFISSO}-m2`;
const chiave = "match_id,votante_id,votato_id";
await assert.rejects(
() =>
upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g1",
voto: 10,
}),
"nessuno si vota da solo (pagelle_no_autovoto)",
);
await assert.rejects(
() =>
upsert("pagelle_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
voto: 11,
}),
"il voto sta fra 1 e 10 (pagelle_voto_range)",
);
});
// --- MVP: qui la regola è l'opposta -------------------------------------------
await prova("l'MVP tiene un solo voto per votante e partita", async () => {
const match = `${PREFISSO}-m3`;
const chiave = "match_id,votante_id";
await upsert("mvp_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
});
await upsert("mvp_voti", chiave, {
match_id: match,
votante_id: "g1",
votato_id: "g3",
votato_nome: "Tre",
});
const righe = await leggi("mvp_voti", `match_id=eq.${match}&select=votato_id`);
assert.equal(righe.length, 1, "cambiare idea sostituisce il voto, non lo aggiunge");
assert.equal(righe[0]?.["votato_id"], "g3", "vale l'ultimo votato");
});
// --- badge social: una preferenza per categoria --------------------------------
await prova("i badge social tengono un voto per categoria", async () => {
const match = `${PREFISSO}-m4`;
const chiave = "match_id,categoria,votante_id";
await upsert("badge_social_voti", chiave, {
match_id: match,
categoria: "sorriso",
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
});
await upsert("badge_social_voti", chiave, {
match_id: match,
categoria: "urlo",
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
});
const righe = await leggi("badge_social_voti", `match_id=eq.${match}&select=categoria`);
assert.equal(righe.length, 2, "categorie diverse, righe diverse");
await upsert("badge_social_voti", chiave, {
match_id: match,
categoria: "urlo",
votante_id: "g1",
votato_id: "g4",
votato_nome: "Quattro",
});
const urlo = await leggi(
"badge_social_voti",
`match_id=eq.${match}&categoria=eq.urlo&select=votato_id`,
);
assert.equal(urlo.length, 1, "nella stessa categoria si sostituisce");
assert.equal(urlo[0]?.["votato_id"], "g4");
});
// --- cacche: una dichiarazione per giocatore e partita --------------------------
await prova("le cacche tengono una quantità per giocatore e partita", async () => {
const evento = `${PREFISSO}-e1`;
const chiave = "evento_id,giocatore_id";
await upsert("cacche_partita", chiave, {
evento_id: evento,
giocatore_id: "g1",
quantita: 2,
});
await upsert("cacche_partita", chiave, {
evento_id: evento,
giocatore_id: "g1",
quantita: 3,
});
await upsert("cacche_partita", chiave, {
evento_id: evento,
giocatore_id: "g2",
quantita: 1,
});
const righe = await leggi(
"cacche_partita",
`evento_id=eq.${evento}&select=giocatore_id,quantita&order=giocatore_id`,
);
assert.equal(righe.length, 2, "un giocatore una riga");
assert.equal(righe[0]?.["quantita"], 3, "la seconda dichiarazione sostituisce la prima");
});
// --- turni palloni: un solo incaricato per evento ------------------------------
await prova("il turno palloni resta uno per evento", async () => {
const evento = `${PREFISSO}-e2`;
await upsert("turni_palloni", "evento_id", {
evento_id: evento,
giocatore_id: "g1",
aggiornato_da: "g1",
});
await upsert("turni_palloni", "evento_id", {
evento_id: evento,
giocatore_id: "g5",
aggiornato_da: "g9",
});
const righe = await leggi(
"turni_palloni",
`evento_id=eq.${evento}&select=giocatore_id,aggiornato_da`,
);
assert.equal(righe.length, 1, "riassegnare non aggiunge un secondo incaricato");
assert.equal(righe[0]?.["giocatore_id"], "g5", "vale l'ultima assegnazione");
assert.equal(righe[0]?.["aggiornato_da"], "g9", "e si sa chi l'ha fatta");
});
// --- presenze: la risposta si cambia fino all'ultimo ---------------------------
await prova("la risposta di presenza si aggiorna, non si duplica", async () => {
const evento = `${PREFISSO}-e3`;
const chiave = "evento_id,giocatore_id";
const prima = await upsert("risposte_presenze", chiave, {
evento_id: evento,
giocatore_id: "g1",
stato: "presente",
aggiornato_il: new Date("2026-01-01T18:00:00Z").toISOString(),
});
await upsert("risposte_presenze", chiave, {
evento_id: evento,
giocatore_id: "g1",
stato: "assente",
aggiornato_il: new Date("2026-01-01T19:00:00Z").toISOString(),
});
const righe = await leggi(
"risposte_presenze",
`evento_id=eq.${evento}&select=stato,aggiornato_il`,
);
assert.equal(righe.length, 1, "una risposta per giocatore");
assert.equal(righe[0]?.["stato"], "assente", "vale l'ultima risposta");
assert.notEqual(
righe[0]?.["aggiornato_il"],
prima[0]?.["aggiornato_il"],
"l'istante della risposta si muove: è quello che alimenta la serie di conferme",
);
});
// --- scout live: lo stato viene sostituito, non fuso ----------------------------
await prova("lo scout live sostituisce lo stato invece di fonderlo", async () => {
const evento = `${PREFISSO}-e4`;
await upsert("scout_live", "evento_id", {
evento_id: evento,
stato: { set: 1, punti: 10 },
});
await upsert("scout_live", "evento_id", { evento_id: evento, stato: { set: 2 } });
const righe = await leggi("scout_live", `evento_id=eq.${evento}&select=stato`);
assert.equal(righe.length, 1);
assert.deepEqual(
righe[0]?.["stato"],
{ set: 2 },
"il jsonb viene rimpiazzato: i campi del set precedente non restano appesi",
);
});
} finally {
for (const tabella of TABELLE) {
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
await rest(`${tabella}?${colonna}=like.${PREFISSO}*`, { method: "DELETE" });
}
riepilogo("scritture");
}
}