diff --git a/test/README.md b/test/README.md index 0c1899e..bf5f089 100644 --- a/test/README.md +++ b/test/README.md @@ -17,12 +17,12 @@ non può inquinare gli altri. ## Struttura -| Cartella | Cosa verifica | Serve rete? | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | -| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa | No | -| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato | Sì | -| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | -| `helpers/` | Avvio del server di test e mini-harness condiviso | — | +| Cartella | Cosa verifica | Serve rete? | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- | +| `unit/` | Logica di dominio pura: badge, serie, palloni, pagelle, MVP, cacche, scout, obiettivi, notifiche, parsing CSI, dati della rosa. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar) | No | +| `integration/` | Le route `/api/public/*` sul server di sviluppo: risposte, cache, validazione degli input. Più schema e permessi del Profilo Giocatore (`schema-profili`) contro il database configurato | Sì | +| `e2e/` | Percorsi completi sull'app servita: schermate, dati CSI fino alla pagina, file PWA, 404 | Sì | +| `helpers/` | Avvio del server di test e mini-harness condiviso | — | ## Convenzioni diff --git a/test/unit/avatar-store.test.ts b/test/unit/avatar-store.test.ts new file mode 100644 index 0000000..da4234a --- /dev/null +++ b/test/unit/avatar-store.test.ts @@ -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 è /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"); diff --git a/test/unit/error-capture.test.ts b/test/unit/error-capture.test.ts new file mode 100644 index 0000000..b3662bd --- /dev/null +++ b/test/unit/error-capture.test.ts @@ -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"); diff --git a/test/unit/error-page.test.ts b/test/unit/error-page.test.ts new file mode 100644 index 0000000..66d27e0 --- /dev/null +++ b/test/unit/error-page.test.ts @@ -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(""), "documento HTML valido"); +assert.ok(html.includes(""), "è 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"); diff --git a/test/unit/lovable-error-reporting.test.ts b/test/unit/lovable-error-reporting.test.ts new file mode 100644 index 0000000..3908b0c --- /dev/null +++ b/test/unit/lovable-error-reporting.test.ts @@ -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"); diff --git a/test/unit/profili.test.ts b/test/unit/profili.test.ts new file mode 100644 index 0000000..c24586f --- /dev/null +++ b/test/unit/profili.test.ts @@ -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"); diff --git a/test/unit/push-client.test.ts b/test/unit/push-client.test.ts new file mode 100644 index 0000000..731f379 --- /dev/null +++ b/test/unit/push-client.test.ts @@ -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"); diff --git a/test/unit/scout-stato.test.ts b/test/unit/scout-stato.test.ts new file mode 100644 index 0000000..09f451e --- /dev/null +++ b/test/unit/scout-stato.test.ts @@ -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"); diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts new file mode 100644 index 0000000..5a3217a --- /dev/null +++ b/test/unit/utils.test.ts @@ -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"); diff --git a/test/unit/webpush-server.test.ts b/test/unit/webpush-server.test.ts new file mode 100644 index 0000000..9924dea --- /dev/null +++ b/test/unit/webpush-server.test.ts @@ -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");