Rilettura completa della documentazione confrontata con il codice. Dove la doc diceva il falso l'ho corretta; dove aveva ragione lei ho corretto il codice. Autovoto (la doc aveva ragione) - migration m12_niente_autovoto: vincoli mvp_no_autovoto e badge_social_no_autovoto, gli stessi che pagelle_voti ha dalla v1.0. Le righe che li violano vengono cancellate prima dell'ALTER, altrimenti fallisce; in locale non ce n'erano. M11 garantisce solo che il voto sia firmato con il proprio votante_id, non che il votato sia un altro: eleggersi MVP restava a un POST di distanza. - VotazioneMvp non mostra più il votante nell'elenco, come già faceva VotoSocial. Test che guardavano la colonna sbagliata - scritture.test.ts verificava che aggiornato_il si muovesse, chiamandolo "quello che alimenta la serie di conferme". È l'opposto: la serie usa risposto_il, che il trigger di M9 deve tenere fermo. Ora il test prova a riscriverlo e controlla che il database abbia tenuto la prima risposta; prima passava anche senza trigger. - destinatariSollecito() esce dalla route sollecita-presenze e diventa una funzione pura in presenze.ts, con i suoi test — stesso trattamento di avvisiPalloniEvento. Tre riletture in meno - giocatori-squadra, scout-store e avatar-store usavano invalidateQueries dove il dato scritto era già noto: ora setQueryData, come il resto dell'app. Resta scout-live, dove il lock può averlo vinto un altro dispositivo. Documentazione riallineata - presenze.md, badge.md, mvp.md: i limiti su RLS aperta e route non autenticata erano superati da M11 e DD-024; - serie-presenze.md: il filtro è e.data < oggi, non <=, e l'evento di oggi non conta (conterebbe come assenza per tutti); aggiunta la tabella risposto_il/aggiornato_il; - ARCHITECTURE.md ed EFFICIENZA_CLOUD.md: una sola eccezione a setQueryData; - DATABASE.md: i vincoli delle tre tabelle di voto; - PROJECT_STATE.md: fermo a M9, ora arriva a M12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
/**
|
|
* Le scritture dell'app contro il database locale: `bun test/integration/scritture.test.ts`.
|
|
*
|
|
* Ogni salvataggio di CrAPP è un `upsert` con un `onConflict` scritto a mano nei hook di
|
|
* `src/lib/`. Se quella chiave non corrisponde al vincolo UNIQUE della tabella non arriva
|
|
* nessun errore: il database sovrascrive la riga sbagliata, e il bug si vede solo settimane
|
|
* dopo in una media che non torna. Qui si ripetono le stesse chiamate dei hook e si conta
|
|
* cosa resta nella tabella.
|
|
*
|
|
* Gira solo sullo stack locale (`npx supabase start`) e cancella le proprie righe alla fine:
|
|
* usa id con il prefisso `test-scritture`, che nessun dato vero può avere.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { statoLocale } from "../helpers/locale";
|
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
|
|
|
const locale = statoLocale();
|
|
|
|
if (!locale) {
|
|
salta("scritture sul database", "stack locale non attivo (npx supabase start)");
|
|
riepilogo("scritture");
|
|
} else {
|
|
const { url: URL_BASE, servizio: SERVIZIO } = locale;
|
|
console.log(`scritture su ${URL_BASE}`);
|
|
|
|
const PREFISSO = "test-scritture";
|
|
const TABELLE = [
|
|
"pagelle_voti",
|
|
"mvp_voti",
|
|
"cacche_partita",
|
|
"badge_social_voti",
|
|
"turni_palloni",
|
|
"risposte_presenze",
|
|
"scout_live",
|
|
] as const;
|
|
|
|
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 ?? {}),
|
|
},
|
|
});
|
|
|
|
/**
|
|
* Lo stesso `upsert` che fa supabase-js: `onConflict` diventa `on_conflict` nella query
|
|
* e `resolution=merge-duplicates` nell'header.
|
|
*/
|
|
async function upsert(tabella: string, onConflict: string, riga: Record<string, unknown>) {
|
|
const res = await rest(`${tabella}?on_conflict=${onConflict}`, {
|
|
method: "POST",
|
|
headers: { Prefer: "resolution=merge-duplicates,return=representation" },
|
|
body: JSON.stringify(riga),
|
|
});
|
|
if (!res.ok) throw new Error(`upsert su ${tabella}: ${res.status} ${await res.text()}`);
|
|
return (await res.json()) as Array<Record<string, unknown>>;
|
|
}
|
|
|
|
const leggi = async (tabella: string, filtro: string) =>
|
|
(await (await rest(`${tabella}?${filtro}`)).json()) as Array<Record<string, unknown>>;
|
|
|
|
try {
|
|
// --- pagelle: la chiave più facile da sbagliare -------------------------------
|
|
// Un votante dà un voto a ogni compagno nella stessa partita: se il conflitto
|
|
// fosse su (match, votante) ogni voto cancellerebbe il precedente.
|
|
await prova("le pagelle tengono un voto per ogni votato, non uno per votante", async () => {
|
|
const match = `${PREFISSO}-m1`;
|
|
const chiave = "match_id,votante_id,votato_id";
|
|
await upsert("pagelle_voti", chiave, {
|
|
match_id: match,
|
|
votante_id: "g1",
|
|
votato_id: "g2",
|
|
voto: 6,
|
|
});
|
|
await upsert("pagelle_voti", chiave, {
|
|
match_id: match,
|
|
votante_id: "g1",
|
|
votato_id: "g3",
|
|
voto: 8,
|
|
});
|
|
const righe = await leggi("pagelle_voti", `match_id=eq.${match}&select=votato_id,voto`);
|
|
assert.equal(righe.length, 2, "due votati, due righe");
|
|
|
|
await upsert("pagelle_voti", chiave, {
|
|
match_id: match,
|
|
votante_id: "g1",
|
|
votato_id: "g2",
|
|
voto: 9,
|
|
});
|
|
const dopo = await leggi("pagelle_voti", `match_id=eq.${match}&votato_id=eq.g2&select=voto`);
|
|
assert.equal(dopo.length, 1, "cambiare voto non crea una riga nuova");
|
|
assert.equal(dopo[0]?.["voto"], 9, "il voto è quello aggiornato");
|
|
});
|
|
|
|
await prova("le pagelle rifiutano l'autovoto e i voti fuori scala", async () => {
|
|
const match = `${PREFISSO}-m2`;
|
|
const chiave = "match_id,votante_id,votato_id";
|
|
await assert.rejects(
|
|
() =>
|
|
upsert("pagelle_voti", chiave, {
|
|
match_id: match,
|
|
votante_id: "g1",
|
|
votato_id: "g1",
|
|
voto: 10,
|
|
}),
|
|
"nessuno si vota da solo (pagelle_no_autovoto)",
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
upsert("pagelle_voti", chiave, {
|
|
match_id: match,
|
|
votante_id: "g1",
|
|
votato_id: "g2",
|
|
voto: 11,
|
|
}),
|
|
"il voto sta fra 1 e 10 (pagelle_voto_range)",
|
|
);
|
|
});
|
|
|
|
// --- MVP: qui la regola è l'opposta -------------------------------------------
|
|
await prova("l'MVP e i badge social rifiutano l'autovoto", async () => {
|
|
// M12: `pagelle_no_autovoto` esisteva dalla v1.0, queste due tabelle no. Il vincolo sta
|
|
// a database perché l'interfaccia non è l'unica strada per scrivere una riga.
|
|
await assert.rejects(
|
|
() =>
|
|
upsert("mvp_voti", "match_id,votante_id", {
|
|
match_id: `${PREFISSO}-m3`,
|
|
votante_id: "g1",
|
|
votato_id: "g1",
|
|
votato_nome: "Uno",
|
|
}),
|
|
"nessuno si elegge MVP da solo (mvp_no_autovoto)",
|
|
);
|
|
await assert.rejects(
|
|
() =>
|
|
upsert("badge_social_voti", "match_id,categoria,votante_id", {
|
|
match_id: `${PREFISSO}-m3`,
|
|
categoria: "cuore",
|
|
votante_id: "g1",
|
|
votato_id: "g1",
|
|
votato_nome: "Uno",
|
|
}),
|
|
"né si assegna un badge social (badge_social_no_autovoto)",
|
|
);
|
|
});
|
|
|
|
await prova("l'MVP tiene un solo voto per votante e partita", async () => {
|
|
const match = `${PREFISSO}-m3`;
|
|
const chiave = "match_id,votante_id";
|
|
await upsert("mvp_voti", chiave, {
|
|
match_id: match,
|
|
votante_id: "g1",
|
|
votato_id: "g2",
|
|
votato_nome: "Due",
|
|
});
|
|
await upsert("mvp_voti", chiave, {
|
|
match_id: match,
|
|
votante_id: "g1",
|
|
votato_id: "g3",
|
|
votato_nome: "Tre",
|
|
});
|
|
const righe = await leggi("mvp_voti", `match_id=eq.${match}&select=votato_id`);
|
|
assert.equal(righe.length, 1, "cambiare idea sostituisce il voto, non lo aggiunge");
|
|
assert.equal(righe[0]?.["votato_id"], "g3", "vale l'ultimo votato");
|
|
});
|
|
|
|
// --- badge social: una preferenza per categoria --------------------------------
|
|
await prova("i badge social tengono un voto per categoria", async () => {
|
|
const match = `${PREFISSO}-m4`;
|
|
const chiave = "match_id,categoria,votante_id";
|
|
await upsert("badge_social_voti", chiave, {
|
|
match_id: match,
|
|
categoria: "sorriso",
|
|
votante_id: "g1",
|
|
votato_id: "g2",
|
|
votato_nome: "Due",
|
|
});
|
|
await upsert("badge_social_voti", chiave, {
|
|
match_id: match,
|
|
categoria: "urlo",
|
|
votante_id: "g1",
|
|
votato_id: "g2",
|
|
votato_nome: "Due",
|
|
});
|
|
const righe = await leggi("badge_social_voti", `match_id=eq.${match}&select=categoria`);
|
|
assert.equal(righe.length, 2, "categorie diverse, righe diverse");
|
|
|
|
await upsert("badge_social_voti", chiave, {
|
|
match_id: match,
|
|
categoria: "urlo",
|
|
votante_id: "g1",
|
|
votato_id: "g4",
|
|
votato_nome: "Quattro",
|
|
});
|
|
const urlo = await leggi(
|
|
"badge_social_voti",
|
|
`match_id=eq.${match}&categoria=eq.urlo&select=votato_id`,
|
|
);
|
|
assert.equal(urlo.length, 1, "nella stessa categoria si sostituisce");
|
|
assert.equal(urlo[0]?.["votato_id"], "g4");
|
|
});
|
|
|
|
// --- cacche: una dichiarazione per giocatore e partita --------------------------
|
|
await prova("le cacche tengono una quantità per giocatore e partita", async () => {
|
|
const evento = `${PREFISSO}-e1`;
|
|
const chiave = "evento_id,giocatore_id";
|
|
await upsert("cacche_partita", chiave, {
|
|
evento_id: evento,
|
|
giocatore_id: "g1",
|
|
quantita: 2,
|
|
});
|
|
await upsert("cacche_partita", chiave, {
|
|
evento_id: evento,
|
|
giocatore_id: "g1",
|
|
quantita: 3,
|
|
});
|
|
await upsert("cacche_partita", chiave, {
|
|
evento_id: evento,
|
|
giocatore_id: "g2",
|
|
quantita: 1,
|
|
});
|
|
const righe = await leggi(
|
|
"cacche_partita",
|
|
`evento_id=eq.${evento}&select=giocatore_id,quantita&order=giocatore_id`,
|
|
);
|
|
assert.equal(righe.length, 2, "un giocatore una riga");
|
|
assert.equal(righe[0]?.["quantita"], 3, "la seconda dichiarazione sostituisce la prima");
|
|
});
|
|
|
|
// --- turni palloni: un solo incaricato per evento ------------------------------
|
|
await prova("il turno palloni resta uno per evento", async () => {
|
|
const evento = `${PREFISSO}-e2`;
|
|
await upsert("turni_palloni", "evento_id", {
|
|
evento_id: evento,
|
|
giocatore_id: "g1",
|
|
aggiornato_da: "g1",
|
|
});
|
|
await upsert("turni_palloni", "evento_id", {
|
|
evento_id: evento,
|
|
giocatore_id: "g5",
|
|
aggiornato_da: "g9",
|
|
});
|
|
const righe = await leggi(
|
|
"turni_palloni",
|
|
`evento_id=eq.${evento}&select=giocatore_id,aggiornato_da`,
|
|
);
|
|
assert.equal(righe.length, 1, "riassegnare non aggiunge un secondo incaricato");
|
|
assert.equal(righe[0]?.["giocatore_id"], "g5", "vale l'ultima assegnazione");
|
|
assert.equal(righe[0]?.["aggiornato_da"], "g9", "e si sa chi l'ha fatta");
|
|
});
|
|
|
|
// --- presenze: la risposta si cambia, il cronometro no --------------------------
|
|
// Due colonne che sembrano la stessa cosa e non lo sono: `risposto_il` è la PRIMA
|
|
// risposta e alimenta la serie "Conferme 24h", `aggiornato_il` è l'ultima modifica e non
|
|
// alimenta niente. Il trigger `risposte_presenze_risposto_il_immutabile` (M9) tiene ferma
|
|
// la prima: senza, chi risponde subito e ci ripensa una settimana dopo risulterebbe lento.
|
|
await prova(
|
|
"la risposta di presenza si aggiorna senza far ripartire il cronometro",
|
|
async () => {
|
|
const evento = `${PREFISSO}-e3`;
|
|
const chiave = "evento_id,giocatore_id";
|
|
const prima = await upsert("risposte_presenze", chiave, {
|
|
evento_id: evento,
|
|
giocatore_id: "g1",
|
|
stato: "presente",
|
|
aggiornato_il: new Date("2026-01-01T18:00:00Z").toISOString(),
|
|
});
|
|
await upsert("risposte_presenze", chiave, {
|
|
evento_id: evento,
|
|
giocatore_id: "g1",
|
|
stato: "assente",
|
|
aggiornato_il: new Date("2026-01-01T19:00:00Z").toISOString(),
|
|
// Il ripensamento prova anche a riscrivere l'istante della prima risposta: è
|
|
// esattamente la mossa che il trigger deve annullare.
|
|
risposto_il: new Date("2026-01-08T19:00:00Z").toISOString(),
|
|
});
|
|
const righe = await leggi(
|
|
"risposte_presenze",
|
|
`evento_id=eq.${evento}&select=stato,aggiornato_il,risposto_il`,
|
|
);
|
|
assert.equal(righe.length, 1, "una risposta per giocatore");
|
|
assert.equal(righe[0]?.["stato"], "assente", "vale l'ultima risposta");
|
|
assert.notEqual(
|
|
righe[0]?.["aggiornato_il"],
|
|
prima[0]?.["aggiornato_il"],
|
|
"l'ultima modifica si muove",
|
|
);
|
|
assert.equal(
|
|
righe[0]?.["risposto_il"],
|
|
prima[0]?.["risposto_il"],
|
|
"la prima risposta resta quella: il trigger di M9 la congela",
|
|
);
|
|
},
|
|
);
|
|
|
|
// --- scout live: lo stato viene sostituito, non fuso ----------------------------
|
|
await prova("lo scout live sostituisce lo stato invece di fonderlo", async () => {
|
|
const evento = `${PREFISSO}-e4`;
|
|
await upsert("scout_live", "evento_id", {
|
|
evento_id: evento,
|
|
stato: { set: 1, punti: 10 },
|
|
});
|
|
await upsert("scout_live", "evento_id", { evento_id: evento, stato: { set: 2 } });
|
|
const righe = await leggi("scout_live", `evento_id=eq.${evento}&select=stato`);
|
|
assert.equal(righe.length, 1);
|
|
assert.deepEqual(
|
|
righe[0]?.["stato"],
|
|
{ set: 2 },
|
|
"il jsonb viene rimpiazzato: i campi del set precedente non restano appesi",
|
|
);
|
|
});
|
|
} finally {
|
|
for (const tabella of TABELLE) {
|
|
const colonna = tabella.endsWith("_voti") ? "match_id" : "evento_id";
|
|
await rest(`${tabella}?${colonna}=like.${PREFISSO}*`, { method: "DELETE" });
|
|
}
|
|
riepilogo("scritture");
|
|
}
|
|
}
|