diff --git a/test/README.md b/test/README.md index a53bee1..3b9fa5e 100644 --- a/test/README.md +++ b/test/README.md @@ -17,12 +17,12 @@ non può inquinare gli altri. ## Struttura -| 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 | -| `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ì | -| `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 | — | +| 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 | +| `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ì | +| `helpers/` | Avvio del server di test e mini-harness condiviso | — | ## 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 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 @@ -44,13 +45,15 @@ una decina di secondi. Le mail finiscono in Mailpit (http://127.0.0.1:54324), non escono dalla macchina. 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 -sicurezza, non una comodità: così un `.env` puntato alla produzione non può -trasformare un test in una scrittura sul database vero. +`supabase status` (helper `test/helpers/locale.ts`) e si fermano se l'URL non è +`127.0.0.1`. È una cintura di sicurezza, non una comodità: così un `.env` +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 -modificate) in un `finally`, così la suite si può rilanciare all'infinito senza -un `db reset` in mezzo. +Ognuno ripristina lo stato che tocca in un `finally` — utenti creati, slot della +rosa, colonne modificate, e per `scritture` tutte le righe con il prefisso +`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: diff --git a/test/helpers/locale.ts b/test/helpers/locale.ts new file mode 100644 index 0000000..7310745 --- /dev/null +++ b/test/helpers/locale.ts @@ -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 }; +} diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index 3207539..371156b 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -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) { diff --git a/test/integration/scritture.test.ts b/test/integration/scritture.test.ts new file mode 100644 index 0000000..5333634 --- /dev/null +++ b/test/integration/scritture.test.ts @@ -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) { + 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>; + } + + const leggi = async (tabella: string, filtro: string) => + (await (await rest(`${tabella}?${filtro}`)).json()) as Array>; + + 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"); + } +}