Nella tab Notifiche della dashboard admin è ora possibile mostrare anche i giocatori senza notifiche attive e inviare un messaggio libero a tutta la squadra o a un singolo giocatore, tramite un nuovo endpoint /api/public/notifica-personalizzata che riusa lo stesso pattern di invio delle altre route push admin. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
281 lines
12 KiB
TypeScript
281 lines
12 KiB
TypeScript
/**
|
|
* Integrazione delle route server: `bun test/integration/api.test.ts`.
|
|
* Avvia il server di sviluppo (o usa BASE_URL) e chiama gli endpoint veri.
|
|
* Nessun test scrive sul database: solo letture e validazioni.
|
|
*/
|
|
import assert from "node:assert/strict";
|
|
import { giocatori } from "@/lib/crapp-data";
|
|
import {
|
|
isNostraSquadra,
|
|
partiteGiocate,
|
|
type DatiCsi,
|
|
type DettaglioPartitaCsi,
|
|
} from "@/lib/csi-core";
|
|
import { obiettiviSquadra } from "@/lib/obiettivi";
|
|
import { avviaServer, haSupabase, json } from "../helpers/server";
|
|
import { prova, riepilogo, salta } from "../helpers/prova";
|
|
|
|
const server = await avviaServer();
|
|
const url = (p: string) => `${server.baseUrl}${p}`;
|
|
const postJson = (p: string, body: unknown) =>
|
|
fetch(url(p), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
console.log(`integrazione API su ${server.baseUrl}`);
|
|
|
|
try {
|
|
// --- GET /api/public/csi ---------------------------------------------------
|
|
let csi: DatiCsi | undefined;
|
|
|
|
// Il portale CSI (livescore.csibologna.it) è a volte in manutenzione lato loro (visto un
|
|
// errore SQL in chiaro al posto del JSON, non un problema nostro): sonda una volta sola
|
|
// prima di questi 5 test, così un "non raggiungibile" temporaneo si vede come saltato,
|
|
// non come fallito — la suite resta verde senza nascondere il motivo, e senza cancellare
|
|
// la copertura per quando il portale torna su.
|
|
const sondaCsi = await fetch(url("/api/public/csi"));
|
|
const csiRaggiungibile = sondaCsi.ok;
|
|
if (!csiRaggiungibile) {
|
|
console.log(
|
|
` · CSI non raggiungibile (${sondaCsi.status}): portale probabilmente in manutenzione`,
|
|
);
|
|
}
|
|
const provaCsi = (nome: string, fn: () => Promise<void> | void) =>
|
|
csiRaggiungibile
|
|
? prova(nome, fn)
|
|
: Promise.resolve(
|
|
salta(nome, "portale CSI non raggiungibile (probabile manutenzione lato loro)"),
|
|
);
|
|
|
|
await provaCsi("GET /api/public/csi restituisce classifica e partite", async () => {
|
|
const res = await fetch(url("/api/public/csi"));
|
|
assert.equal(res.status, 200);
|
|
assert.match(res.headers.get("content-type") ?? "", /application\/json/);
|
|
csi = (await json(res)) as DatiCsi;
|
|
assert.ok(Array.isArray(csi.classifica) && csi.classifica.length > 0);
|
|
assert.ok(Array.isArray(csi.classificaCoppa));
|
|
assert.ok(Array.isArray(csi.partite) && csi.partite.length > 0);
|
|
assert.equal(csi.girone, "Girone B");
|
|
assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido");
|
|
});
|
|
|
|
await provaCsi("la classifica contiene la nostra squadra con dati coerenti", () => {
|
|
const noi = csi?.classifica.find((r) => isNostraSquadra(r.squadra));
|
|
assert.ok(noi, "C.R.A.P. Volley presente in classifica");
|
|
assert.equal(noi.vinte + noi.perse, noi.giocate, "vinte + perse = giocate");
|
|
assert.ok(noi.punti >= 0 && noi.pos > 0);
|
|
const posizioni = csi!.classifica.map((r) => r.pos);
|
|
assert.deepEqual(
|
|
posizioni,
|
|
[...posizioni].sort((a, b) => a - b),
|
|
"ordinata per posizione",
|
|
);
|
|
});
|
|
|
|
await provaCsi("la classifica di Coppa, se presente, contiene la nostra squadra", () => {
|
|
if ((csi?.classificaCoppa.length ?? 0) === 0) return; // fase a gironi finita, pagina cambiata
|
|
const noi = csi?.classificaCoppa.find((r) => isNostraSquadra(r.squadra));
|
|
assert.ok(noi, "C.R.A.P. Volley presente nel girone di Coppa");
|
|
});
|
|
|
|
await provaCsi("ogni partita è coerente con il formato dell'app", () => {
|
|
for (const p of csi?.partite ?? []) {
|
|
assert.match(p.data, /^\d{4}-\d{2}-\d{2}$/, `${p.id}: data ISO`);
|
|
assert.ok(p.avversario.length > 0, `${p.id}: avversario valorizzato`);
|
|
assert.ok(!isNostraSquadra(p.avversario), `${p.id}: non giochiamo contro noi stessi`);
|
|
const giocata = p.setNostri !== null && p.setLoro !== null;
|
|
if (giocata) assert.equal(Math.max(p.setNostri!, p.setLoro!), 3, `${p.id}: si vince a 3 set`);
|
|
else assert.equal(p.parziali.length, 0, `${p.id}: gara futura senza parziali`);
|
|
}
|
|
});
|
|
|
|
await provaCsi(
|
|
"le vittorie in campionato lette dal CSI reale alimentano o3/o4/o5 in modo coerente",
|
|
() => {
|
|
// Stessa logica di useObiettivi() in src/lib/rosa.ts: partite giocate e vinte.
|
|
const vittorieReali = partiteGiocate(csi?.partite ?? []).filter(
|
|
(p) => (p.setNostri ?? 0) > (p.setLoro ?? 0),
|
|
).length;
|
|
|
|
const obiettivi = obiettiviSquadra(giocatori, {
|
|
eventi: [],
|
|
presenze: {},
|
|
pagelle: [],
|
|
vittorie: vittorieReali,
|
|
});
|
|
const o3 = obiettivi.find((o) => o.id === "o3")!;
|
|
const o4 = obiettivi.find((o) => o.id === "o4")!;
|
|
const o5 = obiettivi.find((o) => o.id === "o5")!;
|
|
|
|
assert.deepEqual([o3.target, o4.target, o5.target], [1, 5, 10], "target fissi 1/5/10");
|
|
assert.equal(o3.valore, Math.min(vittorieReali, 1), "prima vittoria, cappata a 1");
|
|
assert.equal(o4.valore, Math.min(vittorieReali, 5), "5 vittorie, cappata a 5");
|
|
assert.equal(o5.valore, Math.min(vittorieReali, 10), "10 vittorie, cappata a 10");
|
|
assert.ok(o3.valore <= o4.valore && o4.valore <= o5.valore, "progressione o3 ≤ o4 ≤ o5");
|
|
},
|
|
);
|
|
|
|
await provaCsi("la seconda chiamata arriva dalla cache del server", async () => {
|
|
const t0 = Date.now();
|
|
const secondo = (await json(await fetch(url("/api/public/csi")))) as DatiCsi;
|
|
assert.equal(secondo.aggiornato, csi?.aggiornato, "stesso timestamp: nessuna nuova fetch");
|
|
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
|
|
});
|
|
|
|
await provaCsi(
|
|
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti",
|
|
async () => {
|
|
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
|
|
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
|
|
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
|
assert.equal(res.status, 200);
|
|
const dettaglio = (await json(res)) as DettaglioPartitaCsi;
|
|
// formazioni/precedenti possono essere null se il portale non li pubblica per questa
|
|
// gara (es. referto non compilato): qui si verifica solo che, quando presenti, siano
|
|
// coerenti — non che ci siano sempre, vedi parseFormazioni()/parsePrecedenti().
|
|
if (dettaglio.formazioni) {
|
|
assert.ok(isNostraSquadra(dettaglio.formazioni.noi.squadra));
|
|
assert.ok(dettaglio.formazioni.noi.titolari.length > 0, "almeno un titolare");
|
|
}
|
|
if (dettaglio.precedenti) {
|
|
assert.ok(dettaglio.precedenti.totale >= 0);
|
|
}
|
|
|
|
const t0 = Date.now();
|
|
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
|
|
assert.equal(secondo.status, 200);
|
|
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
|
|
},
|
|
);
|
|
|
|
await provaCsi(
|
|
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
|
|
async () => {
|
|
// Un match_id inventato fa rispondere 500 a match-stats.php lato CSI (verificato a
|
|
// mano con curl): non è "gara senza formazioni ancora pubblicate" (quello risponde
|
|
// 200 con markup vuoto, gestito da parseFormazioni()/parsePrecedenti() → null), è un
|
|
// vero errore a monte. La route lo tratta come /api/public/csi tratta un CSI giù:
|
|
// 503 pulito, non un crash o un 200 con dati inventati.
|
|
const res = await fetch(url("/api/public/csi-partita/999999999"));
|
|
assert.equal(res.status, 503);
|
|
},
|
|
);
|
|
|
|
// --- GET /api/public/push-config -------------------------------------------
|
|
await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => {
|
|
const res = await fetch(url("/api/public/push-config"));
|
|
assert.equal(res.status, 200);
|
|
const dati = (await json(res)) as Record<string, unknown>;
|
|
assert.deepEqual(Object.keys(dati), ["publicKey"], "nessun altro dato esposto");
|
|
assert.ok(dati["publicKey"] === null || typeof dati["publicKey"] === "string");
|
|
});
|
|
|
|
// --- validazione degli input (nessuna scrittura) ---------------------------
|
|
await prova("push-subscribe rifiuta i payload non validi", async () => {
|
|
assert.equal((await postJson("/api/public/push-subscribe", {})).status, 400);
|
|
assert.equal(
|
|
(
|
|
await postJson("/api/public/push-subscribe", {
|
|
endpoint: "non-un-url",
|
|
giocatoreId: "g1",
|
|
p256dh: "x",
|
|
auth: "y",
|
|
})
|
|
).status,
|
|
400,
|
|
"endpoint deve essere un URL",
|
|
);
|
|
assert.equal(
|
|
(
|
|
await postJson("/api/public/push-subscribe", {
|
|
endpoint: `https://push.example/${"x".repeat(1100)}`,
|
|
giocatoreId: "g1",
|
|
p256dh: "x",
|
|
auth: "y",
|
|
})
|
|
).status,
|
|
400,
|
|
"endpoint troppo lungo",
|
|
);
|
|
const res = await fetch(url("/api/public/push-subscribe"), {
|
|
method: "DELETE",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({}),
|
|
});
|
|
assert.equal(res.status, 400, "anche la cancellazione valida l'input");
|
|
});
|
|
|
|
// DD-026: il testo è nel payload; la vecchia route non deve più essere esposta.
|
|
await prova("push-messaggio è stata rimossa", async () => {
|
|
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 404);
|
|
});
|
|
|
|
// --- le route che mandano notifiche a tutti (DD-024) ------------------------
|
|
// 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 () => {
|
|
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`,
|
|
);
|
|
}
|
|
});
|
|
|
|
await prova("l'invio di un messaggio personalizzato chiede le credenziali", async () => {
|
|
assert.equal(
|
|
(await postJson("/api/public/notifica-personalizzata", { messaggio: "prova" })).status,
|
|
401,
|
|
);
|
|
});
|
|
|
|
await prova("un token malformato non passa", async () => {
|
|
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", authorization: "Bearer non-un-jwt" },
|
|
body: JSON.stringify({ eventoId: "non-esiste" }),
|
|
});
|
|
assert.equal(res.status, 401, "tre segmenti separati da punto, o niente");
|
|
});
|
|
|
|
await prova("uno schema diverso da Bearer non passa", async () => {
|
|
const res = await fetch(url("/api/public/sollecita-presenze"), {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", authorization: "Basic aGVsbG86d29ybGQ=" },
|
|
body: JSON.stringify({ eventoId: "non-esiste" }),
|
|
});
|
|
assert.equal(res.status, 401);
|
|
});
|
|
|
|
// --- endpoint che leggono dal database -------------------------------------
|
|
if (haSupabase()) {
|
|
// La validazione dell'input di sollecita-presenze (400 sul corpo vuoto, 404
|
|
// sull'evento inesistente) ora sta dietro al controllo di accesso: serve un
|
|
// token di amministratore, che questo file non ha. La copre
|
|
// `permessi-route.test.ts` sullo stack locale.
|
|
} else {
|
|
salta("endpoint con database", "SUPABASE_URL/SERVICE_ROLE_KEY non configurate");
|
|
}
|
|
|
|
// --- metodi non previsti ---------------------------------------------------
|
|
await prova("un GET su un endpoint POST non esegue l'handler", async () => {
|
|
const res = await fetch(url("/api/public/sollecita-presenze"));
|
|
// TanStack Start serve la pagina invece del 405: l'importante è che il
|
|
// job non parta e che nessuna notifica venga inviata.
|
|
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
|
assert.doesNotMatch(await res.text(), /"inviate"/);
|
|
});
|
|
|
|
riepilogo("integrazione API");
|
|
} finally {
|
|
server.stop();
|
|
}
|