Sistema i buchi del badge Pagellone: soglia minima voti e voto limitato ai convocati

Il badge Pagellone si sblocca ora solo con almeno 5 pagelle ricevute
(VOTI_MINIMI_PAGELLA): un voto solo poteva sbloccarlo o farlo sparire senza
significatività statistica. Aggiunto Giocatore.votiPagella per farlo funzionare.

La migration m13 (DD-027) estende le policy RLS di M11 su pagelle_voti, mvp_voti e
badge_social_voti: votante e votato devono essere convocati all'evento (prima solo
un filtro applicativo), e per le pagelle anche pagelle_chiuse=false. Le policy
admin restano permissive di proposito. Applicata anche al progetto cloud.

Copertura test completa: unit sulla soglia minima, integration sulle nuove policy
RLS (permessi.test.ts) e un end-to-end reale (pagella-badge.test.ts) sul modello
di mvp-badge.test.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 14:08:52 +02:00
co-authored by Claude Sonnet 5
parent 8c9cf370e8
commit 080379cc64
14 changed files with 548 additions and 34 deletions
+7
View File
@@ -38,6 +38,9 @@ Prima versione, pre-release.
della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock. della partita, e può usarlo chiunque sia autenticato: uno per volta, grazie al lock.
- Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore - Votazione MVP legata all'evento CrAPP e non al referto CSI o allo Scout: si apre due ore
dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md). dopo `data`+`ora` della partita, anche senza risultato caricato — [modules/mvp.md](modules/mvp.md).
- Badge Pagellone: la media pagelle conta per il badge solo con almeno `VOTI_MINIMI_PAGELLA`
(5) voti ricevuti — prima un singolo voto poteva sbloccarlo o farlo sparire senza nessuna
significatività statistica — [modules/badge.md](modules/badge.md).
- Sondaggio pre-partita aperto dalle 8:00 del giorno della partita fino al fischio d'inizio - Sondaggio pre-partita aperto dalle 8:00 del giorno della partita fino al fischio d'inizio
(poi resta chiuso, anche nei giorni successivi) e pulsante «Avvisa tutti del sondaggio» per (poi resta chiuso, anche nei giorni successivi) e pulsante «Avvisa tutti del sondaggio» per
gli amministratori (`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale. gli amministratori (`POST /api/public/apri-sondaggio`); nessun cron, l'invio è manuale.
@@ -79,6 +82,10 @@ Prima versione, pre-release.
`pagelle_voti`. `pagelle_voti`.
- Al voto MVP partecipano solo i presenti (o in ritardo) di quell'evento; il filtro è - Al voto MVP partecipano solo i presenti (o in ritardo) di quell'evento; il filtro è
applicativo, non RLS ([modules/mvp.md](modules/mvp.md)). applicativo, non RLS ([modules/mvp.md](modules/mvp.md)).
- Migration `m13_convocati_e_pagelle_chiuse` (DD-027): la policy di M11 su `pagelle_voti`,
`mvp_voti` e `badge_social_voti` verifica ora anche che votante e votato siano tra i
convocati dell'evento, e per le sole pagelle che `pagelle_chiuse` sia falso — prima erano
filtri solo applicativi, aggirabili scrivendo direttamente su PostgREST.
- La suite copre i rifiuti `401` di `richiediAdmin` (DD-024), i permessi di - La suite copre i rifiuti `401` di `richiediAdmin` (DD-024), i permessi di
`badge_social_voti` e le deroghe admin di M11, e verifica che un ripensamento non riscriva `badge_social_voti` e le deroghe admin di M11, e verifica che un ripensamento non riscriva
`risposto_il` (trigger di `m9`). `risposto_il` (trigger di `m9`).
+4 -4
View File
@@ -15,7 +15,7 @@ nessuna di esse da M4 (DD-011).
| ---------------------------------------------------------------- | ------------------------------------------------------------------- | | ---------------------------------------------------------------- | ------------------------------------------------------------------- |
| `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) | | `eventi_app` | solo admin (nell'app li gestisce la rotta `/eventi`, già riservata) |
| `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin | | `risposte_presenze`, `cacche_partita` | il giocatore sulla propria riga (`giocatore_id`), più gli admin |
| `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), più gli admin | | `pagelle_voti`, `mvp_voti`, `badge_social_voti` | il votante sui propri voti (`votante_id`), se votante e votato sono convocati all'evento (`m13`); solo per le pagelle anche `pagelle_chiuse = false`; gli admin senza questi vincoli |
| `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate | | `turni_palloni`, `scout_sessioni`, `scout_live`, `scout_partite` | qualsiasi autenticato: nell'interfaccia non hanno gate |
| `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) | | `profili_giocatore` | il giocatore sul proprio profilo, admin su tutti (DD-016, DD-017) |
| `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) | | `giocatori_squadra` | admin; il giocatore può solo reclamare uno slot libero (DD-016) |
@@ -63,9 +63,9 @@ La tabella è verificata da `test/integration/permessi.test.ts` contro il databa
| Tabella | Scopo | Note | | Tabella | Scopo | Note |
| ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ | | ------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------ |
| `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`). | | `mvp_voti` | Voti MVP assegnati a fine partita. | Un voto per votante e partita; auto-voto rifiutato (`mvp_no_autovoto`, migration `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
| `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0. | | `pagelle_voti` | Voti anonimi assegnati ai giocatori. | Usati per il voto medio. Voto 1-10 e auto-voto rifiutato dai vincoli della v1.0; votante/votato convocati e `pagelle_chiuse = false` richiesti dalla RLS di `m13_convocati_e_pagelle_chiuse`. |
| `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`). | | `badge_social_voti` | Voti social per i badge. | Un voto per categoria, votante e partita; auto-voto rifiutato (`badge_social_no_autovoto`, `m12_niente_autovoto`); votante e votato devono essere convocati all'evento (RLS, `m13_convocati_e_pagelle_chiuse`). |
## Turni e notifiche ## Turni e notifiche
+54
View File
@@ -42,6 +42,7 @@ Serve a rispondere a domande del tipo:
| [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate | | [DD-024](#dd-024--le-route-che-avvisano-la-squadra-chiedono-le-credenziali) | Route di notifica autenticate |
| [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale | | [DD-025](#dd-025--il-promemoria-palloni-lo-manda-ladmin-per-un-evento) | Promemoria palloni manuale |
| [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato | | [DD-026](#dd-026--il-testo-della-notifica-viaggia-dentro-la-push) | Payload push cifrato |
| [DD-027](#dd-027--chi-vota-deve-essere-convocato-non-solo-autenticato-come-sé-stesso) | Voto limitato ai convocati |
**In valutazione** **In valutazione**
@@ -970,3 +971,56 @@ prima di inviare.
**Riesame** **Riesame**
Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push Se servisse mandare payload più grandi del limite del protocollo, o se un servizio push
smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard). smettesse di accettare corpi cifrati (nessuno lo fa: è lo standard).
### DD-027 — Chi vota deve essere convocato, non solo autenticato come sé stesso
**Stato:** accettata · **Data:** 8 settembre 2026
**Contesto**
Un audit del modulo Badge (`docs/modules/badge.md`) ha trovato due filtri rimasti solo
applicativi dopo DD-023: la policy di M11 garantisce che `votante_id` sia lo slot collegato
all'account di chi scrive, ma non controlla che **votante e votato fossero convocati**
all'evento — un utente che scrive direttamente su PostgREST (bypassando l'interfaccia) poteva
votare o essere votato in una partita a cui non aveva partecipato, gonfiando `mediaVoto`,
`mvp` o un badge social a piacere. Allo stesso modo, `eventi_app.pagelle_chiuse` nascondeva
solo i bottoni in UI: un voto pagella "fuori tempo" restava tecnicamente possibile.
**Decisione**
La policy "Ognuno gestisce i propri voti ..." di `pagelle_voti`, `mvp_voti` e
`badge_social_voti` (M11) guadagna un controllo aggiuntivo tramite la funzione
`evento_permette_voto()` (migration `m13_convocati_e_pagelle_chiuse`): verifica che sia
`votante_id` sia `votato_id` compaiano in `eventi_app.convocati` per quel `match_id`
(`convocati` vuoto = tutta la rosa, la stessa convenzione di `convocatiEvento()` in
`eventi.ts`), e — solo per le pagelle — che `pagelle_chiuse` sia falso. Le policy admin
restano invariate e permissive: un amministratore deve poter correggere un voto anche fuori
convocazione o dopo la chiusura.
Il controllo si ferma alla **convocazione**, non alla **presenza reale**: per l'MVP, ad
esempio, l'interfaccia limita già il voto ai soli presenti/in ritardo
(`usePresenzeEvento`), un filtro più stretto che resta solo applicativo — un convocato ma
assente passa ancora a livello database. Stringere fino a quel punto avrebbe richiesto
leggere `risposte_presenze` dentro la policy, un salto di complessità non giustificato
dall'audit che ha originato questa decisione.
**Alternative scartate**
- Un trigger `BEFORE INSERT/UPDATE` invece di RLS → si applicherebbe anche alla service key
e agli admin, bloccando correzioni legittime fuori convocazione; la RLS, applicata solo
alla policy non-admin, li esclude naturalmente.
- Controllare anche la presenza reale (`risposte_presenze`), non solo la convocazione →
scope maggiore del gap trovato in audit, e specifico dell'MVP (pagelle e badge social non
hanno un concetto di "presente" distinto da "convocato" nell'interfaccia attuale).
**Conseguenze**
- Un evento senza `convocati` esplicito (lista vuota, il caso più comune oggi) non cambia
comportamento: tutta la rosa resta votabile, come prima.
- I test di `test/integration/permessi.test.ts` sono la definizione eseguibile anche di
questa parte della tabella dei permessi (voti non convocati rifiutati, `pagelle_chiuse`
rifiutata a database, controlli positivi che provano che un voto legittimo passa ancora).
- Resta un gap conosciuto e documentato (non quello risolto qui): che il votante fosse
davvero presente, non solo convocato, per MVP/pagelle/badge social.
**Riesame**
Se un giorno servisse bloccare anche il voto di un convocato-ma-assente a livello database,
non solo in UI.
+44 -13
View File
@@ -31,8 +31,16 @@ badge assegnati per voto dai compagni.
inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è inclusive, vince l'ultima raggiunta o superata. Per la maggior parte dei badge il valore è
già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni, già pronto: `Giocatore` arriva da `useRosa()` con presenze, palloni, serie, infortuni,
ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a ritardi, cacche e media pagelle già calcolati da altri moduli — `badges.ts` si limita a
confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, l'MVP e i confrontarli con le soglie. Fanno eccezione, con logica propria descritta sotto, il
badge social. Pagellone, l'MVP e i badge social.
- **Badge Pagellone** (`pagella`, in `badgeDefs`): a differenza degli altri badge da
contatore, richiede un numero minimo di voti (`VOTI_MINIMI_PAGELLA = 5`, `badges.ts`) prima
che `g.mediaVoto` conti — sotto soglia `valore(g)` è forzato a `0` (badge bloccato), anche
con una media altissima. Aggiunto perché senza minimo un singolo voto poteva
sbloccare/far sparire il badge senza nessuna significatività statistica (vedi
[pagelle.md](pagelle.md) per la pipeline voto → media, qui non ripetuta). Il numero di voti
ricevuti arriva in `Giocatore.votiPagella` (`rosa.ts`), popolato insieme a `mediaVoto` dalla
stessa `mediePagelle()`.
- **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per - **Badge social** (`badge-social.ts`, tabella `badge_social_voti`): 5 categorie fisse per
partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della partita ("Compagno affidabile", "Miglior spirito di squadra", "Fair play", "Meme della
partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita partita", "Cuore del gruppo"), votabili una volta a testa per categoria/partita
@@ -72,7 +80,7 @@ soglia raggiunta o superata (soglie inclusive), oltre l'oro resta oro.
| id | nome | come si guadagna | soglie B/A/O | | id | nome | come si guadagna | soglie B/A/O |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 | | `mvp` | MVP | partite vinte nettamente al voto MVP dei compagni (`g.mvp`, vedi pipeline sopra) | 1 / 3 / 5 |
| `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`) | 6.5 / 7.5 / 8.5 | | `pagella` | Pagellone | media dei voti pagella ricevuti dai compagni a fine partita (`g.mediaVoto`), solo se ne ha ricevuti almeno `VOTI_MINIMI_PAGELLA` (5) | 6.5 / 7.5 / 8.5 |
| `palloni` | Sherpa dei palloni | quante volte ti sei incaricato di portare la sacca palloni (`g.palloni`) | 3 / 6 / 10 | | `palloni` | Sherpa dei palloni | quante volte ti sei incaricato di portare la sacca palloni (`g.palloni`) | 3 / 6 / 10 |
| `presenze` | Presenza fissa | totale presenze a eventi/partite in stagione (`g.presenze`) | 5 / 15 / 30 | | `presenze` | Presenza fissa | totale presenze a eventi/partite in stagione (`g.presenze`) | 5 / 15 / 30 |
| `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti, senza saltarne uno (`g.serieAllenamenti`) | 3 / 6 / 10 | | `serie-allenamenti` | Sempre in palestra | allenamenti consecutivi presenti, senza saltarne uno (`g.serieAllenamenti`) | 3 / 6 / 10 |
@@ -126,18 +134,17 @@ categoria): nessun bug trovato nella logica di calcolo di nessuno dei 16 badge.
**Badge normali**`badges.ts` testa la propria funzione pura (soglia → grado, **Badge normali**`badges.ts` testa la propria funzione pura (soglia → grado,
`badges.test.ts`) sull'output di altri moduli: `badges.test.ts`) sull'output di altri moduli:
- `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro, - `mvp`: soglie inclusive verificate (1→bronzo, 3→argento, 99→resta oro,
`badges.test.ts:44-48`), progresso a metà (`:52-56`). **Unico badge normale con integration `badges.test.ts:44-48`), progresso a metà (`:52-56`).
dedicato** (vedi sotto) perché la sua fonte, a differenza degli altri 5, passa da un'altra
tabella di voto (`mvp_voti`) invece che da un contatore già calcolato altrove.
- `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 → - `pagella`: caso critico delle soglie decimali senza arrotondamento per eccesso — 6.4 →
nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi nessun grado, 6.5 → bronzo (`badges.test.ts:65-67`); un vero 6.49 non diventa "quasi
bronzo". bronzo". Più la soglia minima di voti (vedi sotto).
- `palloni`, `presenze`, `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già - `palloni`, `presenze`, `serie-allenamenti`, `serie-conferme`: stessa funzione di soglia già
testata a fondo su `mvp`/`pagella`, coperti dagli invarianti generali testata a fondo su `mvp`/`pagella`, coperti dagli invarianti generali
(`badges.test.ts:154-159`: soglie crescenti, testi presenti, id unici) e da (`badges.test.ts:154-159`: soglie crescenti, testi presenti, id unici) e da
`collezioneBadge`/`prossimoTraguardo` con valori al massimo (`:119-144`). `collezioneBadge`/`prossimoTraguardo` con valori al massimo (`:119-144`).
- Nessun integration dedicato per questi 5: non toccano il database, le statistiche sorgente - Nessun integration dedicato per questi 4: non toccano il database, le statistiche sorgente
(`presenze.test.ts`, `palloni-core.test.ts`, ecc.) sono già coperte nei rispettivi moduli. (`presenze.test.ts`, `palloni-core.test.ts`, ecc.) sono già coperte nei rispettivi moduli.
`mvp` e `pagella` fanno eccezione (sotto) perché la loro fonte passa da una tabella di voto.
**Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto **Badge segreti** — ognuno testato con la propria condizione esatta e il confine appena sotto
(`badges.test.ts:77-109`): `s-tiebreak` (mediaVoto 7.9 non basta, serve 8), `s-mai-forfait` (`badges.test.ts:77-109`): `s-tiebreak` (mediaVoto 7.9 non basta, serve 8), `s-mai-forfait`
@@ -154,12 +161,29 @@ copertura di questo badge):
partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database partita/votante, l'ultimo sostituisce) e rifiuto dell'autovoto a database
(`mvp_no_autovoto`). (`mvp_no_autovoto`).
- `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non - `permessi.test.ts` — RLS di `m11`: il proprio voto MVP si registra (caso positivo), non
si può votare a nome di un altro (caso negativo). si può votare a nome di un altro (caso negativo); RLS di `m13` (sotto): un votante o un
votato non convocati vengono rifiutati.
- `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come - `mvp-badge.test.ts` — end-to-end reale: scrive voti su `mvp_voti`, rilegge via REST come
fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni fa `useVotiMvp()`, calcola `mvpVintiPerGiocatore()` e verifica che `statoBadge()` assegni
il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non il grado corretto (bronzo a 1-2 vittorie nette, argento a 3), incluso un pareggio che non
deve contare come vittoria. deve contare come vittoria.
**Badge Pagellone — pipeline end-to-end e soglia minima di voti** (stessa sessione di sopra,
dopo l'analisi che ha trovato il gap "un voto solo sblocca il badge"):
- Unit: `badges.test.ts:69-88` — sotto `VOTI_MINIMI_PAGELLA` (5) il badge resta bloccato anche
con `mediaVoto: 10`; esattamente a 5 la media torna a contare; sopra soglia valgono le
normali soglie di grado (`mediaVoto: 6.5` con 5 voti → bronzo, non oro).
- Integration (`npx supabase start` richiesto):
- `scritture.test.ts` — semantica dell'`upsert` di `pagelle_voti` e rifiuto dell'autovoto
(`pagelle_no_autovoto`), già presente prima di questa sessione.
- `permessi.test.ts` — RLS di `m13`: un votante o un votato non convocati vengono rifiutati
(per tutte e tre le tabelle di voto, non solo le pagelle), e un voto pagella dopo
`pagelle_chiuse` viene rifiutato anche a database, non solo nascosto in UI.
- `pagella-badge.test.ts` (nuovo) — end-to-end reale: scrive voti su `pagelle_voti`, rilegge
via REST come fa `usePagelle()`, calcola `mediePagelle()` e verifica che `statoBadge()`
tenga il badge bloccato sotto soglia, lo sblocchi al voto minimo con il grado giusto, e
applichi le soglie normali sopra soglia.
**Badge social** — nessuna delle 5 categorie ha logica *propria* nel codice: l'id è solo una **Badge social** — nessuna delle 5 categorie ha logica *propria* nel codice: l'id è solo una
chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono chiave di raggruppamento, `conteggioCategoria`/`vincitoreCategoria`/`badgeSocialVinti` sono
identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero identici per tutte (`badge-social.ts:107-158`). Testare a fondo 2-3 categorie copre l'intero
@@ -176,7 +200,7 @@ meccanismo:
| # | id | tipo | test unit | test integration | | # | id | tipo | test unit | test integration |
| - | --- | --- | --- | --- | | - | --- | --- | --- | --- |
| 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) | | 1 | `mvp` | normale | ✅ | ✅ (`scritture`, `permessi`, `mvp-badge`) |
| 2 | `pagella` | normale | ✅ | non necessario | | 2 | `pagella` | normale | ✅ (incl. soglia minima voti) | ✅ (`scritture`, `permessi`, `pagella-badge`) |
| 3 | `palloni` | normale | ✅ | non necessario | | 3 | `palloni` | normale | ✅ | non necessario |
| 4 | `presenze` | normale | ✅ | non necessario | | 4 | `presenze` | normale | ✅ | non necessario |
| 5 | `serie-allenamenti` | normale | ✅ (limite noto sotto) | non necessario | | 5 | `serie-allenamenti` | normale | ✅ (limite noto sotto) | non necessario |
@@ -219,12 +243,19 @@ Trovati in audit, nessuno bloccante (nessun bug nella logica di calcolo):
risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione. risposte precedenti a `m9`: su quelle righe la serie è un'approssimazione.
- Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già - Nessuno storico dei badge sbloccati: se cambiano le soglie o i dati sorgente, un badge già
"ottenuto" può sparire o apparire retroattivamente. "ottenuto" può sparire o apparire retroattivamente.
- La policy di M11 garantisce che il voto sia firmato con il proprio `votante_id`, ma non
che il votato sia un giocatore convocato per quella partita: quello resta un filtro solo
applicativo (vale per MVP, pagelle e badge social).
- Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando - Notifiche "nuovo badge" solo locali al dispositivo (localStorage), si ripetono cambiando
browser o dispositivo. browser o dispositivo.
**Risolto (M13, `20260908120000_m13_convocati_e_pagelle_chiuse.sql`)**: prima la policy di M11
garantiva solo che il voto fosse firmato con il proprio `votante_id`, non che il votato (né il
votante) fossero convocati per quella partita — filtro solo applicativo, aggirabile scrivendo
direttamente su PostgREST. Ora `evento_permette_voto()` lo verifica anche a database per
`pagelle_voti`, `mvp_voti` e `badge_social_voti` (convocati vuoto = tutta la rosa, stessa
convenzione di `convocatiEvento()`), e per le sole pagelle verifica anche che
`eventi_app.pagelle_chiuse` sia falso — prima un voto "fuori tempo" restava tecnicamente
possibile bypassando l'interfaccia. Le policy admin restano permissive: un amministratore può
ancora correggere un voto anche fuori convocazione o dopo la chiusura.
--- ---
## Evoluzioni possibili ## Evoluzioni possibili
+9 -3
View File
@@ -48,9 +48,15 @@ restano nel database ma non vengono più letti da nessuna schermata).
- Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente. - Nessuna scadenza o chiusura della votazione: una volta aperta resta aperta indefinitamente.
- Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che - Il voto è legato a chi lo scrive: da `m11_scritture_per_ruolo` la policy impone che
`votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico `votante_id` sia lo slot collegato all'account (DD-023). Su chi viene votato l'unico
vincolo è che non sia il votante stesso (`mvp_no_autovoto`): che votante e votato fossero vincolo diretto è che non sia il votante stesso (`mvp_no_autovoto`).
presenti a quella partita, e che siano passate due ore dall'inizio, restano filtri solo Da `m13_convocati_e_pagelle_chiuse` la stessa policy verifica anche che **sia il votante sia
applicativi — chi scrive su PostgREST li aggira. il votato** siano tra i **convocati** dell'evento (`evento_permette_voto()`, convocati vuoto
= tutta la rosa): prima era un filtro solo applicativo, ora un giocatore non convocato non
può più votare né essere votato scrivendo direttamente su PostgREST. Restano invece solo
applicativi, non controllati da nessuna policy: che votante e votato fossero **presenti**
(non solo convocati: `presente`/`ritardo` in `usePresenzeEvento`, un controllo più stretto
della sola convocazione) a quella partita, e le due ore d'attesa dall'inizio evento
(`votoMvpAperto()`) — un amministratore, o chiunque scriva su PostgREST, passa comunque.
- In caso di parità, nessun MVP viene assegnato per quella partita. - In caso di parità, nessun MVP viene assegnato per quella partita.
--- ---
+17 -11
View File
@@ -29,9 +29,13 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più - `useVotaPagella()` fa un upsert su `(match_id, votante_id, votato_id)`: si può votare più
volte, l'ultimo voto sovrascrive il precedente. volte, l'ultimo voto sovrascrive il precedente.
- `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su - `mediePagelle()` calcola la media aritmetica (arrotondata a un decimale) per giocatore su
tutti i voti della stagione; `pagellePartita()` la calcola per singola partita; **tutti i voti mai ricevuti** — l'app non ha un concetto di stagione/reset, quindi non è
`mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in `squadra.tsx`. "la media di questa stagione" ma lo storico completo; `pagellePartita()` la calcola per
- `useRosa()` inietta la media stagionale nel campo `mediaVoto` di ogni giocatore. singola partita; `mediaSquadra()` su tutti i voti di tutti — mostrata come StatTile in
`squadra.tsx`.
- `useRosa()` inietta questa media storica nel campo `mediaVoto` di ogni giocatore, insieme al
numero di voti ricevuti (`votiPagella`) — usato dal badge Pagellone (vedi
[badge.md](badge.md)) per richiedere un minimo di voti prima che la media conti.
--- ---
@@ -39,25 +43,27 @@ UI), `UNIQUE (match_id, votante_id, votato_id)`.
- Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI). - Anti auto-voto imposto anche a livello database (constraint, non solo filtro UI).
- L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di - L'admin può marcare un evento come `pagelleChiuse` (`eventi.ts`), che nasconde i bottoni di
voto in UI. voto in UI **e**, da M13, rifiuta anche a database un voto scritto dopo la chiusura (RLS
`evento_permette_voto()`, `pagelle_voti`).
- Da M13 anche il votante e il votato devono essere convocati all'evento: verificato a
database, non solo in UI (stessa RLS di sopra).
--- ---
## Limiti noti ## Limiti noti
- **`pagelleChiuse` è solo un flag UI**: nessuna policy RLS lo controlla, quindi un voto
"fuori tempo" resta tecnicamente possibile bypassando l'interfaccia.
- **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id` - **L'anonimato è solo applicativo, non tecnico**: la riga salvata contiene sia `votante_id`
sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non sia `votato_id`, leggibili da chiunque sia autenticato (policy SELECT aperta). La UI non
mostra mai il votante, ma il dato non è né aggregato né mascherato lato server. mostra mai il votante, ma il dato non è né aggregato né mascherato lato server.
- Nessun controllo a livello database che il votante sia realmente un convocato della - La media mostrata nel profilo non richiede un numero minimo di voti: con un solo voto
partita: solo filtro applicativo. ricevuto, la media coincide con quel voto. Il badge Pagellone (`badge.md`) applica invece un
- La media non richiede un numero minimo di voti: con un solo voto ricevuto, la media minimo di voti prima di considerarla — la StatTile del profilo no.
coincide con quel voto. - Le due regole di M13 (convocazione, `pagelle_chiuse`) valgono solo per la policy "Ognuno
gestisce i propri voti pagella": un amministratore può ancora correggere un voto fuori
convocazione o dopo la chiusura, di proposito (deve poter sistemare un errore).
--- ---
## Evoluzioni possibili ## Evoluzioni possibili
- Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati. - Una RPC o vista che nasconda `votante_id` per un anonimato garantito anche lato dati.
- Far rispettare `pagelleChiuse` anche via RLS.
+7 -1
View File
@@ -58,6 +58,12 @@ export type BadgeDef = {
notificaPush?: string; notificaPush?: string;
}; };
/**
* Voti minimi perché la media pagelle conti per il badge Pagellone: sotto soglia una singola
* pagella potrebbe sbloccarlo (o farlo sparire) senza significatività statistica.
*/
export const VOTI_MINIMI_PAGELLA = 5;
export const badgeDefs: BadgeDef[] = [ export const badgeDefs: BadgeDef[] = [
{ {
id: "mvp", id: "mvp",
@@ -77,7 +83,7 @@ export const badgeDefs: BadgeDef[] = [
unita: "di media voto", unita: "di media voto",
icon: ClipboardCheck, icon: ClipboardCheck,
soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 }, soglie: { bronzo: 6.5, argento: 7.5, oro: 8.5 },
valore: (g) => g.mediaVoto, valore: (g) => (g.votiPagella >= VOTI_MINIMI_PAGELLA ? g.mediaVoto : 0),
}, },
{ {
id: "palloni", id: "palloni",
+3
View File
@@ -37,6 +37,8 @@ export type Giocatore = {
mvp: number; mvp: number;
/** Media delle pagelle ricevute dai compagni (1-10). */ /** Media delle pagelle ricevute dai compagni (1-10). */
mediaVoto: number; mediaVoto: number;
/** Quante pagelle ha ricevuto: sotto la soglia minima il badge Pagellone resta bloccato. */
votiPagella: number;
/** Quante volte ha portato i palloni. */ /** Quante volte ha portato i palloni. */
palloni: number; palloni: number;
/** Partite di campionato con almeno 3 cacche dichiarate. */ /** Partite di campionato con almeno 3 cacche dichiarate. */
@@ -105,6 +107,7 @@ export const giocatori: Giocatore[] = rosaCSI
serieConferme: 0, serieConferme: 0,
mvp: 0, mvp: 0,
mediaVoto: 0, mediaVoto: 0,
votiPagella: 0,
infortuni: 0, infortuni: 0,
ritardi: 0, ritardi: 0,
palloni: 0, palloni: 0,
+4 -1
View File
@@ -60,7 +60,10 @@ function arrotonda(n: number) {
return Math.round(n * 10) / 10; return Math.round(n * 10) / 10;
} }
/** Media stagionale di ciascun giocatore: giocatoreId -> media e numero di voti. */ /**
* Media storica di ciascun giocatore su tutti i voti mai ricevuti (l'app non ha un concetto
* di stagione/reset): giocatoreId -> media e numero di voti.
*/
export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> { export function mediePagelle(voti: VotoPagella[]): Record<string, MediaPagella> {
const somma: Record<string, { tot: number; n: number }> = {}; const somma: Record<string, { tot: number; n: number }> = {};
for (const v of voti) { for (const v of voti) {
+1
View File
@@ -91,6 +91,7 @@ export function useRosa(): Giocatore[] {
serieConferme: serieConferme(g.id, eventi, tempi), serieConferme: serieConferme(g.id, eventi, tempi),
mvp: mvpVinti[g.id] ?? 0, mvp: mvpVinti[g.id] ?? 0,
mediaVoto: medie[g.id]?.media ?? 0, mediaVoto: medie[g.id]?.media ?? 0,
votiPagella: medie[g.id]?.voti ?? 0,
palloni: palloni[g.id] ?? 0, palloni: palloni[g.id] ?? 0,
cacche: statCacche[g.id]?.giornateTop ?? 0, cacche: statCacche[g.id]?.giornateTop ?? 0,
cacchePartita: statCacche[g.id]?.media ?? 0, cacchePartita: statCacche[g.id]?.media ?? 0,
@@ -0,0 +1,88 @@
-- M13 — Le tabelle di voto controllano anche a database chi può votare chi.
--
-- Due gap segnalati in `docs/modules/badge.md` (audit del modulo Badge), entrambi finora
-- solo filtri applicativi, aggirabili da chi scrive direttamente su PostgREST:
--
-- 1. Niente impediva di votare (o essere votati) da/per un giocatore non convocato
-- all'evento — vale per pagelle_voti, mvp_voti, badge_social_voti.
-- 2. `eventi_app.pagelle_chiuse` nascondeva solo i bottoni in UI: un voto "fuori tempo"
-- restava tecnicamente possibile.
--
-- Le policy "Ognuno gestisce i propri voti ..." di M11 vengono estese con un controllo sui
-- convocati dell'evento: `convocati` vuoto significa "tutta la rosa" (stessa convenzione di
-- `convocatiEvento()` in eventi.ts), quindi il controllo si applica solo se la lista non è
-- vuota. Le policy admin restano invariate: un amministratore può correggere un voto anche
-- per un giocatore che non risultava convocato o dopo la chiusura delle pagelle.
CREATE OR REPLACE FUNCTION public.evento_permette_voto(
p_match_id text, p_votante_id text, p_votato_id text, p_richiede_pagelle_aperte boolean
) RETURNS boolean
LANGUAGE sql STABLE AS $$
SELECT EXISTS (
SELECT 1 FROM public.eventi_app e
WHERE e.id = p_match_id
AND (cardinality(e.convocati) = 0 OR p_votante_id = ANY(e.convocati))
AND (cardinality(e.convocati) = 0 OR p_votato_id = ANY(e.convocati))
AND (NOT p_richiede_pagelle_aperte OR NOT e.pagelle_chiuse)
);
$$;
-- --- pagelle: anche convocazione e chiusura --------------------------------------------
DROP POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti;
CREATE POLICY "Ognuno gestisce i propri voti pagella" ON public.pagelle_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, true)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, true)
);
-- --- MVP: solo convocazione, la votazione non ha un flag di chiusura -------------------
DROP POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti;
CREATE POLICY "Ognuno gestisce il proprio voto MVP" ON public.mvp_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
);
-- --- badge social: solo convocazione, stesso motivo -------------------------------------
DROP POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti;
CREATE POLICY "Ognuno gestisce i propri voti social" ON public.badge_social_voti
FOR ALL TO authenticated
USING (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM public.giocatori_squadra g
WHERE g.id = votante_id AND g.auth_user_id = auth.uid()
)
AND public.evento_permette_voto(match_id, votante_id, votato_id, false)
);
+146
View File
@@ -0,0 +1,146 @@
/**
* Badge Pagellone end-to-end contro il database locale: `bun test/integration/pagella-badge.test.ts`.
*
* I test unitari (`test/unit/pagelle.test.ts`, `test/unit/badges.test.ts`) verificano
* `mediePagelle()` e `statoBadge()` come funzioni pure, con voti costruiti a mano. Qui invece
* si scrivono voti veri su `pagelle_voti`, si rileggono via REST con la stessa selezione di
* `usePagelle()`, e si passa il risultato attraverso `mediePagelle()` fino a `statoBadge()` sul
* badge `pagella`: se una colonna cambia nome o la mappatura si rompe, qui il grado del badge
* torna sbagliato anche se i test unitari restano verdi, perché quelli non toccano mai il
* database.
*
* Copre in particolare la soglia minima di voti (`VOTI_MINIMI_PAGELLA`, aggiunta per il gap
* "un solo voto sblocca/toglie il badge" segnalato in `docs/modules/badge.md`): con voti reali
* letti dal database, non solo con numeri scelti a mano.
*
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
* usa id con il prefisso `test-pagella-badge`, che nessun dato vero può avere.
*/
import assert from "node:assert/strict";
import { badgeDefs, statoBadge, VOTI_MINIMI_PAGELLA } from "@/lib/badges";
import { mediePagelle, type VotoPagella } from "@/lib/pagelle";
import { giocatori, type Giocatore } from "@/lib/crapp-data";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("badge Pagellone sul database", "stack locale non attivo (npx supabase start)");
riepilogo("pagella-badge");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`badge Pagellone su ${URL_BASE}`);
const PREFISSO = "test-pagella-badge";
const pagellaDef = badgeDefs.find((b) => b.id === "pagella")!;
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 ?? {}),
},
});
async function upsert(riga: VotoPagella) {
const res = await rest("pagelle_voti?on_conflict=match_id,votante_id,votato_id", {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify(riga),
});
if (!res.ok) throw new Error(`upsert su pagelle_voti: ${res.status} ${await res.text()}`);
}
/** Rilegge esattamente come `usePagelle()`. */
async function leggiVoti(): Promise<VotoPagella[]> {
const res = await rest(
`pagelle_voti?match_id=like.${PREFISSO}-*&select=match_id,votante_id,votato_id,voto`,
);
return (await res.json()) as VotoPagella[];
}
/** Un giocatore azzerato, come in `test/unit/badges.test.ts`. */
function giocatoreAzzerato(mediaVoto: number, votiPagella: number): Giocatore {
return { ...giocatori[0]!, mediaVoto, votiPagella, mvp: 0, palloni: 0, presenze: 0 };
}
try {
await prova(
`sotto ${VOTI_MINIMI_PAGELLA} voti il badge resta bloccato anche con media alta`,
async () => {
// "pg1" riceve 4 voti da 9-10 (media altissima) ma sotto la soglia minima di voti:
// il badge non deve sbloccarsi nonostante la media sarebbe oro.
for (const [i, votante] of ["va", "vb", "vc", "vd"].entries()) {
await upsert({
match_id: `${PREFISSO}-m${i + 1}`,
votante_id: votante,
votato_id: "pg1",
voto: 9,
});
}
const voti = await leggiVoti();
const medie = mediePagelle(voti);
assert.equal(medie["pg1"]?.voti, 4, "4 voti scritti, 4 riletti");
assert.equal(medie["pg1"]?.media, 9, "media alta");
const badgePg1 = statoBadge(
pagellaDef,
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
);
assert.equal(badgePg1.grado, null, "4 voti < 5: il badge resta bloccato");
},
);
await prova(
`al ${VOTI_MINIMI_PAGELLA}° voto il badge Pagellone si sblocca con il grado giusto`,
async () => {
// Un quinto voto a "pg1" (stesso giocatore del test precedente) fa scattare la
// soglia minima: la media (9) sblocca subito l'oro (soglia 8.5).
await upsert({
match_id: `${PREFISSO}-m5`,
votante_id: "ve",
votato_id: "pg1",
voto: 9,
});
const medie = mediePagelle(await leggiVoti());
assert.equal(medie["pg1"]?.voti, 5);
const badgePg1 = statoBadge(
pagellaDef,
giocatoreAzzerato(medie["pg1"]!.media, medie["pg1"]!.voti),
);
assert.equal(badgePg1.grado, "oro", "5 voti raggiunti: la media conta, è oro");
},
);
await prova("le soglie di grado normali si applicano sopra il minimo di voti", async () => {
// "pg2" riceve 5 voti che fanno una media di bronzo (6.5), non oro: verifica che sopra
// la soglia minima il grado dipenda ancora dalla media, non solo dal numero di voti.
for (const [i, votante] of ["va", "vb", "vc", "vd", "ve"].entries()) {
await upsert({
match_id: `${PREFISSO}-n${i + 1}`,
votante_id: votante,
votato_id: "pg2",
voto: i < 4 ? 6 : 9, // (6*4+9)/5 = 6.6 -> arrotondato 6.6, sopra 6.5
});
}
const medie = mediePagelle(await leggiVoti());
assert.equal(medie["pg2"]?.voti, 5);
assert.equal(medie["pg2"]?.media, 6.6);
const badgePg2 = statoBadge(
pagellaDef,
giocatoreAzzerato(medie["pg2"]!.media, medie["pg2"]!.voti),
);
assert.equal(badgePg2.grado, "bronzo", "media 6.6: bronzo, non oro");
});
} finally {
await rest(`pagelle_voti?match_id=like.${PREFISSO}-*`, { method: "DELETE" });
}
riepilogo("pagella-badge");
}
+136
View File
@@ -406,6 +406,142 @@ if (!locale) {
assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro"); assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro");
}); });
// M13: le tabelle di voto controllano anche a database chi può votare chi, non solo
// chi firma il voto. Prima di M13 un convocato poteva votare/essere votato in un
// evento a cui non aveva partecipato, e un voto pagella restava possibile anche a
// `pagelle_chiuse` — entrambi filtri solo applicativi (segnalati in `badge.md`).
// Nota: `tokenAdmin` non va usato per queste prove, la policy admin di M11 non ha il
// controllo sui convocati (l'admin corregge anche dati fuori convocazione di
// proposito) e farebbe passare tutto a prescindere, senza provare niente sulla nuova
// policy. Si usa solo `tokenGiocatore` (g1), un votante non-admin vero.
const EVENTO_SENZA_G1 = `${PREFISSO}-evento-senza-g1`;
const EVENTO_CON_G1 = `${PREFISSO}-evento-con-g1`;
const EVENTO_CHIUSO = `${PREFISSO}-evento-chiuso`;
await prova("un votante non convocato non può votare", async () => {
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO_SENZA_G1,
tipo: "partita",
titolo: "Partita senza g1 tra i convocati",
data: "2026-01-02",
ora: "20:00",
luogo: "Palestra",
convocati: ["g2", "g5"],
}),
});
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
const votanteEscluso = await rest("pagelle_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_SENZA_G1,
votante_id: "g1",
votato_id: "g2",
voto: 7,
}),
});
assert.ok(!votanteEscluso.ok, `g1 non era convocato, non vota (${votanteEscluso.status})`);
});
await prova("un votante convocato non può votare chi non lo era", async () => {
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO_CON_G1,
tipo: "partita",
titolo: "Partita con g1 convocato, g2 no",
data: "2026-01-02",
ora: "20:00",
luogo: "Palestra",
convocati: ["g1", "g5"],
}),
});
assert.equal(await righeToccate(creato), 1, "l'evento con convocati si crea");
const votatoEscluso = await rest("badge_social_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CON_G1,
categoria: "cuore",
votante_id: "g1",
votato_id: "g2",
votato_nome: "Due",
}),
});
assert.ok(
!votatoEscluso.ok,
`g2 non era convocato, non è votabile (${votatoEscluso.status})`,
);
// Controllo positivo sullo stesso evento: g1 è convocato e vota g5, anche lui
// convocato — senza questo, il test sopra potrebbe fallire per un altro motivo
// (es. un evento inesistente) e sembrare comunque corretto.
const votoValido = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CON_G1,
votante_id: "g1",
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(await righeToccate(votoValido), 1, "votante e votato convocati: il voto passa");
});
await prova("pagelle_chiuse blocca anche a database, non solo in UI", async () => {
const creato = await rest("eventi_app", tokenAdmin, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: EVENTO_CHIUSO,
tipo: "partita",
titolo: "Partita con pagelle chiuse",
data: "2026-01-03",
ora: "20:00",
luogo: "Palestra",
pagelle_chiuse: true,
}),
});
assert.equal(await righeToccate(creato), 1, "l'evento con pagelle chiuse si crea");
const pagellaFuoriTempo = await rest("pagelle_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CHIUSO,
votante_id: "g1",
votato_id: "g5",
voto: 7,
}),
});
assert.ok(
!pagellaFuoriTempo.ok,
`pagelle chiuse: voto rifiutato (${pagellaFuoriTempo.status})`,
);
// Il flag riguarda solo le pagelle: MVP e badge social non hanno un concetto di
// "chiusura" (mvp.md lo segnala esplicitamente come limite noto), quindi restano
// votabili sullo stesso evento.
const mvpAncoraAperto = await rest("mvp_voti", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
match_id: EVENTO_CHIUSO,
votante_id: "g1",
votato_id: "g5",
votato_nome: "Cinque",
}),
});
assert.equal(await righeToccate(mvpAncoraAperto), 1, "l'MVP non ha un flag di chiusura");
});
// Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché // 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 // 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. // Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine.
+28 -1
View File
@@ -25,6 +25,7 @@ function g(valori: Partial<Giocatore> = {}): Giocatore {
presenze: 0, presenze: 0,
mvp: 0, mvp: 0,
mediaVoto: 0, mediaVoto: 0,
votiPagella: 0,
palloni: 0, palloni: 0,
cacche: 0, cacche: 0,
cacchePartita: 0, cacchePartita: 0,
@@ -66,6 +67,29 @@ const pagella = badgeDefs.find((b) => b.id === "pagella")!;
assert.equal(gradoRaggiunto(pagella, 6.4), null); assert.equal(gradoRaggiunto(pagella, 6.4), null);
assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo"); assert.equal(gradoRaggiunto(pagella, 6.5), "bronzo");
// Sotto i voti minimi il badge resta bloccato anche con una media altissima: un voto solo
// non deve poter sbloccare/sfilare Pagellone.
assert.equal(
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 1 })).grado,
null,
"1 voto su 10: non basta, sotto la soglia minima",
);
assert.equal(
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 4 })).grado,
null,
"4 voti: ancora sotto la soglia minima (5)",
);
assert.equal(
statoBadge(pagella, g({ mediaVoto: 10, votiPagella: 5 })).grado,
"oro",
"5 voti: la soglia minima è raggiunta, la media conta",
);
assert.equal(
statoBadge(pagella, g({ mediaVoto: 6.5, votiPagella: 5 })).grado,
"bronzo",
"sopra la soglia minima, valgono le normali soglie di grado",
);
// --- badgeGiocatore ---------------------------------------------------------- // --- badgeGiocatore ----------------------------------------------------------
assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti"); assert.equal(badgeGiocatore(g()).length, badgeDefs.length, "i badge normali sono sempre tutti");
assert.ok( assert.ok(
@@ -116,7 +140,9 @@ assert.equal(vuota.sbloccati.length, 0);
assert.equal(vuota.inProgresso.length, badgeDefs.length); assert.equal(vuota.inProgresso.length, badgeDefs.length);
assert.equal(vuota.nascosti, badgeSegreti.length); assert.equal(vuota.nascosti, badgeSegreti.length);
const piena = collezioneBadge(g({ mvp: 5, mediaVoto: 8, presenze: 30, infortuni: 3 })); const piena = collezioneBadge(
g({ mvp: 5, mediaVoto: 8, votiPagella: 5, presenze: 30, infortuni: 3 }),
);
assert.equal( assert.equal(
piena.ottenuti, piena.ottenuti,
piena.sbloccati.length + piena.segreti.length, piena.sbloccati.length + piena.segreti.length,
@@ -133,6 +159,7 @@ assert.equal(
g({ g({
mvp: 5, mvp: 5,
mediaVoto: 10, mediaVoto: 10,
votiPagella: 5,
palloni: 10, palloni: 10,
presenze: 30, presenze: 30,
serieAllenamenti: 10, serieAllenamenti: 10,