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