From c6437a605ce358802bed1f7a5a476ac5857cb258 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Thu, 24 Sep 2026 12:25:35 +0200 Subject: [PATCH] Aggiunge il controllo dei tipi e copre con un test lo script di backup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - npm run typecheck (tsc --noEmit) entra nella checklist di fine lavoro: bun esegue i test senza controllare i tipi, ed è così che si erano accumulati errori di tipo con la suite verde. - La logica pura del download dello storage passa in storage-core.ts, testata con dati finti (nessuna lettura del .env reale). - Il download dello storage esce con errore se anche un solo file non si scarica, così backup.sh non dichiara completo un backup parziale. Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + AGENTS.md | 3 +- README.md | 1 + backups/README.md | 12 ++++++ backups/download-storage.mjs | 34 +++++++--------- backups/storage-core.ts | 30 ++++++++++++++ docs/ARCHITECTURE.md | 1 + package.json | 1 + test/README.md | 13 +++--- test/unit/backup-storage.test.ts | 70 ++++++++++++++++++++++++++++++++ 10 files changed, 140 insertions(+), 26 deletions(-) create mode 100644 backups/storage-core.ts create mode 100644 test/unit/backup-storage.test.ts diff --git a/.gitignore b/.gitignore index 5613562..bac12d3 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,5 @@ supabase/.temp/ backups/* !backups/backup.sh !backups/download-storage.mjs +!backups/storage-core.ts !backups/README.md \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index b5111c9..c439835 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,6 +30,7 @@ Le dipendenze si installano con **bun** (`bun.lock`). `bunfig.toml` impone npm run dev # vite dev su http://localhost:8080 npm run build # build di produzione (nitro) npm run lint # eslint (include prettier come regola) +npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file) npm run format # prettier --write . npm run test # suite di test (test/); npm run test:all per quella completa @@ -59,7 +60,7 @@ copre più il comportamento nuovo va aggiornata nello stesso lavoro. Prima di dire che hai finito: 1. i test delle funzioni aggiunte o modificate esistono e sono verdi; -2. `npm run lint` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e); +2. `npm run lint`, `npm run typecheck` e `npm run test` passano (`test:all` se hai toccato database o flussi e2e); 3. la documentazione toccata dalla modifica è aggiornata (vedi Test e Tracciabilità); 4. hai detto all'utente cosa hai cambiato, cosa hai lasciato fuori e quali rischi vedi. diff --git a/README.md b/README.md index 0eb56e9..789bd95 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ npm run dev # http://localhost:8080 ```bash npm run build # build di produzione npm run lint # eslint (include prettier) +npm run typecheck # controllo dei tipi TypeScript npm run test # test unit; npm run test:all per la suite completa ``` diff --git a/backups/README.md b/backups/README.md index 312851b..7981cfb 100644 --- a/backups/README.md +++ b/backups/README.md @@ -56,6 +56,10 @@ Ogni esecuzione crea una cartella nuova con timestamp: non sovrascrive backup precedenti. Non c'è pulizia automatica dei vecchi backup — cancellali a mano quando non servono più. +Se un passaggio fallisce (un dump o anche un solo file dello storage non scaricato), +lo script si ferma con un errore e **non** stampa «Backup completato»: un backup +incompleto non va considerato valido, rilancialo. + Il backup contiene: | File/cartella | Contenuto | @@ -97,3 +101,11 @@ personali reali. Per questo: - se sposti un backup fuori da questo host, usa sempre `--encrypt` o cifra tu l'archivio a mano; - cancella i backup in chiaro quando non ti servono più. + +## File di questa cartella + +| File | Ruolo | +| ---------------------- | ------------------------------------------------------------------------------------------------ | +| `backup.sh` | Punto di ingresso: esegue dump e download in ordine | +| `download-storage.mjs` | Scarica i file dei bucket (usa la service role key del `.env`) | +| `storage-core.ts` | Logica pura (lettura del `.env`, elenco dei file), coperta da `test/unit/backup-storage.test.ts` | diff --git a/backups/download-storage.mjs b/backups/download-storage.mjs index 0aa271b..63b2ce7 100644 --- a/backups/download-storage.mjs +++ b/backups/download-storage.mjs @@ -3,41 +3,33 @@ import { createClient } from "@supabase/supabase-js"; import { mkdir, writeFile, readFile } from "node:fs/promises"; import { dirname, join } from "node:path"; +import { elencaFile, leggiEnv } from "./storage-core.ts"; -for (const line of (await readFile(new URL("../.env", import.meta.url), "utf8")).split("\n")) { - const match = /^([\w.]+)=(.*)$/.exec(line.trim()); - if (match) process.env[match[1]] ??= match[2].replace(/^["']|["']$/g, ""); -} +const env = leggiEnv(await readFile(new URL("../.env", import.meta.url), "utf8")); +for (const [chiave, valore] of Object.entries(env)) process.env[chiave] ??= valore; const BUCKETS = ["avatar-giocatori", "profili-giocatore"]; const OUT_DIR = process.argv[2] ?? new URL("./storage/", import.meta.url).pathname; const supabase = createClient(process.env.VITE_SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY); -async function listAll(bucket, prefix = "") { - const { data, error } = await supabase.storage.from(bucket).list(prefix, { limit: 1000 }); +const elencaCartella = (bucket) => async (prefisso) => { + const { data, error } = await supabase.storage.from(bucket).list(prefisso, { limit: 1000 }); if (error) throw error; - let files = []; - for (const entry of data) { - const path = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.id === null) { - // è una "cartella" (nessun id): ricorri - files = files.concat(await listAll(bucket, path)); - } else { - files.push(path); - } - } - return files; -} + return data; +}; + +const falliti = []; for (const bucket of BUCKETS) { console.log(`Bucket: ${bucket}`); - const files = await listAll(bucket); + const files = await elencaFile(elencaCartella(bucket)); console.log(` ${files.length} file trovati`); for (const path of files) { const { data, error } = await supabase.storage.from(bucket).download(path); if (error) { console.error(` ERRORE ${path}:`, error.message); + falliti.push(`${bucket}/${path}`); continue; } const destPath = join(OUT_DIR, bucket, path); @@ -46,4 +38,8 @@ for (const bucket of BUCKETS) { console.log(` scaricato: ${bucket}/${path}`); } } +if (falliti.length) { + console.error(`${falliti.length} file non scaricati: il backup storage è incompleto.`); + process.exit(1); +} console.log("Fatto."); diff --git a/backups/storage-core.ts b/backups/storage-core.ts new file mode 100644 index 0000000..413b3fc --- /dev/null +++ b/backups/storage-core.ts @@ -0,0 +1,30 @@ +/** Logica pura del backup storage, separata dalla rete per poterla testare. */ + +/** Righe `CHIAVE=valore` di un file `.env`; toglie le virgolette solo se aprono e chiudono. */ +export function leggiEnv(testo: string): Record { + const env: Record = {}; + for (const riga of testo.split("\n")) { + const match = /^([\w.]+)=(.*)$/.exec(riga.trim()); + if (!match) continue; + const [, chiave, grezzo] = match as unknown as [string, string, string]; + const tra = /^(["'])(.*)\1$/.exec(grezzo); + env[chiave] = tra ? tra[2]! : grezzo; + } + return env; +} + +export type Voce = { name: string; id: string | null }; + +/** Tutti i percorsi dei file sotto `prefisso`; Supabase segnala le cartelle con `id: null`. */ +export async function elencaFile( + lista: (prefisso: string) => Promise, + prefisso = "", +): Promise { + const file: string[] = []; + for (const voce of await lista(prefisso)) { + const percorso = prefisso ? `${prefisso}/${voce.name}` : voce.name; + if (voce.id === null) file.push(...(await elencaFile(lista, percorso))); + else file.push(percorso); + } + return file; +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index bb640aa..431574e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -108,6 +108,7 @@ lanciato, così swipe come quello del calendario atterrano dove il gesto stava a npm run dev # vite dev su http://localhost:8080 npm run build # build di produzione (nitro) npm run lint # eslint (include prettier come regola) +npm run typecheck # controllo dei tipi TypeScript (tsc, senza generare file) npm run format # prettier --write . npm run test # test unit (veloci, senza rete né database) npm run test:integration # route server vere diff --git a/package.json b/package.json index 1ebf31f..60410e4 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build:dev": "vite build --mode development", "preview": "vite preview", "lint": "eslint .", + "typecheck": "tsc --noEmit", "format": "prettier --write .", "test": "bun test/run.ts", "test:integration": "bun test/run.ts integration", diff --git a/test/README.md b/test/README.md index 7a730ac..1601c86 100644 --- a/test/README.md +++ b/test/README.md @@ -13,7 +13,8 @@ bun test/unit/badges.test.ts # un singolo file ``` Il runner (`test/run.ts`) esegue ogni file in un processo separato, così un test -non può inquinare gli altri. +non può inquinare gli altri. bun esegue il TypeScript senza controllarne i tipi: un +test può risultare verde anche con errori di tipo, che trova solo `npm run typecheck`. Per le push, `unit/push-sw.test.ts` esegue il service worker reale in contesti isolati, senza pagina né rete: verifica la notifica al risveglio e le promesse di `waitUntil`. @@ -23,12 +24,12 @@ la consegna effettiva a schermo bloccato richiede un telefono e il servizio push ## 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. Più le funzioni pure isolabili nei moduli con hook/rete (validazione upload, guardie push, JWT VAPID, cattura errori, avatar, guardia admin delle route) | No | +| 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, guardia admin delle route, lettura del `.env` ed elenco dei bucket dello script di backup) | 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; permessi per ruolo, incluse le policy di M11, le deroghe dell'amministratore e le tabelle lasciate aperte di proposito (`permessi`), accesso alle route di notifica (`permessi-route`), semantica degli upsert e vincoli (`scritture`), le letture lato server delle route push (`lettori-server`) e la pulizia a cascata dei dati collegati alla cancellazione di un evento (`pulizia-evento`, M14) la funzione di bonifica delle righe orfane (`bonifica-evento`, M16), il centro notifiche in-app (`notifiche-utente`, M17), la nascita pubblica sincronizzata dal profilo (`nascita-pubblica`, M18) e il suo backfill per i profili preesistenti (`backfill-nascita-esistenti`, M19 — unico test che esegue SQL diretto via `docker exec` nel container Postgres, perché PostgREST non espone quell'update multi-tabella come RPC) sul database locale | 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 | — | +| `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 | — | ## Database locale in Docker diff --git a/test/unit/backup-storage.test.ts b/test/unit/backup-storage.test.ts new file mode 100644 index 0000000..a6b9377 --- /dev/null +++ b/test/unit/backup-storage.test.ts @@ -0,0 +1,70 @@ +/** Logica pura del backup storage: `bun test/unit/backup-storage.test.ts`. Solo dati finti. */ +import assert from "node:assert/strict"; +import { elencaFile, leggiEnv, type Voce } from "../../backups/storage-core.ts"; +import { prova, riepilogo } from "../helpers/prova"; + +// --- leggiEnv ----------------------------------------------------------------- +await prova("toglie virgolette doppie e singole", () => { + const env = leggiEnv(`A="uno"\nB='due'\nC=tre`); + assert.deepEqual(env, { A: "uno", B: "due", C: "tre" }); +}); + +await prova("lascia le virgolette che non fanno coppia", () => { + const env = leggiEnv(`A="aperta\nB=chiusa"\nC="mista'`); + assert.deepEqual(env, { A: '"aperta', B: 'chiusa"', C: `"mista'` }); +}); + +await prova("conserva gli = dentro il valore", () => { + assert.equal(leggiEnv(`CHIAVE="abc=="`)["CHIAVE"], "abc=="); +}); + +await prova("ignora righe vuote, commenti e spazi ai bordi", () => { + const env = leggiEnv(`# commento\n\n A=1 \r\nnon una variabile\n`); + assert.deepEqual(env, { A: "1" }); +}); + +await prova("valore vuoto resta stringa vuota", () => { + assert.deepEqual(leggiEnv(`A=\nB=""`), { A: "", B: "" }); +}); + +// --- elencaFile --------------------------------------------------------------- +const cartella = (name: string): Voce => ({ name, id: null }); +const file = (name: string): Voce => ({ name, id: `id-${name}` }); + +function bucketFinto(albero: Record) { + const chiesti: string[] = []; + const lista = async (prefisso: string) => { + chiesti.push(prefisso); + return albero[prefisso] ?? []; + }; + return { lista, chiesti }; +} + +await prova("scende nelle sottocartelle e restituisce percorsi completi", async () => { + const { lista } = bucketFinto({ + "": [cartella("g1"), file("radice.jpg")], + g1: [file("avatar.jpg"), cartella("doc")], + "g1/doc": [file("fronte.png")], + }); + assert.deepEqual(await elencaFile(lista), ["g1/avatar.jpg", "g1/doc/fronte.png", "radice.jpg"]); +}); + +await prova("interroga ogni cartella una volta sola", async () => { + const { lista, chiesti } = bucketFinto({ "": [cartella("a"), cartella("b")], a: [file("x")] }); + await elencaFile(lista); + assert.deepEqual(chiesti, ["", "a", "b"]); +}); + +await prova("bucket vuoto: nessun file", async () => { + assert.deepEqual(await elencaFile(bucketFinto({}).lista), []); +}); + +await prova("un errore di elenco interrompe il backup invece di saltare file", async () => { + const lista = async (prefisso: string) => { + if (prefisso === "g1") throw new Error("rete giù"); + return [cartella("g1")]; + }; + await assert.rejects(elencaFile(lista), /rete giù/); +}); + +riepilogo("backup-storage");