Add a test suite for the domain logic and the server routes.

The project had no automated verification at all, so the "Test" step in
the AGENTS.md workflow rested entirely on clicking through the app.

The suite runs on bun with node:assert and adds no dependency: every file
is a script that exits non-zero when a check fails, and test/run.ts runs
each one in its own process. Unit tests cover the rules that decide what
players see (badges, streaks, ball duty rotation, ratings, MVP ties,
scouting totals, goals, notifications, CSI parsing); integration and
end-to-end tests drive the real dev server. Nothing writes to the
database, so both can be pointed at a live environment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 13:07:41 +02:00
co-authored by Claude Opus 5
parent fee3d0b55a
commit af563603f9
22 changed files with 1656 additions and 3 deletions
+153
View File
@@ -0,0 +1,153 @@
/**
* 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 { isNostraSquadra, type DatiCsi } from "@/lib/csi-core";
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;
await prova("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.partite) && csi.partite.length > 0);
assert.equal(csi.girone, "Girone B");
assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido");
});
await prova("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 prova("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 prova("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");
});
// --- 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");
});
await prova("push-messaggio e sollecita-presenze rifiutano i payload non validi", async () => {
assert.equal((await postJson("/api/public/push-messaggio", {})).status, 400);
assert.equal((await postJson("/api/public/sollecita-presenze", {})).status, 400);
assert.equal(
(await postJson("/api/public/sollecita-presenze", { eventoId: "" })).status,
400,
"eventoId vuoto non è valido",
);
});
// --- endpoint che leggono dal database -------------------------------------
if (haSupabase()) {
await prova("push-messaggio risponde con il messaggio di default", async () => {
const res = await postJson("/api/public/push-messaggio", {
endpoint: `https://push.example/test-${Date.now()}`,
});
assert.equal(res.status, 200);
const dati = (await json(res)) as { title?: string; body?: string };
assert.ok(dati.title && dati.body, "un endpoint sconosciuto riceve comunque un testo");
});
await prova("sollecita-presenze su un evento inesistente risponde 404", async () => {
const res = await postJson("/api/public/sollecita-presenze", { eventoId: "non-esiste" });
assert.equal(res.status, 404);
});
} 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();
}