diff --git a/docs/DESIGN_DECISIONS.md b/docs/DESIGN_DECISIONS.md index 046c30c..abe2946 100644 --- a/docs/DESIGN_DECISIONS.md +++ b/docs/DESIGN_DECISIONS.md @@ -40,6 +40,7 @@ Serve a rispondere a domande del tipo: | [DD-022](#dd-022--lapp-è-solo-chiara) | App solo chiara | | [DD-023](#dd-023--ogni-scrittura-è-limitata-a-chi-la-fa) | Scritture limitate per ruolo | | [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 | **In valutazione** @@ -839,8 +840,8 @@ i chiamanti sono diversi (`src/lib/auth-route.server.ts`): `src/lib/ruoli.ts` (DD-011). `401` senza token valido, `403` con token ma senza ruolo. Il controllo viene **prima** della validazione dell'input, così la risposta non rivela nemmeno se un evento esiste. -- `promemoria-palloni` → `richiediSegreto`: intestazione `x-cron-segreto` confrontata con la - variabile `CRON_SEGRETO`. La chiama un cron, che una sessione non ce l'ha. +- `promemoria-palloni` → all'inizio `richiediSegreto`, con un segreto da cron; sostituito + subito dopo da `richiediAdmin` quando la route è diventata manuale (DD-025). `csi`, `push-config`, `push-subscribe` e `push-messaggio` restano aperte: le chiamano il browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubblico. @@ -857,9 +858,8 @@ browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubb **Conseguenze** -- Se `CRON_SEGRETO` non è configurata, `promemoria-palloni` risponde `503` e il promemoria - non parte. È voluto: una porta che si riapre da sola quando manca una variabile - d'ambiente non se ne accorge nessuno. Va impostata negli ambienti di deploy e nel cron. +- Nessuna variabile d'ambiente da configurare: dopo DD-025 tutte e tre le route usano lo + stesso controllo sul ruolo. - I due pulsanti dell'app mandano ora il token con `intestazioniAutenticate()` (`src/lib/auth.ts`), letto al momento della chiamata e non da uno stato React, così non si spedisce un token scaduto. @@ -871,3 +871,55 @@ browser prima del login e il service worker, dove qualsiasi segreto sarebbe pubb **Riesame** Se un giorno l'app userà `createServerFn`, il middleware già presente diventa la strada naturale e questi controlli vanno riletti alla sua luce. + +### DD-025 — Il promemoria palloni lo manda l'admin, per un evento + +**Data:** 6 settembre 2026 +**Stato:** Accettata + +**Contesto** +`promemoria-palloni` era disegnata per un cron quotidiano: calcolava chi è di turno **oggi** e +gli mandava una push. DD-024 l'aveva chiusa con un segreto condiviso, coerente con quel +disegno. Ma nel repository non c'è nessun cron, e `docs/modules/palloni.md` lo annotava già +come «da verificare lato hosting»: nei fatti quel promemoria non è mai partito. Una route che +funziona e che nessuno chiama. + +Nel frattempo l'app aveva già il precedente giusto: `apri-sondaggio` è manuale fin dall'inizio +(«Nessun cron: l'invio è manuale», CHANGELOG v1.0.6). + +**Decisione** +Il promemoria lo fa partire un amministratore dal pulsante «Avvisa chi è di turno», dentro il +riquadro palloni della pagina evento. La route accetta un `eventoId` e avvisa i destinatari di +**quell'evento** — chi deve prendere i palloni e chi deve riportarli — invece della giornata +corrente. Il controllo di accesso diventa `richiediAdmin` come le altre due, e +`richiediSegreto` con la sua variabile `CRON_SEGRETO` spariscono. + +Il testo dell'avviso viaggia in coda su `promemoria_push`: la push parte vuota e il service +worker chiede a `push-messaggio` cosa mostrare, ma quella route sa raccontare solo la giornata +corrente. Senza la coda, un avviso mandato il martedì per il sabato arriverebbe con il testo +generico. + +**Alternative scartate** + +- Un pulsante «manda il promemoria di oggi» in Dashboard → rispecchia la route com'era, ma + premuto un martedì qualsiasi risponderebbe «inviate: 0» e sembrerebbe rotto. L'admin + ragiona per evento, non per giornata. +- Tenere il cron e configurarlo davvero → più lavoro, una variabile d'ambiente da gestire in + ogni ambiente, e nessuno l'aveva chiesto. Un pulsante che funziona batte uno scheduler che + non esiste. +- Calcolare il testo al volo come fa `messaggioPalloniOggi` → funziona solo se l'avviso parte + il giorno stesso, cioè proprio il vincolo da cui volevamo uscire. + +**Conseguenze** + +- Il promemoria è ora una scelta consapevole di un admin, non un automatismo: se nessuno preme + il pulsante, non parte niente. È un passo indietro rispetto all'idea originale, ma un passo + avanti rispetto alla realtà, dove non partiva mai. +- `destinatariPromemoriaPalloni()` non è più usata dalla route ma resta in `palloni-core.ts`, + perché `push-messaggio` continua a costruire il testo della giornata per le push senza coda. +- Sparisce `CRON_SEGRETO`: nessuna variabile d'ambiente nuova da configurare in nessun + ambiente. + +**Riesame** +Se la squadra si accorge che l'admin si dimentica di premere il pulsante. A quel punto il cron +torna utile, e con lui il segreto: DD-024 descrive già come farlo. diff --git a/docs/modules/notifiche.md b/docs/modules/notifiche.md index 11fec89..58a1854 100644 --- a/docs/modules/notifiche.md +++ b/docs/modules/notifiche.md @@ -59,20 +59,17 @@ L'invio effettivo (`src/lib/webpush.server.ts`, funzione `inviaPush`) firma un J (ECDSA P-256) e fa una POST senza corpo all'endpoint push del browser; è riusato identico da `sollecita-presenze.ts` e `promemoria-palloni.ts`. -### Chi può farle partire (DD-024) +### Chi può farle partire (DD-024, DD-025) Queste route usano la service role e saltano la RLS, quindi il permesso deve stare nella -route. `src/lib/auth-route.server.ts` fornisce i due controlli: +route. Tutte e tre partono da un gesto di un amministratore dentro l'app, quindi il controllo +è uno solo (`richiediAdmin` in `src/lib/auth-route.server.ts`) e non serve configurare nessuna +variabile d'ambiente. -| Route | Controllo | Chi la chiama | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| `apri-sondaggio`, `sollecita-presenze` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, dal pulsante riservato agli admin | -| `promemoria-palloni` | `richiediSegreto` — intestazione `x-cron-segreto` uguale alla variabile `CRON_SEGRETO` | un cron, senza sessione | -| `csi`, `push-config`, `push-subscribe`, `push-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno | - -**`CRON_SEGRETO` va configurata negli ambienti**: se manca, `promemoria-palloni` risponde -503 e il promemoria non parte. È voluto — una porta che si riapre da sola quando manca una -configurazione non se ne accorge nessuno. +| Route | Controllo | Chi la chiama | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `apri-sondaggio`, `sollecita-presenze`, `promemoria-palloni` | `richiediAdmin` — token della sessione Supabase, poi ruolo `admin` in `user_roles` | l'app, da un pulsante riservato agli admin | +| `csi`, `push-config`, `push-subscribe`, `push-messaggio` | nessuno | il browser prima del login e il service worker, che una sessione non ce l'hanno | --- diff --git a/docs/modules/palloni.md b/docs/modules/palloni.md index 4a30183..da9a22c 100644 --- a/docs/modules/palloni.md +++ b/docs/modules/palloni.md @@ -42,23 +42,26 @@ compaiono. ## Route API pubblica `/api/public/promemoria-palloni` -Pensata per essere chiamata quotidianamente da uno scheduler esterno (pg_cron o simile, -secondo `docs/PORTABILITA.md`), non da nessun componente client. Calcola i destinatari del -giorno — chi deve **prendere** i palloni oggi e chi deve **riportarli** (l'incaricato -dell'evento precedente) — e invia loro una push "vuota" (`src/lib/webpush.server.ts`); il -testo effettivo viene calcolato al volo dal service worker interrogando -`/api/public/push-messaggio` (vedi [Notifiche](notifiche.md)). +La fa partire un **amministratore** dal pulsante «Avvisa chi è di turno» dentro il riquadro +palloni dell'evento (`TurnoPalloni.tsx`), riservato agli admin (DD-025). Riceve l'`eventoId`, +e `avvisiPalloniEvento()` calcola i due destinatari di _quell'evento_: chi deve **prendere** i +palloni e chi deve **riportarli** (l'incaricato dell'evento precedente), con un testo diverso +per ciascuno. + +Il testo va messo in coda su `promemoria_push` prima di inviare la push, perché la push parte +"vuota" e il service worker chiede a `/api/public/push-messaggio` cosa mostrare — e quella +route da sola sa raccontare solo la giornata corrente, quindi un avviso mandato con giorni di +anticipo arriverebbe con il testo generico. Stesso meccanismo di `apri-sondaggio` (vedi +[Notifiche](notifiche.md)). --- ## Limiti noti -- **Nessun cron nel repository**: lo scheduling effettivo (se esiste) è configurato fuori dal - codice versionato — da verificare lato Supabase/hosting. Finché non esiste, il promemoria - quotidiano non parte da solo. -- La route è protetta dal segreto previsto dal piano originale (DD-024): chi la chiama deve - mandare `x-cron-segreto` uguale alla variabile `CRON_SEGRETO`. Se la variabile non è - configurata nell'ambiente la route risponde `503`. +- **L'invio è manuale**: nessun cron manda il promemoria da solo, se l'admin non preme il + pulsante non parte niente (DD-025). `destinatariPromemoriaPalloni()` — la versione "chi è di + turno oggi" — resta in `palloni-core.ts` perché la usa `push-messaggio` per il testo + calcolato al volo, ma nessuno scheduler la interroga. - Il conteggio dei turni include anche le proposte non confermate: badge e statistiche possono contare turni mai effettivamente convalidati da nessuno. - La rotazione non considera le assenze dichiarate: può proporre il turno a chi ha risposto diff --git a/src/components/crapp/TurnoPalloni.tsx b/src/components/crapp/TurnoPalloni.tsx index ab73c7d..5ffaf23 100644 --- a/src/components/crapp/TurnoPalloni.tsx +++ b/src/components/crapp/TurnoPalloni.tsx @@ -1,17 +1,21 @@ import { useState } from "react"; -import { Check, CircleDot, Loader2, Pencil } from "lucide-react"; +import { BellRing, Check, CircleDot, Loader2, Pencil } from "lucide-react"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { Avatar } from "@/components/crapp/Avatar"; +import { intestazioniAutenticate } from "@/lib/auth"; import { nomeCompleto, useGiocatoriSquadra } from "@/lib/giocatori-squadra"; import { useAssegnaTurno, useTurniPalloni } from "@/lib/palloni"; +import { useIsAdmin } from "@/lib/ruoli"; import { useGiocatoreCorrente } from "@/lib/user-store"; export function TurnoPalloni({ eventoId }: { eventoId: string }) { const [aperto, setAperto] = useState(false); + const [avviso, setAvviso] = useState(false); const { salvati, turni, isPending } = useTurniPalloni(); const assegna = useAssegnaTurno(); const io = useGiocatoreCorrente(); + const admin = useIsAdmin(); const { righe: squadra } = useGiocatoriSquadra(); const rosa = squadra.filter((g) => g.attivo); @@ -30,6 +34,29 @@ export function TurnoPalloni({ eventoId }: { eventoId: string }) { ); } + /** Avvisa via push chi è di turno per questo evento, e chi deve riportare i palloni. */ + async function avvisa() { + setAvviso(true); + try { + const res = await fetch("/api/public/promemoria-palloni", { + method: "POST", + headers: { "Content-Type": "application/json", ...(await intestazioniAutenticate()) }, + body: JSON.stringify({ eventoId }), + }); + if (!res.ok) throw new Error(); + const dati = (await res.json()) as { inviate: number; destinatari: number }; + toast.success( + dati.inviate > 0 + ? `Promemoria inviato a ${dati.inviate} dispositivi` + : "Nessun dispositivo con le notifiche attive tra gli incaricati", + ); + } catch { + toast.error("Non sono riuscito a inviare il promemoria"); + } finally { + setAvviso(false); + } + } + return (
+ ) : null}
); } diff --git a/src/lib/auth-route.server.ts b/src/lib/auth-route.server.ts index 728f9d1..bb9f48a 100644 --- a/src/lib/auth-route.server.ts +++ b/src/lib/auth-route.server.ts @@ -7,13 +7,10 @@ * è un timestamp in base 36 e compare negli URL che la squadra si scambia — quindi non * può fare da credenziale. * - * Due tipi di chiamante, due controlli: - * - dall'app, per un'azione da amministratore -> `richiediAdmin`, che verifica il token - * della sessione Supabase e poi il ruolo in `user_roles`; - * - da una macchina (cron), dove nessuna sessione esiste -> `richiediSegreto`. - * - * Entrambe tornano `null` quando la richiesta può proseguire, altrimenti la `Response` di - * rifiuto già pronta. + * Tutte e tre le route che mandano notifiche partono da un pulsante riservato agli + * amministratori, quindi il controllo è uno solo: `richiediAdmin` verifica il token della + * sessione Supabase e poi il ruolo in `user_roles`. Torna `null` quando la richiesta può + * proseguire, altrimenti la `Response` di rifiuto già pronta. */ /** Il token della sessione Supabase, se la richiesta ne porta uno ben formato. */ @@ -49,21 +46,3 @@ export async function richiediAdmin(request: Request): Promise if (!ruolo) return new Response("Riservato agli amministratori", { status: 403 }); return null; } - -/** - * Lascia passare solo chi conosce `CRON_SEGRETO`, per le chiamate senza sessione. - * - * Se la variabile non è configurata la route resta chiusa: una porta che si apre da sola - * quando manca una configurazione è peggio di una porta che non funziona, perché nessuno - * se ne accorge. - */ -export function richiediSegreto(request: Request): Response | null { - const atteso = process.env["CRON_SEGRETO"]; - if (!atteso) { - console.error("[auth] CRON_SEGRETO non configurato: la route resta chiusa"); - return new Response("Route non configurata", { status: 503 }); - } - const fornito = request.headers.get("x-cron-segreto"); - if (fornito !== atteso) return new Response("Segreto non valido", { status: 401 }); - return null; -} diff --git a/src/lib/palloni-core.ts b/src/lib/palloni-core.ts index 45bbc5c..8335337 100644 --- a/src/lib/palloni-core.ts +++ b/src/lib/palloni-core.ts @@ -139,3 +139,49 @@ export function messaggioPalloniOggi( body: nome ? `${nome}, controlla il turno palloni nel calendario.` : "Controlla il calendario.", }; } + +/** + * Avvisi da mandare per un evento preciso, con il testo già pronto (DD-025). + * + * Diverso da `destinatariPromemoriaPalloni`, che guarda la giornata di oggi: qui l'admin + * sceglie l'evento dalla sua pagina, quindi il messaggio nomina quell'evento e non "oggi". + * Il testo viaggia in coda su `promemoria_push` perché la push parte vuota e il service + * worker chiede a `push-messaggio` cosa mostrare — che da solo saprebbe raccontare solo + * la giornata corrente. + */ +export function avvisiPalloniEvento( + turni: Record, + eventi: Evento[], + eventoId: string, +): Array<{ giocatoreId: string; titolo: string; testo: string }> { + const evento = eventiPalloni(eventi).find((e) => e.id === eventoId); + if (!evento) return []; + + const avvisi: Array<{ giocatoreId: string; titolo: string; testo: string }> = []; + const quando = `${evento.titolo} · ${formatData(evento.data)} alle ${evento.ora}`; + + const incaricato = turni[evento.id]; + if (incaricato) { + const dopo = eventoSuccessivo(eventi, evento.id); + avvisi.push({ + giocatoreId: incaricato, + titolo: "Tocca a te prendere i palloni", + testo: dopo + ? `${quando}: a fine evento porti a casa i palloni e li riporti il ${formatData(dopo.data)}.` + : `${quando}: a fine evento porti a casa i palloni.`, + }); + } + + // Chi li ha presi la volta scorsa deve ricordarsi di portarli. + const prima = eventoPrecedente(eventi, evento.id); + const precedente = prima ? turni[prima.id] : undefined; + if (precedente && precedente !== incaricato) { + avvisi.push({ + giocatoreId: precedente, + titolo: "Porta i palloni", + testo: `${quando}: i palloni li hai tu dalla volta scorsa.`, + }); + } + + return avvisi; +} diff --git a/src/routes/api/public/promemoria-palloni.ts b/src/routes/api/public/promemoria-palloni.ts index e581430..2347d70 100644 --- a/src/routes/api/public/promemoria-palloni.ts +++ b/src/routes/api/public/promemoria-palloni.ts @@ -1,19 +1,33 @@ import { createFileRoute } from "@tanstack/react-router"; -import { richiediSegreto } from "@/lib/auth-route.server"; +import { z } from "zod"; +import { richiediAdmin } from "@/lib/auth-route.server"; import { nomeCompleto } from "@/lib/giocatori-squadra"; import { leggiGiocatoriSquadra } from "@/lib/giocatori-squadra.server"; -import { completaTurni, destinatariPromemoriaPalloni, oggiISO } from "@/lib/palloni-core"; +import { avvisiPalloniEvento, completaTurni } from "@/lib/palloni-core"; import { inviaPush } from "@/lib/webpush.server"; import { leggiEventi } from "@/lib/eventi.server"; +const schema = z.object({ eventoId: z.string().min(1).max(50) }); + +/** + * Promemoria del turno palloni per un evento: lo fa partire un admin dalla pagina + * dell'evento (DD-025). Il testo va in coda su `promemoria_push` perché la push parte + * vuota e il service worker chiede a `push-messaggio` cosa mostrare. + */ export const Route = createFileRoute("/api/public/promemoria-palloni")({ server: { handlers: { POST: async ({ request }) => { - // Chiamata da un cron, senza sessione: qui vale il segreto condiviso. - const negato = richiediSegreto(request); + const negato = await richiediAdmin(request); if (negato) return negato; + const parsed = schema.safeParse(await request.json()); + if (!parsed.success) return new Response("Dati non validi", { status: 400 }); + + const eventi = await leggiEventi(); + const evento = eventi.find((e) => e.id === parsed.data.eventoId); + if (!evento) return new Response("Evento non trovato", { status: 404 }); + const { supabaseAdmin } = await import("@/integrations/supabase/client.server"); const { data: righe } = await supabaseAdmin @@ -21,26 +35,34 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({ .select("evento_id, giocatore_id"); const salvati: Record = {}; for (const riga of righe ?? []) salvati[riga.evento_id] = riga.giocatore_id; - const eventi = await leggiEventi(); + const squadra = await leggiGiocatoriSquadra(); const rosa = squadra .filter((g) => g.attivo) .map((g) => ({ id: g.id, nome: nomeCompleto(g) })); const turni = completaTurni(salvati, eventi, rosa); - const oggi = oggiISO(); - const destinatari = destinatariPromemoriaPalloni(turni, eventi, oggi); - - if (destinatari.length === 0) return Response.json({ inviate: 0 }); + const avvisi = avvisiPalloniEvento(turni, eventi, evento.id); + if (avvisi.length === 0) return Response.json({ inviate: 0, destinatari: 0 }); const { data: iscrizioni } = await supabaseAdmin .from("push_subscriptions") .select("endpoint, giocatore_id") - .in("giocatore_id", destinatari); + .in( + "giocatore_id", + avvisi.map((a) => a.giocatoreId), + ); let inviate = 0; for (const iscrizione of iscrizioni ?? []) { + const avviso = avvisi.find((a) => a.giocatoreId === iscrizione.giocatore_id); + if (!avviso) continue; try { + await supabaseAdmin.from("promemoria_push").insert({ + endpoint: iscrizione.endpoint, + titolo: avviso.titolo, + testo: avviso.testo, + }); const stato = await inviaPush(iscrizione.endpoint); if (stato === 404 || stato === 410) { await supabaseAdmin @@ -55,7 +77,7 @@ export const Route = createFileRoute("/api/public/promemoria-palloni")({ } } - return Response.json({ inviate }); + return Response.json({ inviate, destinatari: (iscrizioni ?? []).length }); }, }, }, diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index c37529c..a34e5e8 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -117,18 +117,18 @@ try { // Il controllo di accesso viene prima della validazione: senza credenziali la // risposta non deve nemmeno dire se l'evento esiste. await prova("le route che avvisano la squadra chiedono le credenziali", async () => { - for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) { + const route = [ + "/api/public/sollecita-presenze", + "/api/public/apri-sondaggio", + "/api/public/promemoria-palloni", + ]; + for (const percorso of route) { assert.equal( (await postJson(percorso, { eventoId: "non-esiste" })).status, 401, `${percorso} senza token`, ); } - assert.equal( - (await postJson("/api/public/promemoria-palloni", {})).status < 400, - false, - "promemoria-palloni senza segreto non parte", - ); }); await prova("un token malformato non passa", async () => { diff --git a/test/integration/permessi-route.test.ts b/test/integration/permessi-route.test.ts index 421588b..64253b7 100644 --- a/test/integration/permessi-route.test.ts +++ b/test/integration/permessi-route.test.ts @@ -22,13 +22,11 @@ if (!locale) { riepilogo("permessi route"); } else { const { url: SUPABASE, anon: ANON, servizio: SERVIZIO } = locale; - const SEGRETO = "segreto-di-prova"; // Il server di sviluppo eredita queste: le route leggono i nomi senza prefisso. process.env["SUPABASE_URL"] = SUPABASE; process.env["SUPABASE_PUBLISHABLE_KEY"] = ANON; process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO; - process.env["CRON_SEGRETO"] = SEGRETO; const PASSWORD = "prova-route-123"; const idUtenti: string[] = []; @@ -74,6 +72,13 @@ if (!locale) { const server = await avviaServer(); console.log(`permessi route su ${server.baseUrl} (database ${SUPABASE})`); + /** Le tre route che mandano notifiche: stesso controllo, stesso corpo (DD-024, DD-025). */ + const ROUTE = [ + "/api/public/sollecita-presenze", + "/api/public/apri-sondaggio", + "/api/public/promemoria-palloni", + ]; + const chiama = (percorso: string, intestazioni: Record = {}) => fetch(`${server.baseUrl}${percorso}`, { method: "POST", @@ -83,12 +88,11 @@ if (!locale) { try { await prova("senza token la route non risponde nemmeno se l'evento esiste", async () => { - assert.equal((await chiama("/api/public/sollecita-presenze")).status, 401); - assert.equal((await chiama("/api/public/apri-sondaggio")).status, 401); + for (const percorso of ROUTE) assert.equal((await chiama(percorso)).status, 401, percorso); }); await prova("un giocatore autenticato non avvisa la squadra", async () => { - for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) { + for (const percorso of ROUTE) { const res = await chiama(percorso, { authorization: `Bearer ${tokenGiocatore}` }); assert.equal(res.status, 403, `${percorso}: token valido ma senza ruolo`); } @@ -98,30 +102,11 @@ if (!locale) { // validazione dell'input. Il 404 dice esattamente questo — è passato, e l'evento // inventato non esiste. await prova("un amministratore passa e arriva alla validazione", async () => { - for (const percorso of ["/api/public/sollecita-presenze", "/api/public/apri-sondaggio"]) { + for (const percorso of ROUTE) { const res = await chiama(percorso, { authorization: `Bearer ${tokenAdmin}` }); assert.equal(res.status, 404, `${percorso}: superato l'accesso, evento inesistente`); } }); - - await prova("il promemoria palloni chiede il segreto del cron", async () => { - const senza = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, { - method: "POST", - }); - assert.equal(senza.status, 401, "senza segreto non parte"); - - const sbagliato = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, { - method: "POST", - headers: { "x-cron-segreto": "non-e-questo" }, - }); - assert.equal(sbagliato.status, 401, "un segreto sbagliato vale come nessun segreto"); - - const giusto = await fetch(`${server.baseUrl}/api/public/promemoria-palloni`, { - method: "POST", - headers: { "x-cron-segreto": SEGRETO }, - }); - assert.equal(giusto.status, 200, "con il segreto giusto il job parte"); - }); } finally { server.stop(); for (const id of idUtenti) { diff --git a/test/unit/palloni-core.test.ts b/test/unit/palloni-core.test.ts index 4b44448..03c8603 100644 --- a/test/unit/palloni-core.test.ts +++ b/test/unit/palloni-core.test.ts @@ -5,6 +5,7 @@ import type { Evento } from "@/lib/eventi"; import { completaTurni, conteggioTurni, + avvisiPalloniEvento, destinatariPromemoriaPalloni, eventiDelGiorno, eventiPalloni, @@ -139,6 +140,42 @@ assert.deepEqual( "stessa persona oggi e alla volta prima: un solo avviso", ); +// --- avvisiPalloniEvento ------------------------------------------------------ +// Il promemoria per un evento scelto dall'admin: due destinatari, con testi diversi. +const avvisi = avvisiPalloniEvento(turniPush, eventiPush, "p2"); +assert.deepEqual( + avvisi.map((a) => a.giocatoreId), + ["g2", "g1"], + "l'incaricato di questo evento, e chi ha i palloni dalla volta prima", +); +assert.match(avvisi[0]!.titolo, /prendere i palloni/); +assert.match(avvisi[1]!.titolo, /Porta i palloni/); +assert.ok( + avvisi.every((a) => !/oggi/i.test(a.testo)), + "il testo nomina l'evento, non 'oggi': può arrivare giorni prima", +); + +assert.deepEqual( + avvisiPalloniEvento(turniPush, eventiPush, "p1").map((a) => a.giocatoreId), + ["g1"], + "il primo evento non ha un precedente da avvisare", +); +assert.deepEqual( + avvisiPalloniEvento({ p1: "g1", p2: "g1" }, eventiPush, "p2").map((a) => a.giocatoreId), + ["g1"], + "stessa persona nei due ruoli: un avviso solo, non due notifiche uguali", +); +assert.deepEqual( + avvisiPalloniEvento(turniPush, eventiPush, "non-esiste"), + [], + "un evento inesistente non produce avvisi", +); +assert.deepEqual( + avvisiPalloniEvento({}, eventiPush, "p2"), + [], + "senza turni assegnati non c'è nessuno da avvisare", +); + // --- messaggioPalloniOggi ----------------------------------------------------- assert.deepEqual( messaggioPalloniOggi(turniPush, eventiPush, "2026-02-02", "g1", "Mario"),