From 752b3004740339a046334949b86d8ce6b508625a Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Sun, 6 Sep 2026 19:03:18 +0200 Subject: [PATCH] Copre presenze, calendario e guardia admin con nuovi test. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confrontando le funzioni esportate di src/lib/ con i riferimenti in test/, le uniche funzioni pure ancora scoperte erano i conteggi presenze, tre funzioni del calendario, i titoli MVP e i rifiuti della guardia notifiche. - unit: contaPresenzeGiocatore e totaliEventiGiocatore, compleanniEventi, convocatiEvento, eventoVuoto, mvpVintiPerGiocatore, e il nuovo auth-route.test.ts sui 401 di richiediAdmin (DD-024); - integration/permessi: badge_social_voti, che mancava tra le tabelle di M11, e le deroghe "Gli admin gestiscono tutti/e ...", mai verificate finora. Due modifiche al codice servivano per poter scrivere i test: - presenze.ts: i conteggi leggevano la data da dataOggi() e non erano verificabili; ora accettano oggi come parametro opzionale, come già facevano serieConsecutiva e serieConferme. Toglie anche la dipendenza dall'orologio che avrebbe cambiato i risultati dei test delle serie dal 10 settembre in poi; - auth-route.server.ts: "Bearer .." passava il pre-check con tre segmenti vuoti e arrivava fino alla chiamata di rete; ora i segmenti devono essere non vuoti. Co-Authored-By: Claude Opus 5 --- docs/CHANGELOG.md | 12 ++++++ src/lib/auth-route.server.ts | 5 ++- src/lib/presenze.ts | 18 +++++---- test/README.md | 12 +++--- test/integration/permessi.test.ts | 56 +++++++++++++++++++++++++++- test/unit/auth-route.test.ts | 30 +++++++++++++++ test/unit/eventi.test.ts | 61 ++++++++++++++++++++++++++++++- test/unit/mvp-voti.test.ts | 26 ++++++++++++- test/unit/presenze.test.ts | 22 +++++++++++ 9 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 test/unit/auth-route.test.ts diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 26560a6..fc5e747 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -6,6 +6,18 @@ qui: sta in [ROADMAP.md](ROADMAP.md). ## Versione attuale — agosto 2026 +### La suite copre i conteggi presenze, il calendario e la guardia delle notifiche + +- Nuovi check unitari su `contaPresenzeGiocatore`/`totaliEventiGiocatore` (numeratore e + denominatore delle statistiche), su `compleanniEventi`, `convocatiEvento` ed `eventoVuoto`, + su `mvpVintiPerGiocatore` e sui rifiuti `401` di `richiediAdmin` (DD-024). +- I test dei permessi coprono ora anche `badge_social_voti` e le deroghe + «Gli admin gestiscono tutti/e …» introdotte da M11. +- Perché servivano modifiche al codice: le funzioni di conteggio presenze leggevano la data + di oggi dall'orologio e non erano verificabili — ora accettano `oggi` come parametro, + come già facevano le serie; `richiediAdmin` scarta anche i token con segmenti vuoti + (`Bearer ..`), che prima arrivavano fino alla chiamata di rete. + ### Le notifiche push arrivano anche ad app chiusa - Il testo della notifica viaggia ora cifrato **dentro** la push (`aes128gcm`, RFC 8291) diff --git a/src/lib/auth-route.server.ts b/src/lib/auth-route.server.ts index bb9f48a..94cbac2 100644 --- a/src/lib/auth-route.server.ts +++ b/src/lib/auth-route.server.ts @@ -18,8 +18,9 @@ function tokenDaRichiesta(request: Request): string | null { const intestazione = request.headers.get("authorization"); if (!intestazione?.startsWith("Bearer ")) return null; const token = intestazione.slice("Bearer ".length).trim(); - // Un JWT ha tre segmenti: scartarlo qui evita una chiamata di rete per ogni rumore. - return token && token.split(".").length === 3 ? token : null; + // Un JWT ha tre segmenti non vuoti: scartarlo qui evita una chiamata di rete per ogni rumore. + const segmenti = token.split("."); + return segmenti.length === 3 && segmenti.every(Boolean) ? token : null; } /** diff --git a/src/lib/presenze.ts b/src/lib/presenze.ts index d1180ca..c44e973 100644 --- a/src/lib/presenze.ts +++ b/src/lib/presenze.ts @@ -14,8 +14,7 @@ export type MappaPresenze = Record>; export type MappaTempiRisposta = Record>; /** Allenamenti e partite CrAPP già passati, che contano per le statistiche di presenza. */ -function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string) { - const oggi = dataOggi(); +function eventiContanoPresenze(eventi: Evento[], giocatoreId?: string, oggi = dataOggi()) { return eventi.filter( (e) => (e.tipo === "partita" || e.tipo === "allenamento") && @@ -29,16 +28,21 @@ export function contaPresenzeGiocatore( giocatoreId: string, eventi: Evento[], presenze: MappaPresenze, + oggi: string = dataOggi(), ): number { - return eventiContanoPresenze(eventi, giocatoreId).filter((e) => { + return eventiContanoPresenze(eventi, giocatoreId, oggi).filter((e) => { const stato = presenze[e.id]?.[giocatoreId]; return stato === "presente" || stato === "ritardo"; }).length; } /** Eventi CrAPP rilevanti per il denominatore presenze di un giocatore. */ -export function totaliEventiGiocatore(giocatoreId: string, eventi: Evento[]): number { - return eventiContanoPresenze(eventi, giocatoreId).length; +export function totaliEventiGiocatore( + giocatoreId: string, + eventi: Evento[], + oggi: string = dataOggi(), +): number { + return eventiContanoPresenze(eventi, giocatoreId, oggi).length; } /** @@ -96,8 +100,8 @@ function serieSu( tipo: "partita" | "allenamento" | undefined, onorato: (e: Evento) => boolean, ): number { - return eventiContanoPresenze(eventi, giocatoreId) - .filter((e) => (tipo === undefined || e.tipo === tipo) && e.data <= oggi) + return eventiContanoPresenze(eventi, giocatoreId, oggi) + .filter((e) => tipo === undefined || e.tipo === tipo) .sort((a, b) => a.data.localeCompare(b.data)) .reduce((serie, e) => aggiornaSerie(serie, onorato(e)), 0); } diff --git a/test/README.md b/test/README.md index 09b8e90..e67691a 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) | 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`), 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 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 | — | ## Database locale in Docker diff --git a/test/integration/permessi.test.ts b/test/integration/permessi.test.ts index 8ff4d55..50ccb22 100644 --- a/test/integration/permessi.test.ts +++ b/test/integration/permessi.test.ts @@ -340,6 +340,60 @@ if (!locale) { assert.ok(!altrui.ok, `quelle di un altro no (${altrui.status})`); }); + await prova("anche i badge social si firmano con il proprio nome", async () => { + const mio = await rest("badge_social_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO, + categoria: "sorriso", + votante_id: "g1", + votato_id: "g5", + votato_nome: "Cinque", + }), + }); + assert.equal(await righeToccate(mio), 1, "il proprio voto social si registra"); + + const falso = await rest("badge_social_voti", tokenGiocatore, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ + match_id: EVENTO, + categoria: "sorriso", + votante_id: "g5", + votato_id: "g1", + votato_nome: "Uno", + }), + }); + assert.ok(!falso.ok, `non si vota a nome di un altro (${falso.status})`); + }); + + // L'altra metà di M11: le policy `Gli admin gestiscono tutti/e …`. Senza queste + // l'amministratore non potrebbe correggere una risposta sbagliata né ripulire i voti + // di una partita, e la rotta /eventi sarebbe monca. + await prova("l'amministratore corregge i dati degli altri", async () => { + const presenza = await rest("risposte_presenze", tokenAdmin, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", stato: "assente" }), + }); + assert.equal(await righeToccate(presenza), 1, "l'admin risponde anche per un altro"); + + const cacca = await rest("cacche_partita", tokenAdmin, { + method: "POST", + headers: { Prefer: "resolution=merge-duplicates,return=representation" }, + body: JSON.stringify({ evento_id: EVENTO, giocatore_id: "g5", quantita: 1 }), + }); + assert.equal(await righeToccate(cacca), 1, "vale anche per le cacche"); + + const pagella = await rest( + `pagelle_voti?match_id=eq.${EVENTO}&votante_id=eq.g1`, + tokenAdmin, + { method: "DELETE", headers: { Prefer: "return=representation" } }, + ); + 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. await prova("il turno palloni resta assegnabile da chiunque sia autenticato", async () => { @@ -357,7 +411,7 @@ if (!locale) { for (const tabella of ["risposte_presenze", "cacche_partita", "turni_palloni"]) { await rest(`${tabella}?evento_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); } - for (const tabella of ["pagelle_voti", "mvp_voti"]) { + for (const tabella of ["pagelle_voti", "mvp_voti", "badge_social_voti"]) { await rest(`${tabella}?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); } await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" }); diff --git a/test/unit/auth-route.test.ts b/test/unit/auth-route.test.ts new file mode 100644 index 0000000..fde5511 --- /dev/null +++ b/test/unit/auth-route.test.ts @@ -0,0 +1,30 @@ +/** + * Check della guardia delle route che mandano notifiche: `bun test/unit/auth-route.test.ts`. + * + * `richiediAdmin` (DD-024) è l'unica barriera davanti a route che usano la service role e + * saltano la RLS: qui si verificano i rifiuti che non richiedono rete, cioè tutti quelli + * decisi prima di chiedere l'utente a Supabase. Il caso «token buono ma non admin» sta in + * `test/integration/permessi-route.test.ts`, che ha un database vero. + */ +import assert from "node:assert/strict"; +import { richiediAdmin } from "@/lib/auth-route.server"; + +const con = (intestazioni: Record) => + richiediAdmin(new Request("http://localhost/api/public/qualcosa", { headers: intestazioni })); + +const rifiuti: Array<[string, Record]> = [ + ["nessuna intestazione", {}], + ["schema sbagliato", { authorization: "Basic abc" }], + ["Bearer senza token", { authorization: "Bearer " }], + ["token non JWT", { authorization: "Bearer non-un-jwt" }], + ["JWT a due segmenti", { authorization: "Bearer aaa.bbb" }], + ["JWT con segmenti vuoti", { authorization: "Bearer .." }], +]; + +for (const [caso, intestazioni] of rifiuti) { + const res = await con(intestazioni); + assert.ok(res, `${caso}: la richiesta va fermata`); + assert.equal(res.status, 401, `${caso}: risponde 401`); +} + +console.log("auth-route: ok"); diff --git a/test/unit/eventi.test.ts b/test/unit/eventi.test.ts index 808ccdc..46b12b2 100644 --- a/test/unit/eventi.test.ts +++ b/test/unit/eventi.test.ts @@ -1,6 +1,15 @@ /** Check della conversione eventi: `bun test/unit/eventi.test.ts`. */ import assert from "node:assert/strict"; -import { categoriaEvento, daCategoria, daRiga, type RigaEvento } from "@/lib/eventi"; +import { giocatori, type Giocatore } from "@/lib/crapp-data"; +import { + categoriaEvento, + compleanniEventi, + convocatiEvento, + daCategoria, + daRiga, + eventoVuoto, + type RigaEvento, +} from "@/lib/eventi"; const riga: RigaEvento = { id: "e1", @@ -63,4 +72,54 @@ for (const c of ["partita", "amichevole", "allenamento", "evento"] as const) { assert.equal(categoriaEvento(daCategoria(c)), c, `andata e ritorno stabile per ${c}`); } +// --- compleanniEventi: l'anagrafica diventa calendario ----------------------- +const rosa: Giocatore[] = [ + { ...giocatori[0]!, id: "g1", nome: "Bruno", nascita: "1990-12-31" }, + { ...giocatori[0]!, id: "g2", nome: "Anna", nascita: "2001-03-08" }, + { ...giocatori[0]!, id: "g3", nome: "Senza data", nascita: "" }, +]; + +const compleanni = compleanniEventi(rosa, 2026); +assert.deepEqual( + compleanni.map((e) => [e.id, e.data, e.luogo]), + [ + ["c-g2", "2026-03-08", "Compie 25 anni"], + ["c-g1", "2026-12-31", "Compie 36 anni"], + ], + "chi non ha data di nascita resta fuori, gli altri sono in ordine di data", +); +assert.equal(compleanni[0]!.titolo, "Compleanno di Anna"); +assert.equal(compleanni[0]!.tipo, "compleanno"); +assert.deepEqual(compleanni[0]!.convocati, [], "un compleanno non convoca nessuno"); +assert.deepEqual(compleanniEventi([], 2026), []); + +// --- convocatiEvento: elenco vuoto = tutta la rosa --------------------------- +const evento = daRiga({ ...riga, convocati: ["g2"] }); +assert.deepEqual( + convocatiEvento(evento, rosa).map((g) => g.id), + ["g2"], + "con i convocati indicati si filtra", +); +assert.deepEqual( + convocatiEvento(daRiga({ ...riga, convocati: null }), rosa), + rosa, + "vuoto = tutti", +); +assert.deepEqual(convocatiEvento(null, rosa), rosa, "senza evento restano tutti"); +assert.deepEqual( + convocatiEvento(daRiga({ ...riga, convocati: ["ignoto"] }), rosa), + [], + "un convocato che non è in rosa non inventa giocatori", +); + +// --- eventoVuoto: la bozza parte allenamento, oggi, senza convocati ---------- +const bozza = eventoVuoto(); +assert.equal(bozza.tipo, "allenamento"); +assert.equal(bozza.campionato, false); +assert.equal(bozza.pagelleChiuse, false); +assert.deepEqual(bozza.convocati, []); +assert.match(bozza.data, /^\d{4}-\d{2}-\d{2}$/, "la data è di oggi in formato ISO"); +assert.match(bozza.id, /^e[a-z0-9]+$/, "id generato dal client"); +assert.notEqual(bozza.id, "", "ogni bozza ha un id"); + console.log("eventi: ok"); diff --git a/test/unit/mvp-voti.test.ts b/test/unit/mvp-voti.test.ts index ffcc140..a5d175d 100644 --- a/test/unit/mvp-voti.test.ts +++ b/test/unit/mvp-voti.test.ts @@ -1,6 +1,12 @@ /** Check dei voti MVP: `bun test/unit/mvp-voti.test.ts`. */ import assert from "node:assert/strict"; -import { conteggioPartita, mioVoto, vincitoriMvp, type VotoMvp } from "@/lib/mvp-voti"; +import { + conteggioPartita, + mioVoto, + mvpVintiPerGiocatore, + vincitoriMvp, + type VotoMvp, +} from "@/lib/mvp-voti"; const v = ( match_id: string, @@ -50,4 +56,22 @@ assert.equal(mioVoto(partita, "m1", "g1")?.votato_nome, "Bruno"); assert.equal(mioVoto(partita, "m1", "g9"), null, "chi non ha votato non ha voto"); assert.equal(mioVoto(partita, "m9", "g1"), null); +// --- mvpVintiPerGiocatore: una vittoria per partita, la parità non conta ----- +assert.deepEqual( + mvpVintiPerGiocatore(partita), + { g2: 1, g5: 1 }, + "m1 la vince g2, m2 g5: un titolo a testa", +); +assert.deepEqual( + mvpVintiPerGiocatore([...partita, ...pari]), + { g2: 1, g5: 1 }, + "la parità non assegna", +); +assert.deepEqual( + mvpVintiPerGiocatore([...partita, v("m5", "g1", "g2", "Bruno")]), + { g2: 2, g5: 1 }, + "i titoli si sommano su partite diverse", +); +assert.deepEqual(mvpVintiPerGiocatore([]), {}); + console.log("mvp-voti: ok"); diff --git a/test/unit/presenze.test.ts b/test/unit/presenze.test.ts index 11fa60a..0fe2424 100644 --- a/test/unit/presenze.test.ts +++ b/test/unit/presenze.test.ts @@ -2,8 +2,10 @@ import assert from "node:assert/strict"; import type { Evento } from "@/lib/eventi"; import { + contaPresenzeGiocatore, serieConferme, serieConsecutiva, + totaliEventiGiocatore, type MappaPresenze, type MappaTempiRisposta, } from "@/lib/presenze"; @@ -58,6 +60,26 @@ assert.equal(serieConsecutiva("g2", eventi, presenze, "allenamento", OGGI), 0); const conConvocati = [...eventi, ev("a6", "allenamento", "2026-08-30", ["g9"])]; assert.equal(serieConsecutiva("g1", conConvocati, presenze, "allenamento", OGGI), 2); +// --- conteggio presenze: numeratore e denominatore delle statistiche ---------- +// a1 presente, a2 assente, a3 ritardo, a4 presente, p1 presente: 4 su 5 passati. +assert.equal(contaPresenzeGiocatore("g1", eventi, presenze, OGGI), 4, "il ritardo conta presente"); +assert.equal(totaliEventiGiocatore("g1", eventi, OGGI), 5, "gli eventi futuri non contano"); +assert.equal(contaPresenzeGiocatore("g2", eventi, presenze, OGGI), 0, "chi non risponde è a zero"); +assert.equal(totaliEventiGiocatore("g2", eventi, OGGI), 5, "il denominatore è uguale per tutti"); + +// Solo partite e allenamenti: compleanni e altri eventi restano fuori. +const conAltri: Evento[] = [ + ...eventi, + ev("x1", "evento", "2026-08-15"), + ev("c1", "compleanno", "2026-08-16"), +]; +assert.equal(totaliEventiGiocatore("g1", conAltri, OGGI), 5, "solo partite e allenamenti"); + +// Un evento con convocati espliciti conta solo per i convocati. +const conRistretti: Evento[] = [...eventi, ev("a7", "allenamento", "2026-08-29", ["g9"])]; +assert.equal(totaliEventiGiocatore("g9", conRistretti, OGGI), 6, "il convocato ce l'ha in più"); +assert.equal(totaliEventiGiocatore("g1", conRistretti, OGGI), 5, "chi non è convocato no"); + // --- serie conferme: risposta entro 24h dalla convocazione -------------------- const convocati = (id: string, data: string, creatoIl?: string): Evento => ({ ...ev(id, "allenamento", data),