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
+16 -13
View File
@@ -17,12 +17,12 @@ non può inquinare gli altri.
## Struttura ## Struttura
| Cartella | Cosa verifica | Serve rete? | | Cartella | Cosa verifica | Serve rete? |
| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------- |
| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar) | No | | `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar) | No |
| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato, e i permessi per ruolo (`permessi`) sul database locale | Sì | | `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato; permessi per ruolo (`permessi`) e semantica degli upsert (`scritture`) sul database locale | Sì |
| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | | `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì |
| `helpers/` | Avvio del server di test e mini-harness condiviso | — | | `helpers/` | Avvio del server di test e mini-harness condiviso | — |
## Database locale in Docker ## Database locale in Docker
@@ -36,7 +36,8 @@ npx supabase status # URL e chiavi locali; Studio su http://127.0.0.1:54323
npx supabase db reset # ricrea il database da zero se i dati si sporcano npx supabase db reset # ricrea il database da zero se i dati si sporcano
npx supabase stop # spegne tutto npx supabase stop # spegne tutto
bun test/integration/permessi.test.ts # i test che richiedono lo stack locale bun test/integration/permessi.test.ts # permessi per ruolo
bun test/integration/scritture.test.ts # semantica degli upsert
``` ```
Il primo `start` scarica le immagini (qualche minuto), i successivi partono in Il primo `start` scarica le immagini (qualche minuto), i successivi partono in
@@ -44,13 +45,15 @@ una decina di secondi. Le mail finiscono in Mailpit (http://127.0.0.1:54324),
non escono dalla macchina. non escono dalla macchina.
I test che scrivono **non leggono `.env`**: prendono URL e chiavi da I test che scrivono **non leggono `.env`**: prendono URL e chiavi da
`supabase status` e si fermano se l'URL non è `127.0.0.1`. È una cintura di `supabase status` (helper `test/helpers/locale.ts`) e si fermano se l'URL non è
sicurezza, non una comodità: così un `.env` puntato alla produzione non può `127.0.0.1`. È una cintura di sicurezza, non una comodità: così un `.env`
trasformare un test in una scrittura sul database vero. puntato alla produzione non può trasformare un test in una scrittura sul
database vero.
Ognuno ripristina lo stato che tocca (utenti creati, slot della rosa, colonne Ognuno ripristina lo stato che tocca in un `finally`utenti creati, slot della
modificate) in un `finally`, così la suite si può rilanciare all'infinito senza rosa, colonne modificate, e per `scritture` tutte le righe con il prefisso
un `db reset` in mezzo. `test-scritture`, che nessun dato vero può avere. Così la suite si rilancia
all'infinito senza un `db reset` in mezzo.
Due cose scoperte scrivendo questi test, utili a chi ne aggiunge: Due cose scoperte scrivendo questi test, utili a chi ne aggiunge:
+28
View File
@@ -0,0 +1,28 @@
import { spawnSync } from "node:child_process";
/**
* Credenziali dello stack Supabase locale (`npx supabase start`), lette da
* `supabase status` e non da `.env`.
*
* I test che scrivono usano questo helper e non le variabili d'ambiente: un `.env`
* puntato alla produzione non deve poter trasformare un test in una scrittura sul
* database vero. Torna `null` — e il test si salta — se lo stack non è attivo o se
* per qualsiasi motivo l'URL non è locale.
*/
export type StackLocale = { url: string; anon: string; servizio: string };
export function statoLocale(): StackLocale | 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;
if (!/^https?:\/\/(127\.0\.0\.1|localhost)/.test(url)) return null;
return { url, anon, servizio };
}
+1 -19
View File
@@ -13,27 +13,9 @@
* reclamato in `giocatori_squadra` e il telefono del profilo g1. * reclamato in `giocatori_squadra` e il telefono del profilo g1.
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawnSync } from "node:child_process"; import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova"; 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(); const locale = statoLocale();
if (!locale) { 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");
}
}