Copre la cache delle presenze, le tabelle aperte e le letture server.

Chiude i tre buchi di copertura rimasti dalla rilettura della documentazione:
comportamenti che la doc descrive come regole ma che nessun test verificava.

- conRisposta() esce da useSalvaPresenza e diventa una funzione pura in presenze.ts.
  La mutation non rilegge dopo la scrittura, quindi la cache deve imitare il database:
  l'istante si scrive solo se manca (??=) e il ritiro della risposta lo cancella. Erano
  due dettagli che serie-presenze.md chiede di preservare e che un refactor poteva
  perdere in silenzio, falsando la serie "Conferme 24h" fino al refresh successivo.
- permessi.test.ts copre ora anche il terzo gruppo di DD-023, le tabelle lasciate
  aperte di proposito: scout_sessioni, scout_live, scout_partite e push_subscriptions.
  Non dicono che sono sicure, dicono che sono aperte per scelta: se una prende un gate
  nell'interfaccia, le policy devono seguirlo e questi casi vanno cambiati con loro.
- lettori-server.test.ts esegue leggiEventi() e leggiGiocatoriSquadra() sul database
  locale, mettendo le credenziali in process.env prima della prima chiamata perché
  supabaseAdmin nasce pigramente. Nessuna delle due controlla l'errore di PostgREST:
  una colonna rinominata darebbe zero righe, zero destinatari e nessuna push, senza
  che niente segnali il problema. Scrivendolo è emerso che eventi_app.note è NOT NULL,
  mentre RigaEvento lo dichiara nullable.

