Copre presenze, calendario e guardia admin con nuovi test.

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 19:03:18 +02:00
co-authored by Claude Opus 5
parent 3d8cb22235
commit 752b300474
9 changed files with 224 additions and 18 deletions
+12
View File
@@ -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)
+3 -2
View File
@@ -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;
}
/**
+11 -7
View File
@@ -14,8 +14,7 @@ export type MappaPresenze = Record<string, Record<string, Stato>>;
export type MappaTempiRisposta = Record<string, Record<string, string>>;
/** 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);
}
+6 -6
View File
@@ -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
+55 -1
View File
@@ -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" });
+30
View File
@@ -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<string, string>) =>
richiediAdmin(new Request("http://localhost/api/public/qualcosa", { headers: intestazioni }));
const rifiuti: Array<[string, Record<string, string>]> = [
["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");
+60 -1
View File
@@ -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");
+25 -1
View File
@@ -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");
+22
View File
@@ -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),