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:
@@ -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);
|
||||
}
|
||||
@@ -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)}`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user