diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 252561e..6f3d626 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -36,8 +36,8 @@ reali (M9). ## Database -- Schema v1.0 e migration da M1 a M11 applicate al nuovo Supabase; `m12_niente_autovoto` - applicata in locale, **da applicare in produzione** (`npx supabase db push`) +- Schema v1.0 e migration da M1 a M12 applicate al nuovo Supabase (`m12_niente_autovoto` + in produzione dal 06/09/2026, verificata con `npx supabase migration list`) - `public.giocatori_squadra`: rosa iniziale di 17 giocatori (migration `m5_email_giocatori_squadra`) più quelli aggiunti da `/admin` a stagione in corso; da settembre 2026 tutti i giocatori attivi hanno l'email registrata (colonna `email`, DD-018), impostabile da `/admin` senza diff --git a/src/lib/presenze.ts b/src/lib/presenze.ts index 7c22e3e..ba0cd14 100644 --- a/src/lib/presenze.ts +++ b/src/lib/presenze.ts @@ -152,6 +152,38 @@ export function usePresenzeEvento(eventoId: string) { return { ...resto, risposte: presenze[eventoId] ?? {} }; } +/** + * La cache delle presenze dopo una risposta salvata, senza rileggere il database. + * + * Due dettagli non sono cosmetici e non vanno persi (vedi `docs/modules/serie-presenze.md`): + * + * - l'istante si scrive **solo se manca** (`??=`), come fa il database, dove `risposto_il` + * non viene inviato sull'upsert e un trigger lo congela: è la prima risposta, non l'ultima, + * e un ripensamento non deve far ripartire il cronometro della serie "Conferme 24h"; + * - cancellare la risposta (`stato: null`) elimina **anche** l'istante, così se il giocatore + * risponde di nuovo il cronometro riparte davvero — ha ritirato la risposta. + */ +export function conRisposta( + prec: LetturaPresenze | undefined, + input: { eventoId: string; giocatoreId: string; stato: Stato | null }, + adesso: string = new Date().toISOString(), +): LetturaPresenze { + const presenze: MappaPresenze = { ...(prec?.presenze ?? {}) }; + const tempi: MappaTempiRisposta = { ...(prec?.tempi ?? {}) }; + const stati = { ...(presenze[input.eventoId] ?? {}) }; + const istanti = { ...(tempi[input.eventoId] ?? {}) }; + if (input.stato === null) { + delete stati[input.giocatoreId]; + delete istanti[input.giocatoreId]; + } else { + stati[input.giocatoreId] = input.stato; + istanti[input.giocatoreId] ??= adesso; + } + presenze[input.eventoId] = stati; + tempi[input.eventoId] = istanti; + return { presenze, tempi }; +} + export function useSalvaPresenza() { const queryClient = useQueryClient(); return useMutation({ @@ -179,23 +211,7 @@ export function useSalvaPresenza() { }, // Scrittura unica + aggiornamento cache locale, nessuna rilettura. onSuccess: (input) => { - queryClient.setQueryData(PRESENZE_KEY, (prec) => { - const presenze: MappaPresenze = { ...(prec?.presenze ?? {}) }; - const tempi: MappaTempiRisposta = { ...(prec?.tempi ?? {}) }; - const stati = { ...(presenze[input.eventoId] ?? {}) }; - const istanti = { ...(tempi[input.eventoId] ?? {}) }; - if (input.stato === null) { - delete stati[input.giocatoreId]; - delete istanti[input.giocatoreId]; - } else { - stati[input.giocatoreId] = input.stato; - // Come a database: l'istante è quello della prima risposta, non dei ripensamenti. - istanti[input.giocatoreId] ??= new Date().toISOString(); - } - presenze[input.eventoId] = stati; - tempi[input.eventoId] = istanti; - return { presenze, tempi }; - }); + queryClient.setQueryData(PRESENZE_KEY, (prec) => conRisposta(prec, input)); }, }); } diff --git a/test/README.md b/test/README.md index e67691a..6c83db0 100644 --- a/test/README.md +++ b/test/README.md @@ -23,12 +23,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push ## 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, guardia admin delle route) | 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, incluse le policy di M11 e le deroghe dell'amministratore (`permessi`), accesso alle route di notifica (`permessi-route`) 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 | — | +| 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, guardia admin delle route) | 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, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`) e le letture lato server delle route push (`lettori-server`) 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 @@ -44,7 +44,8 @@ npx supabase stop # spegne tutto bun test/integration/permessi.test.ts # permessi per ruolo sulle tabelle bun test/integration/permessi-route.test.ts # chi può far partire le notifiche -bun test/integration/scritture.test.ts # semantica degli upsert +bun test/integration/scritture.test.ts # semantica degli upsert e vincoli +bun test/integration/lettori-server.test.ts # le letture server delle route push ``` `permessi-route` avvia il server di sviluppo **puntato al database locale** invece che al @@ -55,6 +56,12 @@ Il primo `start` scarica le immagini (qualche minuto), i successivi partono in una decina di secondi. Le mail finiscono in Mailpit (http://127.0.0.1:54324), non escono dalla macchina. +`lettori-server` fa la stessa cosa in un altro modo: mette le credenziali locali in +`process.env` prima della prima chiamata, perché `client.server.ts` costruisce +`supabaseAdmin` pigramente leggendo l'ambiente. Serve a eseguire per davvero le query di +`leggiEventi()`/`leggiGiocatoriSquadra()`: nessuna delle due controlla l'errore di PostgREST, +quindi una colonna rinominata darebbe zero righe e zero destinatari, in silenzio. + I test che scrivono **non leggono `.env`**: prendono URL e chiavi da `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` diff --git a/test/integration/lettori-server.test.ts b/test/integration/lettori-server.test.ts new file mode 100644 index 0000000..6cc2f2e --- /dev/null +++ b/test/integration/lettori-server.test.ts @@ -0,0 +1,110 @@ +/** + * I due lettori lato server contro il database locale: + * `bun test/integration/lettori-server.test.ts`. + * + * `leggiEventi()` e `leggiGiocatoriSquadra()` sono le uniche letture delle tre route che + * mandano push: se una colonna elencata nelle costanti `COLONNE`/`COLONNE_SQUADRA` sparisse + * o cambiasse nome, PostgREST risponderebbe con un errore, `data` sarebbe `null` e le route + * manderebbero le notifiche a zero destinatari — senza che niente si accorga di niente, + * perché nessuna delle due controlla l'errore. Qui si esegue la query vera e si verifica che + * le righe arrivino mappate. + * + * Gira solo sullo stack locale (`npx supabase start`): le credenziali arrivano da + * `supabase status` e vengono messe in `process.env` **prima** della prima chiamata, perché + * `client.server.ts` costruisce il client pigramente leggendo l'ambiente. Così il test non + * può parlare con il progetto cloud nemmeno per sbaglio. + */ +import assert from "node:assert/strict"; +import { statoLocale } from "../helpers/locale"; +import { prova, riepilogo, salta } from "../helpers/prova"; + +const locale = statoLocale(); + +if (!locale) { + salta("lettori server", "stack locale non attivo (npx supabase start)"); + riepilogo("lettori server"); +} else { + const { url: URL_BASE, servizio: SERVIZIO } = locale; + console.log(`lettori server su ${URL_BASE}`); + + // Prima di qualsiasi import dei moduli server: è da qui che nasce `supabaseAdmin`. + process.env["SUPABASE_URL"] = URL_BASE; + process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO; + + const { leggiEventi } = await import("@/lib/eventi.server"); + const { leggiGiocatoriSquadra } = await import("@/lib/giocatori-squadra.server"); + + const PREFISSO = "test-lettori"; + const EVENTO = `${PREFISSO}-e1`; + + 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 ?? {}), + }, + }); + + try { + const inserito = await rest("eventi_app", { + method: "POST", + body: JSON.stringify({ + id: EVENTO, + tipo: "partita", + titolo: "Lettori server", + luogo: "Palestra", + data: "2026-01-02", + ora: "21:00", + note: "", + convocati: ["g1", "g2"], + campionato: true, + casa: false, + }), + }); + if (!inserito.ok) throw new Error(`preparazione fallita: ${await inserito.text()}`); + + await prova("leggiEventi() mappa le colonne che le route si aspettano", async () => { + const eventi = await leggiEventi(); + const evento = eventi.find((e) => e.id === EVENTO); + assert.ok(evento, "l'evento appena inserito arriva fino al chiamante"); + assert.equal(evento.titolo, "Lettori server"); + assert.equal(evento.tipo, "partita"); + assert.equal(evento.ora, "21:00"); + assert.equal(evento.casa, false, "la trasferta resta una trasferta"); + assert.equal(evento.campionato, true); + assert.deepEqual(evento.convocati, ["g1", "g2"], "i convocati servono al sollecito"); + assert.equal(evento.note, "", "le note vuote restano stringa vuota"); + assert.equal(evento.pagelleChiuse, false); + }); + + await prova("leggiGiocatoriSquadra() torna la rosa con i campi usati dalle route", async () => { + const squadra = await leggiGiocatoriSquadra(); + assert.ok(squadra.length > 0, "la rosa del seed non è vuota"); + const g1 = squadra.find((g) => g.id === "g1"); + assert.ok(g1, "g1 esiste nel seed locale"); + assert.equal(typeof g1.nome, "string"); + assert.equal(typeof g1.cognome, "string"); + assert.equal(typeof g1.numero, "number"); + assert.equal(typeof g1.attivo, "boolean", "`attivo` filtra i destinatari delle push"); + // Le colonne di M8: fanno parte di COLONNE_SQUADRA, quindi se mancassero la query + // fallirebbe per tutti, non solo per la dashboard tesseramenti. + assert.ok("numeroTessera" in g1 && "dataTessera" in g1, "le colonne di M8 sono lette"); + }); + + await prova("l'ordine è quello dichiarato: cognome, poi nome", async () => { + const squadra = await leggiGiocatoriSquadra(); + const chiavi = squadra.map((g) => `${g.cognome} ${g.nome}`); + assert.deepEqual( + chiavi, + [...chiavi].sort((a, b) => a.localeCompare(b)), + "gli elenchi mostrati alla squadra dipendono da questo ordinamento", + ); + }); + } finally { + await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" }); + riepilogo("lettori server"); + } +} diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index 50ccb22..1eacb07 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -394,8 +394,12 @@ if (!locale) { assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro"); }); - // I turni palloni restano aperti di proposito: nell'interfaccia il turno se lo passa - // chiunque, senza gate. Se un giorno arriva il gate, questo test va cambiato. + // Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché + // nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo + // Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine. + // Questi casi non dicono che sono sicure: dicono che sono aperte per scelta. Se un + // giorno una di loro prende un gate nell'interfaccia, le policy devono seguirlo e + // questi test vanno cambiati insieme. await prova("il turno palloni resta assegnabile da chiunque sia autenticato", async () => { const res = await rest("turni_palloni", tokenGiocatore, { method: "POST", @@ -404,6 +408,79 @@ if (!locale) { }); assert.equal(await righeToccate(res), 1, "DD-023 lascia questa tabella invariata"); }); + + await prova("lo Scout Live resta aperto a chiunque sia autenticato", async () => { + const sessione = await rest("scout_sessioni", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + evento_id: EVENTO, + giocatore_id: "g5", + giocatore_nome: "Cinque", + aggiornato_il: new Date().toISOString(), + }), + }); + assert.equal(await righeToccate(sessione), 1, "il lock lo prende chiunque"); + + const stato = await rest("scout_live", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, stato: { set: 1 } }), + }); + assert.equal(await righeToccate(stato), 1, "e lo stato in corso lo scrive chiunque"); + + const archivio = await rest("scout_partite", tokenGiocatore, { + method: "POST", + headers: { Prefer: "return=representation" }, + body: JSON.stringify({ + id: `${PREFISSO}-match`, + evento_id: EVENTO, + data: "2026-01-01", + avversario: "Prova", + casa: true, + set_nostri: 3, + set_loro: 0, + parziali: [], + azioni: [], + }), + }); + assert.equal(await righeToccate(archivio), 1, "come l'archivio di fine partita"); + + const cancella = await rest(`scout_partite?id=eq.${PREFISSO}-match`, tokenGiocatore, { + method: "DELETE", + headers: { Prefer: "return=representation" }, + }); + assert.equal(await righeToccate(cancella), 1, "e chiunque può anche cancellarlo"); + }); + + // Le iscrizioni push non passano dalla RLS per identificare il dispositivo: la chiave è + // l'endpoint, che il browser conosce solo per sé. Restano scrivibili da chiunque sia + // autenticato, ed è il motivo per cui `push_subscriptions` non contiene dati personali + // oltre all'endpoint e alle sue chiavi. + await prova("l'iscrizione alle notifiche la registra qualsiasi autenticato", async () => { + const endpoint = `https://esempio.test/${PREFISSO}-push`; + const res = await rest("push_subscriptions", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + giocatore_id: "g1", + endpoint, + p256dh: "chiave-di-prova", + auth: "auth-di-prova", + }), + }); + assert.equal(await righeToccate(res), 1, "il dispositivo si registra da solo"); + + const via = await rest( + `push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`, + tokenGiocatore, + { + method: "DELETE", + headers: { Prefer: "return=representation" }, + }, + ); + assert.equal(await righeToccate(via), 1, "e si cancella quando le notifiche si spengono"); + }); } finally { // Ripristino: prima le righe create (la service role passa sopra alle policy di M11), // poi gli slot (serve il JWT admin, il trigger rifiuta la service key), il telefono e @@ -414,6 +491,11 @@ if (!locale) { for (const tabella of ["pagelle_voti", "mvp_voti", "badge_social_voti"]) { await rest(`${tabella}?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); } + for (const tabella of ["scout_sessioni", "scout_live"]) { + await rest(`${tabella}?evento_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); + } + await rest(`scout_partite?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); + await rest(`push_subscriptions?endpoint=like.*${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); for (const id of ["g1", "g2"]) { diff --git a/test/unit/presenze.test.ts b/test/unit/presenze.test.ts index 3f17971..925bcde 100644 --- a/test/unit/presenze.test.ts +++ b/test/unit/presenze.test.ts @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import type { Evento } from "@/lib/eventi"; import { + conRisposta, contaPresenzeGiocatore, destinatariSollecito, serieConferme, @@ -153,4 +154,60 @@ assert.equal( ); assert.equal(serieConferme("g2", conConvocazione, tempi, OGGI), 0, "chi non risponde è a zero"); +// --- cache locale dopo una risposta: il cronometro non riparte ---------------- +// Stessa regola del database: `risposto_il` è la PRIMA risposta e un trigger la congela. +// Qui la cache deve imitarla, altrimenti la serie "Conferme 24h" mente fino al refresh. +const PRIMA = "2026-08-01T10:00:00Z"; +const POI = "2026-08-08T10:00:00Z"; + +const dopoPrimaRisposta = conRisposta( + undefined, + { eventoId: "e1", giocatoreId: "g1", stato: "presente" }, + PRIMA, +); +assert.deepEqual(dopoPrimaRisposta, { + presenze: { e1: { g1: "presente" } }, + tempi: { e1: { g1: PRIMA } }, +}); + +const dopoRipensamento = conRisposta( + dopoPrimaRisposta, + { eventoId: "e1", giocatoreId: "g1", stato: "assente" }, + POI, +); +assert.equal(dopoRipensamento.presenze["e1"]?.["g1"], "assente", "vale l'ultima risposta"); +assert.equal( + dopoRipensamento.tempi["e1"]?.["g1"], + PRIMA, + "l'istante resta quello della prima risposta: il ripensamento non fa ripartire il cronometro", +); + +const dopoRitiro = conRisposta( + dopoRipensamento, + { eventoId: "e1", giocatoreId: "g1", stato: null }, + POI, +); +assert.deepEqual(dopoRitiro.presenze["e1"], {}, "ritirare la risposta la toglie"); +assert.deepEqual( + dopoRitiro.tempi["e1"], + {}, + "e toglie anche l'istante: chi risponde di nuovo riparte da capo", +); +assert.equal( + conRisposta(dopoRitiro, { eventoId: "e1", giocatoreId: "g1", stato: "presente" }, POI).tempi[ + "e1" + ]?.["g1"], + POI, + "dopo un ritiro il cronometro riparte davvero", +); + +// Gli altri giocatori e gli altri eventi non vengono toccati. +const conAltri2 = conRisposta( + conRisposta(undefined, { eventoId: "e1", giocatoreId: "g1", stato: "presente" }, PRIMA), + { eventoId: "e2", giocatoreId: "g2", stato: "forse" }, + POI, +); +assert.equal(conAltri2.presenze["e1"]?.["g1"], "presente", "l'altro evento resta in cache"); +assert.equal(conAltri2.tempi["e2"]?.["g2"], POI); + console.log("presenze: ok");