Aggiunge unit test per i moduli lib rimasti scoperti

Copre la logica pura isolabile di avatar-store, error-capture, error-page,
lovable-error-reporting, profili (validazione upload), push-client (guardie
senza DOM), scout-stato e webpush.server (firma JWT VAPID con chiavi P-256
generate al volo, fetch intercettato). Alza i file di src/lib coperti da 19
a 28 su 37, senza introdurre nuove dipendenze.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 11:34:09 +02:00
co-authored by Claude Sonnet 5
parent d3b117e470
commit ff45b2379c
10 changed files with 332 additions and 6 deletions
+16
View File
@@ -0,0 +1,16 @@
/**
* Check dell'avatar giocatore: `bun test/unit/avatar-store.test.ts`.
* `urlAvatar` è puro (il bucket è pubblico, nessuna richiesta di rete): qui
* verifichiamo solo la forma dell'URL, non serve un database.
*/
import assert from "node:assert/strict";
import { urlAvatar } from "@/lib/avatar-store";
const url = urlAvatar("g1");
assert.ok(url.includes("avatar-giocatori"), "punta al bucket degli avatar");
assert.ok(url.includes("g1/avatar.jpg"), "il percorso è <id>/avatar.jpg");
assert.equal(urlAvatar("g1"), url, "deterministico: nessuna chiamata di rete coinvolta");
assert.notEqual(urlAvatar("g2"), url, "id diversi -> percorsi diversi");
console.log("avatar-store: ok");
+53
View File
@@ -0,0 +1,53 @@
/**
* Check della cattura errori server-side: `bun test/unit/error-capture.test.ts`.
* Il modulo sovrascrive console.error al caricamento: qui verifichiamo sia
* l'espansione della descrizione sia il recupero one-shot dell'errore originale.
*/
import assert from "node:assert/strict";
import { consumeLastCapturedError, describeError } from "@/lib/error-capture";
// --- describeError: un errore semplice ----------------------------------------
const semplice = new Error("boom");
assert.ok(describeError(semplice).includes("boom"));
assert.ok(describeError(semplice).includes(semplice.stack!.split("\n")[0]!));
// --- describeError: catena di cause, con lo stato HTTP se presente -----------
const interno = new Error("interno");
const esterno = new Error("esterno", { cause: interno });
(esterno as unknown as { status: number }).status = 404;
const descrizioneCatena = describeError(esterno);
assert.ok(descrizioneCatena.includes("(status 404)"), "riporta lo status dell'errore radice");
assert.ok(descrizioneCatena.includes("caused by:"), "etichetta le cause successive alla prima");
assert.ok(descrizioneCatena.includes("interno"), "include il messaggio della causa");
const conStatusCode = new Error("legacy");
(conStatusCode as unknown as { statusCode: number }).statusCode = 500;
assert.ok(describeError(conStatusCode).includes("(status 500)"), "accetta anche statusCode");
// --- describeError: valori che non sono Error ---------------------------------
assert.equal(describeError("solo una stringa"), "solo una stringa");
assert.equal(describeError({ a: 1 }), '{"a":1}', "gli oggetti passano da JSON.stringify");
// --- describeError: la catena si ferma dopo 5 livelli -------------------------
let radice = new Error("livello-0");
for (let i = 1; i <= 6; i++) radice = new Error(`livello-${i}`, { cause: radice });
const descrizioneLunga = describeError(radice);
assert.ok(descrizioneLunga.includes("livello-6"), "il primo livello è sempre incluso");
assert.ok(descrizioneLunga.includes("livello-2"), "quinto livello (indice 4) ancora incluso");
assert.ok(!descrizioneLunga.includes("livello-1"), "oltre 5 livelli la catena si tronca");
// --- describeError: la descrizione non supera 8000 caratteri -----------------
const enorme = new Error("grande");
enorme.stack = "x".repeat(20_000);
assert.equal(describeError(enorme).length, 8_000);
// --- console.error registra l'errore per consumeLastCapturedError ------------
// Il modulo ha già avvolto console.error al caricamento: chiamarlo (non
// sostituirlo, altrimenti si perderebbe il wrapper) registra l'errore.
assert.equal(consumeLastCapturedError(), undefined, "nulla da consumare all'inizio");
const catturato = new Error("da recuperare");
console.error(catturato);
assert.equal(consumeLastCapturedError(), catturato, "restituisce l'istanza originale");
assert.equal(consumeLastCapturedError(), undefined, "una volta consumato non si ripete");
console.log("error-capture: ok");
+21
View File
@@ -0,0 +1,21 @@
/** Check della pagina di errore statica: `bun test/unit/error-page.test.ts`. */
import assert from "node:assert/strict";
import { renderErrorPage } from "@/lib/error-page";
const html = renderErrorPage();
// --- è un documento HTML completo e senza contenuto dinamico ------------------
assert.ok(html.startsWith("<!doctype html>"), "documento HTML valido");
assert.ok(html.includes("<html"), "ha il tag html");
assert.ok(html.includes("</html>"), "è chiuso correttamente");
assert.ok(!html.includes("${"), "nessun placeholder di template rimasto non risolto");
// --- contenuto minimo per l'utente: titolo e via di uscita --------------------
assert.ok(html.includes("This page didn't load"));
assert.ok(html.includes('href="/"'), "offre un link per tornare alla home");
assert.ok(html.includes("location.reload()"), "offre un modo per riprovare");
// --- è deterministica: nessuno stato o input, stesso output ogni volta -------
assert.equal(renderErrorPage(), html);
console.log("error-page: ok");
+47
View File
@@ -0,0 +1,47 @@
/**
* Check della segnalazione errori verso l'editor Lovable: `bun test/unit/lovable-error-reporting.test.ts`.
* Fuori dal browser (nessun `window`) deve essere un no-op silenzioso; qui simuliamo
* anche un `window` minimale per verificare cosa viene inoltrato al hook di reporting.
*/
import assert from "node:assert/strict";
import { reportLovableError } from "@/lib/lovable-error-reporting";
// --- fuori dal browser: nessun window, nessun crash ---------------------------
assert.equal(typeof window, "undefined", "il test gira in ambiente server, senza DOM");
assert.doesNotThrow(() => reportLovableError(new Error("boom")));
// --- con un window simulato: inoltra al hook dell'editor ----------------------
type Riportato = { message: string; stack?: string; filename?: string };
let riportato: Riportato | undefined;
const finto = {
location: { pathname: "/rosa" },
__lovableReportRuntimeError: (payload: Riportato) => {
riportato = payload;
},
} as unknown as Window & typeof globalThis;
(globalThis as { window?: unknown }).window = finto;
try {
reportLovableError(new Error("qualcosa è andato storto"));
assert.ok(riportato, "il payload è stato inoltrato");
assert.equal(riportato!.message, "qualcosa è andato storto");
assert.equal(riportato!.filename, "/rosa");
assert.ok(riportato!.stack, "include lo stack per un Error");
// Una Response non ha un messaggio leggibile: si usa status + url.
riportato = undefined;
reportLovableError(new Response(null, { status: 404 }));
assert.equal(riportato!.message, "Response 404");
assert.equal(riportato!.stack, undefined, "una Response non ha stack");
// Un valore qualunque diventa la sua stringa.
riportato = undefined;
reportLovableError("motivo generico");
assert.equal(riportato!.message, "motivo generico");
} finally {
delete (globalThis as { window?: unknown }).window;
}
assert.equal(typeof window, "undefined", "il window simulato non è rimasto in giro");
console.log("lovable-error-reporting: ok");
+25
View File
@@ -0,0 +1,25 @@
/**
* Check dei vincoli sui file del Profilo Giocatore: `bun test/unit/profili.test.ts`.
* `caricaFile` valida tipo e dimensione prima di qualunque upload: qui verifichiamo
* solo questi due rifiuti, che non richiedono rete né storage.
*/
import assert from "node:assert/strict";
import { caricaFile } from "@/lib/profili";
// --- formato non ammesso: rifiutato prima di toccare lo storage --------------
const testoNonAmmesso = new File(["contenuto"], "documento.txt", { type: "text/plain" });
await assert.rejects(
() => caricaFile("g1", "documento-fronte", testoNonAmmesso),
/Formato non ammesso: usa JPG, PNG, WEBP o PDF\./,
);
// --- file troppo grande: rifiutato anche con un formato valido ---------------
const troppoGrande = new File([new Uint8Array(8 * 1024 * 1024 + 1)], "foto.jpg", {
type: "image/jpeg",
});
await assert.rejects(
() => caricaFile("g1", "foto", troppoGrande),
/File troppo grande: massimo 8 MB\./,
);
console.log("profili: ok");
+27
View File
@@ -0,0 +1,27 @@
/**
* Check delle notifiche push lato client: `bun test/unit/push-client.test.ts`.
* Fuori dal browser (nessun `navigator.serviceWorker`) tutte le funzioni devono
* riconoscere l'assenza di supporto senza tentare rete o API del DOM.
*/
import assert from "node:assert/strict";
import {
attivaNotifiche,
disattivaNotifiche,
pushSupportato,
statoNotifiche,
} from "@/lib/push-client";
// --- pushSupportato: falso in ambiente server (nessun window) -----------------
assert.equal(typeof window, "undefined", "il test gira senza DOM");
assert.equal(pushSupportato(), false);
// --- statoNotifiche: nessun supporto -> nessuna sottoscrizione ----------------
assert.equal(await statoNotifiche(), false);
// --- attivaNotifiche: rifiuta subito, senza chiedere permessi o rete ----------
await assert.rejects(() => attivaNotifiche("g1"), /Notifiche non supportate su questo dispositivo/);
// --- disattivaNotifiche: no-op silenzioso, nessun errore ----------------------
await assert.doesNotReject(() => disattivaNotifiche());
console.log("push-client: ok");
+25
View File
@@ -0,0 +1,25 @@
/** Check dello stato condiviso dello Scout Live: `bun test/unit/scout-stato.test.ts`. */
import assert from "node:assert/strict";
import { SCOUT_STATO_KEY, statoIniziale } from "@/lib/scout-stato";
// --- statoIniziale: parte vuoto, con avversario e campo memorizzati -----------
const stato = statoIniziale("Avversari", true);
assert.deepEqual(stato, { azioni: [], setChiusi: [], avversario: "Avversari", casa: true });
const trasferta = statoIniziale("Altra Squadra", false);
assert.equal(trasferta.casa, false);
assert.deepEqual(trasferta.azioni, [], "ogni chiamata parte da una lista vuota indipendente");
// Le liste non devono essere condivise tra due stati distinti.
stato.azioni.push({ id: "a1", tipo: "attacco", set: 1, ts: 0 });
assert.deepEqual(trasferta.azioni, [], "modificare uno stato non tocca l'altro");
// --- SCOUT_STATO_KEY: chiave stabile per React Query --------------------------
assert.deepEqual(SCOUT_STATO_KEY("evt-1"), ["scout-stato", "evt-1"]);
assert.notDeepEqual(
SCOUT_STATO_KEY("evt-1"),
SCOUT_STATO_KEY("evt-2"),
"chiavi diverse per eventi diversi",
);
console.log("scout-stato: ok");
+19
View File
@@ -0,0 +1,19 @@
/** Check dell'helper di classi CSS: `bun test/unit/utils.test.ts`. */
import assert from "node:assert/strict";
import { cn } from "@/lib/utils";
// --- cn: unisce classi statiche e condizionali --------------------------------
assert.equal(cn("a", "b"), "a b");
const escluso = false as boolean;
assert.equal(cn("a", escluso && "b", null, undefined, "c"), "a c", "scarta i valori falsy");
assert.equal(cn("p-2", ["m-1", "text-sm"]), "p-2 m-1 text-sm", "accetta anche gli array");
// --- cn: tailwind-merge risolve i conflitti tenendo l'ultima classe -----------
assert.equal(cn("p-2", "p-4"), "p-4", "l'ultima utility vince su quella in conflitto");
assert.equal(
cn("text-red-500", "text-lg"),
"text-red-500 text-lg",
"classi non in conflitto convivono",
);
console.log("utils: ok");
+93
View File
@@ -0,0 +1,93 @@
/**
* Check dell'invio push server-side: `bun test/unit/webpush-server.test.ts`.
* `inviaPush` firma un JWT VAPID con Web Crypto e fa una POST all'endpoint del
* browser: qui generiamo una vera coppia di chiavi P-256 e sostituiamo `fetch`
* per intercettare la richiesta, così il test non tocca mai la rete.
*/
import assert from "node:assert/strict";
import { inviaPush } from "@/lib/webpush.server";
function base64UrlEncode(bytes: Uint8Array): string {
let binary = "";
bytes.forEach((b) => (binary += String.fromCharCode(b)));
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
async function chiaviVapidDiProva() {
const coppia = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, [
"sign",
"verify",
]);
const raw = new Uint8Array(await crypto.subtle.exportKey("raw", coppia.publicKey));
const jwk = await crypto.subtle.exportKey("jwk", coppia.privateKey);
return { publicKey: base64UrlEncode(raw), privateKey: jwk.d! };
}
const originali = {
pub: process.env["VAPID_PUBLIC_KEY"],
priv: process.env["VAPID_PRIVATE_KEY"],
subj: process.env["VAPID_SUBJECT"],
};
function ripristinaEnv() {
for (const [chiave, valore] of Object.entries({
VAPID_PUBLIC_KEY: originali.pub,
VAPID_PRIVATE_KEY: originali.priv,
VAPID_SUBJECT: originali.subj,
})) {
if (valore === undefined) delete process.env[chiave];
else process.env[chiave] = valore;
}
}
try {
// --- senza chiavi configurate: rifiuta subito, senza tentare la firma -------
delete process.env["VAPID_PUBLIC_KEY"];
delete process.env["VAPID_PRIVATE_KEY"];
await assert.rejects(() => inviaPush("https://push.example/ep"), /Chiavi VAPID non configurate/);
// --- con le chiavi: firma il JWT e chiama fetch con l'header vapid ----------
const { publicKey, privateKey } = await chiaviVapidDiProva();
process.env["VAPID_PUBLIC_KEY"] = publicKey;
process.env["VAPID_PRIVATE_KEY"] = privateKey;
process.env["VAPID_SUBJECT"] = "mailto:test@example.com";
const fetchOriginale = globalThis.fetch;
let richiesta: { url: string; init: RequestInit } | undefined;
globalThis.fetch = (async (url: string, init: RequestInit) => {
richiesta = { url: String(url), init };
return new Response(null, { status: 201 });
}) as typeof fetch;
try {
const stato = await inviaPush("https://push.example/abc123");
assert.equal(stato, 201, "restituisce lo status della risposta");
assert.ok(richiesta, "ha chiamato fetch");
assert.equal(richiesta!.url, "https://push.example/abc123");
assert.equal(richiesta!.init.method, "POST");
const headers = new Headers(richiesta!.init.headers);
assert.equal(headers.get("TTL"), "86400");
assert.equal(headers.get("Content-Length"), "0");
const auth = headers.get("Authorization")!;
assert.ok(auth.startsWith("vapid t="), "usa lo schema vapid con il token JWT");
assert.ok(auth.includes(`k=${publicKey}`), "include la chiave pubblica");
const jwt = /t=([^,]+),/.exec(auth)![1]!;
const [header, payload] = jwt.split(".");
const decodifica = (parte: string) =>
JSON.parse(Buffer.from(parte.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString());
assert.deepEqual(decodifica(header!), { typ: "JWT", alg: "ES256" });
const claim = decodifica(payload!);
assert.equal(claim.aud, "https://push.example", "l'audience è l'origine dell'endpoint");
assert.equal(claim.sub, "mailto:test@example.com");
assert.ok(claim.exp > Date.now() / 1000, "il token scade nel futuro");
} finally {
globalThis.fetch = fetchOriginale;
}
} finally {
ripristinaEnv();
}
console.log("webpush-server: ok");