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
+114
View File
@@ -0,0 +1,114 @@
/**
* Percorsi end-to-end sull'app servita davvero: `bun test/e2e/percorsi.test.ts`.
* Verifica il giro completo route → server → dati → risposta HTTP.
*
* L'app renderizza i contenuti dopo l'idratazione: il server invia il guscio
* (titolo, meta, splash) e il resto arriva nel browser. Qui si verifica quindi
* tutto ciò che è osservabile senza browser; per i contenuti renderizzati
* servirebbe un driver tipo Playwright.
*/
import assert from "node:assert/strict";
import { isNostraSquadra, type DatiCsi } from "@/lib/csi-core";
import { avviaServer, json } from "../helpers/server";
import { prova, riepilogo } from "../helpers/prova";
const server = await avviaServer();
const url = (p: string) => `${server.baseUrl}${p}`;
async function pagina(percorso: string): Promise<string> {
const res = await fetch(url(percorso));
assert.equal(res.status, 200, `${percorso} deve rispondere 200`);
assert.match(res.headers.get("content-type") ?? "", /text\/html/, `${percorso} è una pagina`);
const html = await res.text();
assert.doesNotMatch(html, /Errore imprevisto|h3 swallowed/i, `${percorso}: nessun errore server`);
return html;
}
const titolo = (html: string) => /<title>(.*?)<\/title>/.exec(html)?.[1] ?? "";
console.log(`percorsi end-to-end su ${server.baseUrl}`);
try {
// --- 1. Primo avvio: l'app si carica --------------------------------------
await prova("la home serve il guscio dell'app con il proprio titolo", async () => {
const html = await pagina("/");
assert.match(titolo(html), /CrAPP/, "titolo della home");
assert.match(html, /icon-192\.png/, "splash di caricamento");
assert.match(html, /manifest\.webmanifest/, "manifest collegato: installabile come PWA");
assert.match(html, /<meta name="description"/, "descrizione per la condivisione");
});
// --- 2. Ogni schermata ha la sua identità ----------------------------------
await prova("ogni schermata risponde con il proprio titolo", async () => {
const attesi: Array<[string, RegExp]> = [
["/benvenuto", /Benvenuto/],
["/squadra", /Squadra/],
["/classifica", /Classifica/],
["/calendario", /Calendario|CrAPP/],
["/profilo", /Profilo|CrAPP/],
["/eventi", /Eventi|CrAPP/],
["/scout", /Scout|CrAPP/],
];
for (const [percorso, atteso] of attesi) {
assert.match(titolo(await pagina(percorso)), atteso, `${percorso}: titolo corretto`);
}
});
// --- 3. Campionato: dal portale CSI fino alla pagina -----------------------
await prova("i dati ufficiali CSI arrivano fino alla pagina Campionato", async () => {
const res = await fetch(url("/api/public/csi"));
assert.equal(res.status, 200, "l'endpoint che alimenta la pagina risponde");
const csi = (await json(res)) as DatiCsi;
const noi = csi.classifica.find((r) => isNostraSquadra(r.squadra));
assert.ok(noi, "la nostra squadra arriva dal portale");
assert.ok(
csi.partite.some((p) => p.setNostri !== null),
"ci sono risultati giocati",
);
const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/);
assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio");
});
// --- 4. Dettaglio di un evento --------------------------------------------
await prova("le pagine di dettaglio reggono un id inesistente", async () => {
for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) {
const res = await fetch(url(percorso));
assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`);
}
});
// --- 5. Installazione come PWA e notifiche push ---------------------------
await prova("i file necessari alla PWA sono serviti", async () => {
const manifest = await fetch(url("/manifest.webmanifest"));
assert.equal(manifest.status, 200);
const dati = (await json(manifest)) as { name?: string; icons?: unknown[]; start_url?: string };
assert.ok(dati.name?.includes("CrAPP"));
assert.equal(dati.start_url, "/");
assert.ok((dati.icons ?? []).length >= 2, "icone per l'installazione");
for (const file of ["/push-sw.js", "/icon-192.png", "/icon-512.png", "/robots.txt"]) {
assert.equal((await fetch(url(file))).status, 200, `${file} raggiungibile`);
}
});
await prova("il service worker e la sua configurazione si parlano", async () => {
const sw = await (await fetch(url("/push-sw.js"))).text();
assert.match(sw, /\/api\/public\/push-messaggio/, "endpoint invocato dal worker");
assert.match(sw, /showNotification/, "il worker mostra la notifica");
const config = await fetch(url("/api/public/push-config"));
assert.equal(config.status, 200, "la chiave pubblica VAPID è interrogabile");
});
// --- 6. Percorsi inesistenti ----------------------------------------------
await prova("una rotta inesistente risponde 404 senza rompere l'app", async () => {
const res = await fetch(url("/pagina-che-non-esiste"));
assert.equal(res.status, 404);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
});
riepilogo("percorsi end-to-end");
} finally {
server.stop();
}