PROJECT_STATE: m12_niente_autovoto è applicata in produzione dal 06/09/2026,
verificata con `npx supabase migration list` (23 migration, local = remote).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-06 19:46:52 +02:00
co-authored by Claude Opus 5
parent f687322c3f
commit 7c1f08d943
6 changed files with 300 additions and 28 deletions
+110
View File
@@ -0,0 +1,110 @@
/**
* I due lettori lato server contro il database locale:
* `bun test/integration/lettori-server.test.ts`.
*
* `leggiEventi()` e `leggiGiocatoriSquadra()` sono le uniche letture delle tre route che
* mandano push: se una colonna elencata nelle costanti `COLONNE`/`COLONNE_SQUADRA` sparisse
* o cambiasse nome, PostgREST risponderebbe con un errore, `data` sarebbe `null` e le route
* manderebbero le notifiche a zero destinatari — senza che niente si accorga di niente,
* perché nessuna delle due controlla l'errore. Qui si esegue la query vera e si verifica che
* le righe arrivino mappate.
*
* Gira solo sullo stack locale (`npx supabase start`): le credenziali arrivano da
* `supabase status` e vengono messe in `process.env` **prima** della prima chiamata, perché
* `client.server.ts` costruisce il client pigramente leggendo l'ambiente. Così il test non
* può parlare con il progetto cloud nemmeno per sbaglio.
*/
import assert from "node:assert/strict";
import { statoLocale } from "../helpers/locale";
import { prova, riepilogo, salta } from "../helpers/prova";
const locale = statoLocale();
if (!locale) {
salta("lettori server", "stack locale non attivo (npx supabase start)");
riepilogo("lettori server");
} else {
const { url: URL_BASE, servizio: SERVIZIO } = locale;
console.log(`lettori server su ${URL_BASE}`);
// Prima di qualsiasi import dei moduli server: è da qui che nasce `supabaseAdmin`.
process.env["SUPABASE_URL"] = URL_BASE;
process.env["SUPABASE_SERVICE_ROLE_KEY"] = SERVIZIO;
const { leggiEventi } = await import("@/lib/eventi.server");
const { leggiGiocatoriSquadra } = await import("@/lib/giocatori-squadra.server");
const PREFISSO = "test-lettori";
const EVENTO = `${PREFISSO}-e1`;
const rest = (percorso: string, init?: RequestInit) =>
fetch(`${URL_BASE}/rest/v1/${percorso}`, {
...init,
headers: {
apikey: SERVIZIO,
Authorization: `Bearer ${SERVIZIO}`,
"content-type": "application/json",
...(init?.headers ?? {}),
},
});
try {
const inserito = await rest("eventi_app", {
method: "POST",
body: JSON.stringify({
id: EVENTO,
tipo: "partita",
titolo: "Lettori server",
luogo: "Palestra",
data: "2026-01-02",
ora: "21:00",
note: "",
convocati: ["g1", "g2"],
campionato: true,
casa: false,
}),
});
if (!inserito.ok) throw new Error(`preparazione fallita: ${await inserito.text()}`);
await prova("leggiEventi() mappa le colonne che le route si aspettano", async () => {
const eventi = await leggiEventi();
const evento = eventi.find((e) => e.id === EVENTO);
assert.ok(evento, "l'evento appena inserito arriva fino al chiamante");
assert.equal(evento.titolo, "Lettori server");
assert.equal(evento.tipo, "partita");
assert.equal(evento.ora, "21:00");
assert.equal(evento.casa, false, "la trasferta resta una trasferta");
assert.equal(evento.campionato, true);
assert.deepEqual(evento.convocati, ["g1", "g2"], "i convocati servono al sollecito");
assert.equal(evento.note, "", "le note vuote restano stringa vuota");
assert.equal(evento.pagelleChiuse, false);
});
await prova("leggiGiocatoriSquadra() torna la rosa con i campi usati dalle route", async () => {
const squadra = await leggiGiocatoriSquadra();
assert.ok(squadra.length > 0, "la rosa del seed non è vuota");
const g1 = squadra.find((g) => g.id === "g1");
assert.ok(g1, "g1 esiste nel seed locale");
assert.equal(typeof g1.nome, "string");
assert.equal(typeof g1.cognome, "string");
assert.equal(typeof g1.numero, "number");
assert.equal(typeof g1.attivo, "boolean", "`attivo` filtra i destinatari delle push");
// Le colonne di M8: fanno parte di COLONNE_SQUADRA, quindi se mancassero la query
// fallirebbe per tutti, non solo per la dashboard tesseramenti.
assert.ok("numeroTessera" in g1 && "dataTessera" in g1, "le colonne di M8 sono lette");
});
await prova("l'ordine è quello dichiarato: cognome, poi nome", async () => {
const squadra = await leggiGiocatoriSquadra();
const chiavi = squadra.map((g) => `${g.cognome} ${g.nome}`);
assert.deepEqual(
chiavi,
[...chiavi].sort((a, b) => a.localeCompare(b)),
"gli elenchi mostrati alla squadra dipendono da questo ordinamento",
);
});
} finally {
await rest(`eventi_app?id=like.${PREFISSO}*`, { method: "DELETE" });
riepilogo("lettori server");
}
}
+84 -2
View File
@@ -394,8 +394,12 @@ if (!locale) {
assert.equal(await righeToccate(pagella), 1, "e per cancellare il voto di un altro");
});
// I turni palloni restano aperti di proposito: nell'interfaccia il turno se lo passa
// chiunque, senza gate. Se un giorno arriva il gate, questo test va cambiato.
// Il terzo gruppo di DD-023: tabelle lasciate aperte **di proposito**, perché
// nell'interfaccia non hanno nessun gate — il turno palloni se lo passa chiunque, e lo
// Scout Live lo apre chiunque, con il solo lock di sessione a tenere l'ordine.
// Questi casi non dicono che sono sicure: dicono che sono aperte per scelta. Se un
// giorno una di loro prende un gate nell'interfaccia, le policy devono seguirlo e
// questi test vanno cambiati insieme.
await prova("il turno palloni resta assegnabile da chiunque sia autenticato", async () => {
const res = await rest("turni_palloni", tokenGiocatore, {
method: "POST",
@@ -404,6 +408,79 @@ if (!locale) {
});
assert.equal(await righeToccate(res), 1, "DD-023 lascia questa tabella invariata");
});
await prova("lo Scout Live resta aperto a chiunque sia autenticato", async () => {
const sessione = await rest("scout_sessioni", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
evento_id: EVENTO,
giocatore_id: "g5",
giocatore_nome: "Cinque",
aggiornato_il: new Date().toISOString(),
}),
});
assert.equal(await righeToccate(sessione), 1, "il lock lo prende chiunque");
const stato = await rest("scout_live", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({ evento_id: EVENTO, stato: { set: 1 } }),
});
assert.equal(await righeToccate(stato), 1, "e lo stato in corso lo scrive chiunque");
const archivio = await rest("scout_partite", tokenGiocatore, {
method: "POST",
headers: { Prefer: "return=representation" },
body: JSON.stringify({
id: `${PREFISSO}-match`,
evento_id: EVENTO,
data: "2026-01-01",
avversario: "Prova",
casa: true,
set_nostri: 3,
set_loro: 0,
parziali: [],
azioni: [],
}),
});
assert.equal(await righeToccate(archivio), 1, "come l'archivio di fine partita");
const cancella = await rest(`scout_partite?id=eq.${PREFISSO}-match`, tokenGiocatore, {
method: "DELETE",
headers: { Prefer: "return=representation" },
});
assert.equal(await righeToccate(cancella), 1, "e chiunque può anche cancellarlo");
});
// Le iscrizioni push non passano dalla RLS per identificare il dispositivo: la chiave è
// l'endpoint, che il browser conosce solo per sé. Restano scrivibili da chiunque sia
// autenticato, ed è il motivo per cui `push_subscriptions` non contiene dati personali
// oltre all'endpoint e alle sue chiavi.
await prova("l'iscrizione alle notifiche la registra qualsiasi autenticato", async () => {
const endpoint = `https://esempio.test/${PREFISSO}-push`;
const res = await rest("push_subscriptions", tokenGiocatore, {
method: "POST",
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
body: JSON.stringify({
giocatore_id: "g1",
endpoint,
p256dh: "chiave-di-prova",
auth: "auth-di-prova",
}),
});
assert.equal(await righeToccate(res), 1, "il dispositivo si registra da solo");
const via = await rest(
`push_subscriptions?endpoint=eq.${encodeURIComponent(endpoint)}`,
tokenGiocatore,
{
method: "DELETE",
headers: { Prefer: "return=representation" },
},
);
assert.equal(await righeToccate(via), 1, "e si cancella quando le notifiche si spengono");
});
} finally {
// Ripristino: prima le righe create (la service role passa sopra alle policy di M11),
// poi gli slot (serve il JWT admin, il trigger rifiuta la service key), il telefono e
@@ -414,6 +491,11 @@ if (!locale) {
for (const tabella of ["pagelle_voti", "mvp_voti", "badge_social_voti"]) {
await rest(`${tabella}?match_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
}
for (const tabella of ["scout_sessioni", "scout_live"]) {
await rest(`${tabella}?evento_id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
}
await rest(`scout_partite?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
await rest(`push_subscriptions?endpoint=like.*${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
await rest(`eventi_app?id=like.${PREFISSO}*`, SERVIZIO, { method: "DELETE" });
for (const id of ["g1", "g2"]) {