/** * 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) => 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; 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("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(); }