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
+32
View File
@@ -0,0 +1,32 @@
/** Mini-harness condiviso: nessuna dipendenza, solo conteggio e uscita non-zero. */
const falliti: string[] = [];
let eseguiti = 0;
let saltati = 0;
export async function prova(nome: string, fn: () => Promise<void> | void) {
eseguiti += 1;
try {
await fn();
console.log(`${nome}`);
} catch (errore) {
falliti.push(nome);
console.error(`${nome}\n ${(errore as Error).message.split("\n")[0]}`);
}
}
export function salta(nome: string, motivo: string) {
saltati += 1;
console.log(` · ${nome} (saltato: ${motivo})`);
}
/** Da chiamare a fine file: stampa il riepilogo e imposta il codice di uscita. */
export function riepilogo(gruppo: string) {
const passati = eseguiti - falliti.length;
console.log(
`${gruppo}: ${passati}/${eseguiti} ok${saltati ? `, ${saltati} saltati` : ""}${
falliti.length ? `, falliti: ${falliti.join(", ")}` : ""
}`,
);
if (falliti.length) process.exit(1);
}
+111
View File
@@ -0,0 +1,111 @@
import { spawn, type ChildProcess } from "node:child_process";
import { readFileSync } from "node:fs";
/**
* Avvia il server di sviluppo per i test che parlano HTTP.
* Con BASE_URL impostata usa un server già attivo e non ne avvia uno nuovo.
*/
export type ServerTest = { baseUrl: string; stop: () => void };
/**
* `.env` contiene solo le variabili `VITE_*`, ma le route server leggono i nomi
* senza prefisso (vedi docs/modules/collegamento-csi.md e client.server.ts):
* qui li deriviamo, così i test girano senza configurazione aggiuntiva.
*/
export function envDaFile(): Record<string, string> {
let testo = "";
try {
testo = readFileSync(new URL("../../.env", import.meta.url), "utf8");
} catch {
return {};
}
const env: Record<string, string> = {};
for (const riga of testo.split("\n")) {
const m = /^\s*([A-Z0-9_]+)\s*=\s*(.*)$/.exec(riga);
if (m?.[1]) env[m[1]] = (m[2] ?? "").trim().replace(/^["']|["']$/g, "");
}
if (!env["SUPABASE_URL"] && env["VITE_SUPABASE_URL"])
env["SUPABASE_URL"] = env["VITE_SUPABASE_URL"];
if (!env["SUPABASE_PUBLISHABLE_KEY"] && env["VITE_SUPABASE_PUBLISHABLE_KEY"]) {
env["SUPABASE_PUBLISHABLE_KEY"] = env["VITE_SUPABASE_PUBLISHABLE_KEY"];
}
return env;
}
export function haSupabase(): boolean {
const env = { ...envDaFile(), ...process.env };
return Boolean(env["SUPABASE_URL"] && env["SUPABASE_SERVICE_ROLE_KEY"]);
}
export async function avviaServer(timeoutMs = 120_000): Promise<ServerTest> {
const esistente = process.env["BASE_URL"];
if (esistente) return { baseUrl: esistente.replace(/\/$/, ""), stop: () => {} };
const processo: ChildProcess = spawn("npm", ["run", "dev"], {
cwd: new URL("../..", import.meta.url).pathname,
env: { ...envDaFile(), ...process.env },
stdio: ["ignore", "pipe", "pipe"],
// Gruppo di processi dedicato: npm avvia vite come figlio e un SIGTERM al
// solo npm lascerebbe il server orfano ad occupare la porta.
detached: true,
});
const stop = () => {
if (processo.killed || processo.pid === undefined) return;
try {
process.kill(-processo.pid, "SIGTERM");
} catch {
processo.kill("SIGTERM");
}
};
const baseUrl = await new Promise<string>((resolve, reject) => {
const scadenza = setTimeout(() => {
stop();
reject(new Error(`Il server non è partito entro ${timeoutMs / 1000}s`));
}, timeoutMs);
let uscita = "";
const cerca = (chunk: Buffer) => {
uscita += chunk.toString();
const url = /http:\/\/localhost:\d+/.exec(uscita)?.[0];
if (url) {
clearTimeout(scadenza);
resolve(url);
}
};
processo.stdout?.on("data", cerca);
processo.stderr?.on("data", cerca);
processo.on("exit", (code) => {
clearTimeout(scadenza);
reject(new Error(`Il server è uscito con codice ${code}:\n${uscita}`));
});
});
await attendiPronto(baseUrl);
return { baseUrl, stop };
}
/** Il server annuncia l'URL prima di saper rispondere: attende la prima risposta. */
async function attendiPronto(baseUrl: string, tentativi = 60) {
for (let i = 0; i < tentativi; i += 1) {
try {
const res = await fetch(baseUrl, { signal: AbortSignal.timeout(5_000) });
if (res.status < 500) return;
} catch {
/* non ancora pronto */
}
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`Il server non risponde su ${baseUrl}`);
}
export async function json(res: Response): Promise<unknown> {
const testo = await res.text();
try {
return JSON.parse(testo);
} catch {
throw new Error(`Risposta non JSON (${res.status}): ${testo.slice(0, 200)}`);
}
}