Aggiunge il controllo dei tipi e copre con un test lo script di backup
- 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) <noreply@anthropic.com>
This commit is contained in:
1 parent
431530af2a
commit
c6437a605c
10 files changed
+140
-26
No files matched your search
@@ -47,4 +47,5 @@ supabase/.temp/
|
||||
backups/*
|
||||
!backups/backup.sh
|
||||
!backups/download-storage.mjs
|
||||
!backups/storage-core.ts
|
||||
!backups/README.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.
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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` |
|
||||
@@ -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.");
|
||||
@@ -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<string, string> {
|
||||
const env: Record<string, string> = {};
|
||||
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<Voce[]>,
|
||||
prefisso = "",
|
||||
): Promise<string[]> {
|
||||
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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
+7
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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<string, Voce[]>) {
|
||||
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");
|
||||
Reference in new issue
Block a user