diff --git a/src/components/longevity/MacroScore.astro b/src/components/longevity/MacroScore.astro index 2b220e3..4b97ba1 100644 --- a/src/components/longevity/MacroScore.astro +++ b/src/components/longevity/MacroScore.astro @@ -6,6 +6,8 @@ // L'etichetta si deriva dal nome del contenitore (dato, letto da vista.ts), non da un // elenco scritto qui: PERFORMANCE/ENERGY/RECOVERY diventano Performance/Energy/Recovery // con una trasformazione, non con un dizionario da tenere allineato a mano. +import { formattaNumero } from '../../lib/longevity/formato'; + interface Props { nome: string; valore: number | null; @@ -20,7 +22,7 @@ const intensita = valore !== null ? Math.max(valore / 100, 0.15) : 0;

{etichetta}

- {valore !== null ? valore : '—'} + {valore !== null ? formattaNumero(valore) : '—'} / 100
@@ -29,9 +31,7 @@ const intensita = valore !== null ? Math.max(valore / 100, 0.15) : 0; style={`width:${valore !== null ? valore : 0}%; --lg-intensita:${intensita}`} >
- {valore === null && ( -

- Copertura dati: {Math.round(copertura * 100)}% — non ancora sufficiente per un numero. -

- )} +

+ Copertura dati: {Math.round(copertura * 100)}%{valore === null && ' — non ancora sufficiente per un numero.'} +

diff --git a/src/components/longevity/Radar.tsx b/src/components/longevity/Radar.tsx index 26799c2..6a0e1f5 100644 --- a/src/components/longevity/Radar.tsx +++ b/src/components/longevity/Radar.tsx @@ -37,6 +37,12 @@ export default function RadarAssi({ assi }: Props) { return (
+ {/* La spiegazione del linguaggio visivo viene PRIMA del grafico che spiega, non dopo: + chi legge deve sapere cosa sta guardando prima di guardarlo. */} +

+ L'area piena è quello che sappiamo davvero; il perimetro tratteggiato è il resto + dell'asse, dove il dato non c'è ancora. +

@@ -68,8 +74,11 @@ export default function RadarAssi({ assi }: Props) { {incompleti.length > 0 && (
-

- L'area piena è quello che sappiamo davvero. Il perimetro tratteggiato si completa così: + {/* L'invito concreto: l'elenco sotto è nomi di test, da solo non dice cosa fare. + Questi test si fanno in palestra con lo staff, non a casa da soli. */} +

+ Questi test si fanno in sala, con il trainer: prenota la prossima sessione per + completare il quadro.

{incompleti.map((a) => (
diff --git a/src/lib/longevity/formato.ts b/src/lib/longevity/formato.ts new file mode 100644 index 0000000..5ffea65 --- /dev/null +++ b/src/lib/longevity/formato.ts @@ -0,0 +1,11 @@ +// Formattazione dei numeri del referto. Un solo posto, perché prima ognuno stampava il +// numero grezzo di JavaScript (punto, non virgola) direttamente nel template — e un +// referto italiano con "72.5" al posto di "72,5" si legge come un difetto, non come un +// dettaglio. + +/** Un punteggio o una Fitness Age, formattati con la virgola decimale italiana. + * Le ETICHETTE dei punteggi (PERFORMANCE/ENERGY/RECOVERY, nomi di prodotto) non si + * toccano: solo i NUMERI si leggono all'italiana in un referto italiano. */ +export function formattaNumero(n: number): string { + return n.toLocaleString('it-IT'); +} diff --git a/src/lib/longevity/questionario.ts b/src/lib/longevity/questionario.ts index 3bbfb06..d47377d 100644 --- a/src/lib/longevity/questionario.ts +++ b/src/lib/longevity/questionario.ts @@ -5,6 +5,12 @@ import { esisteTest } from './registro'; /** Versione del set di domande. Si incrementa quando cambia una domanda. */ export const QUEST_VERSION = 'v1.0'; +/** Versione del TESTO del consenso al trattamento dei dati sanitari — distinta da + * QUEST_VERSION apposta: se cambia la formulazione del consenso senza che cambi una + * domanda del questionario, il record del consenso deve poterlo dire. Prima di questa + * costante il record portava QUEST_VERSION, la versione sbagliata. */ +export const CONSENT_VERSION = 'v1.0'; + /** * Campi testuali che si salvano ma non entrano MAI in una formula * (indicazione esplicita del cliente, punto 7 della specifica). diff --git a/src/lib/longevity/vista.ts b/src/lib/longevity/vista.ts index 9a35ad7..be66d2c 100644 --- a/src/lib/longevity/vista.ts +++ b/src/lib/longevity/vista.ts @@ -10,8 +10,8 @@ // cosa) che un domani va dimostrato tanto quanto un assenso. import type Database from 'better-sqlite3'; import { testAttivi, pesiDi, MODEL_VERSION } from './registro'; -import { salvaCompilazione, QUEST_VERSION } from './questionario'; -import { leggiScore } from './motore'; +import { salvaCompilazione, CONSENT_VERSION } from './questionario'; +import { leggiScore, calcolaSessione, salvaScore } from './motore'; export type CampoQuestionario = { id: string; @@ -49,15 +49,16 @@ function consensoDato(risposta: string): boolean { return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase()); } -/** Registra il fatto del consenso — chi, quando, a che versione del questionario — - * indipendentemente dall'esito. Legata al cliente sempre; alla sessione solo quando il - * consenso è affermativo e la sessione esiste davvero. */ +/** Registra il fatto del consenso — chi, quando, a che versione del TESTO del consenso + * (CONSENT_VERSION, non QUEST_VERSION: il consenso può cambiare formulazione senza che + * cambi una domanda) — indipendentemente dall'esito. Legata al cliente sempre; alla + * sessione solo quando il consenso è affermativo e la sessione esiste davvero. */ function registraConsenso( db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string ): void { const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim(); const oggi = new Date().toISOString().slice(0, 10); - const testo = `Consenso al trattamento dei dati sanitari: ${esito} — questionario ${QUEST_VERSION}, registrato il ${oggi}`; + const testo = `Consenso al trattamento dei dati sanitari: ${esito} — consenso ${CONSENT_VERSION}, registrato il ${oggi}`; db.prepare( `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)` ).run(clientCode, sessioneId, 'consenso_sanitario', testo); @@ -68,6 +69,15 @@ function registraConsenso( * qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né * note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si * scrive comunque, con l'esito letterale ricevuto. + * + * Quando il consenso è affermativo, TUTTO il resto avviene in un'unica transazione (il + * salvataggio dei dati sanitari, il calcolo dei punteggi e il congelamento in `score`, la + * nota di consenso): o entra tutto nel fascicolo, o non entra niente. Prima di questa + * correzione il calcolo restava scollegato dal salvataggio — nessuno chiamava + * calcolaSessione/salvaScore in produzione — e la nota di consenso si scriveva fuori dalla + * transazione, quindi un fallimento lì poteva lasciare dati sanitari senza il consenso che + * li giustifica. `db.transaction` di better-sqlite3 annida automaticamente con savepoint + * quelle interne di `salvaCompilazione` e `salvaScore`. */ export function salvaDalForm( db: Database.Database, @@ -86,17 +96,26 @@ export function salvaDalForm( }; } - const sessioneId = salvaCompilazione(db, { - client_code: input.client_code, - data: input.data, - eta: input.eta, - risposte: input.risposte, - liberi: input.liberi, + const tx = db.transaction(() => { + const sessioneId = salvaCompilazione(db, { + client_code: input.client_code, + data: input.data, + eta: input.eta, + risposte: input.risposte, + liberi: input.liberi, + }); + + // Il calcolo e il congelamento dei punteggi sono parte del salvataggio, non un passo + // successivo affidato a chi legge: senza questo, una compilazione restava per sempre + // senza i suoi punteggi. + salvaScore(db, sessioneId, calcolaSessione(db, sessioneId)); + + registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario); + + return sessioneId; }); - registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario); - - return { ok: true, sessioneId }; + return { ok: true, sessioneId: tx() }; } // @@ -191,7 +210,7 @@ function mancanoPer(db: Database.Database, sessioneId: number, nomeAsse: string, export function refertoDi( db: Database.Database, sessioneId: number -): { assi: AsseVista[]; macro: MacroVista[]; fitnessAge: number | null } { +): { assi: AsseVista[]; macro: MacroVista[]; fitnessAge: number | null; fitnessAgeCopertura: number } { const punteggi = leggiScore(db, sessioneId); const presenti = sottoDominiPresenti(db, sessioneId); @@ -212,8 +231,12 @@ export function refertoDi( }); const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null; + // Prima apertura: con poche misure la Fitness Age manca quasi sempre. La pagina deve + // poter dire QUANTO manca (come fa già ogni carta di macro-score), non solo che manca: + // la copertura resta leggibile anche quando lo stato è 'insufficiente'. + const fitnessAgeCopertura = punteggi.fitnessAge?.copertura ?? 0; - return { assi, macro, fitnessAge }; + return { assi, macro, fitnessAge, fitnessAgeCopertura }; } /** L'ultima sessione di un cliente (la più recente per data, a parità di data la più diff --git a/src/pages/api/longevity/questionario.ts b/src/pages/api/longevity/questionario.ts index 7d8f470..ee65a2e 100644 --- a/src/pages/api/longevity/questionario.ts +++ b/src/pages/api/longevity/questionario.ts @@ -9,29 +9,38 @@ export const prerender = false; const json = (status: number, body: object) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); -export const POST: APIRoute = async ({ request, locals }) => { - // Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal - // corpo della richiesta: qui infatti il corpo non viene mai letto per questo campo. La - // risoluzione passa da anagrafica.ts (l'unico modulo autorizzato a toccare quella - // connessione): questo file apre solo longevity, per salvare. - const codice = codicePerUtenteLoggato(locals.user!.id); - if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' }); +/** + * Il corpo legittimo della richiesta: SOLO i campi che un cliente può davvero mandare. + * `client_code` non è un campo di questo tipo — non per una lista di nomi vietati (una + * lista così invecchia appena il file rinomina la variabile, vedi il commento nel test), + * ma perché il tipo non lo prevede: chi scrivesse domani `corpo.client_code` non + * supererebbe `tsc --noEmit`, il campo non esiste. Il codice cliente resta SEMPRE quello + * della sessione (codicePerUtenteLoggato, spec §8), mai un campo letto da qui. + */ +type CorpoQuestionario = { + risposte: Record; + liberi: Record; + consensoSanitario: string; + eta?: number; +}; - let data: Record; - try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } - if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' }); +/** Estrae SOLO i campi di CorpoQuestionario da un JSON grezzo; `null` se il corpo non è + * nemmeno un oggetto, o se manca `risposte`. Ogni altra chiave del corpo (incluso un + * eventuale `client_code`) viene semplicemente ignorata: non esiste modo di farla arrivare + * al chiamante, perché il tipo di ritorno non la contiene. */ +function corpoDaJson(raw: unknown): CorpoQuestionario | null { + if (typeof raw !== 'object' || raw === null) return null; + const grezzo = raw as Record; - const risposteIn = data.risposte; - if (typeof risposteIn !== 'object' || risposteIn === null) { - return json(400, { error: 'Risposte mancanti.' }); - } + const risposteIn = grezzo.risposte; + if (typeof risposteIn !== 'object' || risposteIn === null) return null; const risposte: Record = {}; for (const [id, v] of Object.entries(risposteIn as Record)) { if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v; } // Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove. - const liberiIn = data.liberi; + const liberiIn = grezzo.liberi; const liberi: Record = {}; if (typeof liberiIn === 'object' && liberiIn !== null) { for (const campo of CAMPI_LIBERI) { @@ -40,8 +49,27 @@ export const POST: APIRoute = async ({ request, locals }) => { } } - const consensoSanitario = typeof data.consensoSanitario === 'string' ? data.consensoSanitario : ''; - const eta = typeof data.eta === 'number' && Number.isFinite(data.eta) ? data.eta : undefined; + const consensoSanitario = typeof grezzo.consensoSanitario === 'string' ? grezzo.consensoSanitario : ''; + const eta = typeof grezzo.eta === 'number' && Number.isFinite(grezzo.eta) ? grezzo.eta : undefined; + + return { risposte, liberi, consensoSanitario, eta }; +} + +export const POST: APIRoute = async ({ request, locals }) => { + // Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal + // corpo della richiesta: la risoluzione passa da anagrafica.ts (l'unico modulo autorizzato + // a toccare quella connessione). `corpo`, qui sotto, non ha nemmeno un campo da cui + // prenderlo per errore. + const codice = codicePerUtenteLoggato(locals.user!.id); + if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' }); + + let raw: unknown; + try { raw = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); } + if (typeof raw !== 'object' || raw === null) return json(400, { error: 'Dati non validi.' }); + + const corpo = corpoDaJson(raw); + if (!corpo) return json(400, { error: 'Risposte mancanti.' }); + const { risposte, liberi, consensoSanitario, eta } = corpo; // La data della compilazione è quella del server, non quella (manomettibile) del client. const oggi = new Date().toISOString().slice(0, 10); diff --git a/src/pages/longevity/io.astro b/src/pages/longevity/io.astro index 6d2b992..7adb7d4 100644 --- a/src/pages/longevity/io.astro +++ b/src/pages/longevity/io.astro @@ -9,6 +9,7 @@ import MacroScore from '../../components/longevity/MacroScore.astro'; import { getLongevityDb } from '../../lib/longevity/db'; import { codicePerUtenteLoggato } from '../../lib/longevity/anagrafica'; import { refertoDi, ultimaSessioneDi } from '../../lib/longevity/vista'; +import { formattaNumero } from '../../lib/longevity/formato'; export const prerender = false; @@ -34,12 +35,18 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null; {referto && (
-
+

Fitness Age

- {referto.fitnessAge ?? '—'} + + {referto.fitnessAge !== null ? formattaNumero(referto.fitnessAge) : '—'} + {referto.fitnessAge !== null && anni}
+

+ Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}% + {referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'} +

@@ -52,6 +59,14 @@ const referto = sessioneId !== null ? refertoDi(db, sessioneId) : null; ))}
+ +
+

+ InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni + raccontano come cambi, non solo come stai oggi. + Compila di nuovo il questionario. +

+
)} diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts index 27fa181..8a2a824 100644 --- a/tests/longevity/anagrafica.test.ts +++ b/tests/longevity/anagrafica.test.ts @@ -105,6 +105,19 @@ describe('anagrafica pseudonimizzata', () => { // come compaiono in qualunque accessore camelCase) invece di un elenco chiuso di nomi: // un elenco di nomi vietati invecchia (è già successo qui: getIdentityDb/getLongevityDb // sono nati dopo la prima versione di questo test e sono passati indisturbati). + // ⚠️ VERIFICATO IL 22/08, UN TERZO BUCO, STAVOLTA IN UN FILE SOLO: `createIdentityDb` + // (a differenza del singleton privato getIdentityDb) È esportata da db.ts — serve a + // creaCliente, che riceve due connessioni già aperte da fuori. Un file che chiama + // `createIdentityDb()` direttamente e apre longevity con una connessione propria (es. + // `new Database(process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db')`, mai col nome + // `getLongevityDb`/`createLongevityDb`) contiene "Identity" ma MAI "Longevity" con + // l'iniziale maiuscola — LONGEVITY_DB_PATH è tutto maiuscolo, 'longevity.db' tutto + // minuscolo — e questo test lo lascia passare. Provato con un file del genere in + // src/lib/longevity/: zero test rossi. Non è una falla da chiudere con un controllo + // più furbo (già tentato e aggirato due volte prima di questa): la garanzia resta solo + // quella architetturale sopra, e su `createIdentityDb` non c'è — è pensata per essere + // chiamata da fuori anagrafica.ts (creaCliente la riceve come parametro), quindi non si + // può chiuderla come si è chiusa getIdentityDb senza cambiare quella funzione. const srcDir = join(process.cwd(), 'src'); const colpevoli: string[] = []; diff --git a/tests/longevity/consenso-atomico.test.ts b/tests/longevity/consenso-atomico.test.ts new file mode 100644 index 0000000..6f83e72 --- /dev/null +++ b/tests/longevity/consenso-atomico.test.ts @@ -0,0 +1,52 @@ +// Il consenso veniva scritto DOPO che salvaCompilazione aveva già chiuso la propria +// transazione: se il passo successivo falliva, restavano i dati sanitari SENZA il record +// di consenso che li giustifica — l'esatto contrario dell'invariante ("o entra tutto nel +// fascicolo, o non entra niente"). Questo file forza un fallimento nel calcolo dei +// punteggi (mockando calcolaSessione) DOPO che la sessione e le misure sono già scritte, e +// verifica che l'intera transazione — sessione, misure, punteggi, consenso — vada +// indietro insieme. Il mock vive in un file a parte perché vi.mock è per-file: gli altri +// test su calcolaSessione (referto.test.ts, salvataggio-e2e.test.ts) devono restare reali. +import { describe, it, expect, vi } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; + +vi.mock('../../src/lib/longevity/motore', async (importOriginal) => { + const reale = await importOriginal(); + return { + ...reale, + calcolaSessione: () => { throw new Error('rottura simulata nel calcolo dei punteggi'); }, + }; +}); + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + return db; +} + +describe('salvaDalForm — atomicità di dati, punteggi e consenso', () => { + it('se il calcolo dei punteggi fallisce DOPO che sessione e misure sono state scritte, tutto torna indietro: niente sessione, niente misure, niente nota di consenso', async () => { + const { salvaDalForm } = await import('../../src/lib/longevity/vista'); + const db = dbPronto(); + + expect(() => + salvaDalForm(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { q_riposato: 8 }, consensoSanitario: 'Si', liberi: {}, + }) + ).toThrow(/rottura simulata/); + + const sessioni = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }; + const misure = db.prepare(`SELECT COUNT(*) n FROM misure`).get() as { n: number }; + const score = db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number }; + const consenso = db.prepare(`SELECT COUNT(*) n FROM profilo_note WHERE campo_id = 'consenso_sanitario'`) + .get() as { n: number }; + + expect(sessioni.n).toBe(0); + expect(misure.n).toBe(0); + expect(score.n).toBe(0); + expect(consenso.n).toBe(0); + }); +}); diff --git a/tests/longevity/formato.test.ts b/tests/longevity/formato.test.ts new file mode 100644 index 0000000..f7f1620 --- /dev/null +++ b/tests/longevity/formato.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest'; +import { formattaNumero } from '../../src/lib/longevity/formato'; + +describe('formattaNumero — i numeri del referto in italiano', () => { + it('un decimale usa la virgola, non il punto', () => { + expect(formattaNumero(72.5)).toBe('72,5'); + }); + + it('un intero resta senza decimali spuri', () => { + expect(formattaNumero(80)).toBe('80'); + }); + + it('un negativo (possibile solo per differenze, non per un punteggio 0-100) mantiene il segno', () => { + expect(formattaNumero(-3.2)).toBe('-3,2'); + }); +}); diff --git a/tests/longevity/prima-apertura.test.ts b/tests/longevity/prima-apertura.test.ts new file mode 100644 index 0000000..150ab35 --- /dev/null +++ b/tests/longevity/prima-apertura.test.ts @@ -0,0 +1,66 @@ +// La prima apertura del referto (revisione del 22/08): con i dati di oggi il cliente vede +// molti assi tratteggiati. È corretto, ma la pagina deve SPIEGARLO, non lasciarlo sembrare +// un guasto. Questi controlli leggono il sorgente delle pagine/componenti — come già fa +// referto.test.ts per "nessuna pagina interroga il database da sola" — perché non c'è un +// harness di rendering per Astro/React in questo repo (nessuna dipendenza nuova ammessa). +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const leggi = (p: string) => readFileSync(join(process.cwd(), p), 'utf8'); + +describe('la Fitness Age, quando manca, spiega perché (come le altre carte)', () => { + it('io.astro mostra una nota di copertura anche per la Fitness Age, non solo un trattino nudo', () => { + const src = leggi('src/pages/longevity/io.astro'); + // Deve leggere la copertura dedicata (vista.ts, fitnessAgeCopertura), non inventarsi + // un numero: la sola presenza del trattino '—' non basta più. + expect(src).toMatch(/fitnessAgeCopertura/); + expect(src).toMatch(/Copertura dati/); + }); + + it('il numero della Fitness Age, quando c\'è, è formattato all\'italiana', () => { + const src = leggi('src/pages/longevity/io.astro'); + expect(src).toMatch(/formattaNumero\(referto\.fitnessAge\)/); + }); +}); + +describe('il radar spiega prima di mostrare', () => { + it('la spiegazione del radar precede il grafico, non lo segue', () => { + const src = leggi('src/components/longevity/Radar.tsx'); + const idxIntro = src.indexOf("L'area piena"); + const idxGrafico = src.indexOf(' { + const src = leggi('src/components/longevity/Radar.tsx'); + expect(src).toMatch(/trainer/i); + }); +}); + +describe('la copertura si mostra sempre, non solo quando il numero manca', () => { + it('MacroScore.astro non nasconde più la copertura quando il punteggio c\'è', () => { + const src = leggi('src/components/longevity/MacroScore.astro'); + // PRIMA: `{valore === null && (

Copertura dati...`. La nota deve poter comparire + // anche quando valore !== null: la vecchia guardia esatta non deve più esserci. + expect(src).not.toMatch(/\{valore === null && \(\s*

/); + expect(src).toMatch(/Copertura dati/); + }); + + it('il punteggio di MacroScore è formattato all\'italiana', () => { + const src = leggi('src/components/longevity/MacroScore.astro'); + expect(src).toMatch(/formattaNumero\(valore\)/); + }); +}); + +describe('chi ha già compilato può ricompilare (monitoraggio nel tempo)', () => { + it('io.astro offre il collegamento al questionario anche quando il referto esiste già', () => { + const src = leggi('src/pages/longevity/io.astro'); + const idxReferto = src.indexOf('{referto && ('); + expect(idxReferto).toBeGreaterThan(-1); + const blocco = src.slice(idxReferto); + expect(blocco).toMatch(/href=["'`]\/longevity\/questionario["'`]/); + }); +}); diff --git a/tests/longevity/questionario-pagina.test.ts b/tests/longevity/questionario-pagina.test.ts index 225de9b..d387f08 100644 --- a/tests/longevity/questionario-pagina.test.ts +++ b/tests/longevity/questionario-pagina.test.ts @@ -1,9 +1,12 @@ import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; -import { createLongevityDb } from '../../src/lib/longevity/db'; +import { tmpdir } from 'node:os'; +import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; import { seedRegistro } from '../../src/lib/longevity/registro'; import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista'; +import { creaCliente } from '../../src/lib/longevity/anagrafica'; +import { CONSENT_VERSION, QUEST_VERSION } from '../../src/lib/longevity/questionario'; function dbPronto() { const db = createLongevityDb(':memory:'); @@ -78,10 +81,102 @@ describe('il salvataggio, e il consenso', () => { expect(note[0].testo).toMatch(/No/); }); - it('il codice cliente NON puo arrivare da chi chiama la pagina', () => { + // PRIMA di questa correzione il ramo del consenso DATO non era coperto da nessun test: + // cancellando la riga che registra il consenso accettato (registraConsenso dentro il ramo + // affermativo di salvaDalForm) la suite restava tutta verde — provato. Il consenso dato è + // l'unico documento che un domani si esibisce a chi chiede conto del trattamento, tanto + // quanto il rifiuto (già coperto sopra). + it('registra il consenso anche quando e dato: e il documento che un domani va esibito', () => { + const db = dbPronto(); + const esito = salvaDalForm(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte, consensoSanitario: 'Si', liberi: {}, + }); + expect(esito.ok).toBe(true); + const note = db.prepare( + `SELECT campo_id, testo, sessione_id FROM profilo_note WHERE campo_id = 'consenso_sanitario'` + ).all() as { campo_id: string; testo: string; sessione_id: number | null }[]; + expect(note.length).toBe(1); + expect(note[0].testo).toMatch(/Si/); + if (esito.ok) expect(note[0].sessione_id).toBe(esito.sessioneId); + }); + + // Il record del consenso portava la versione delle DOMANDE (QUEST_VERSION): se cambia il + // testo del consenso senza che cambi una domanda, il record non lo diceva. Ora porta una + // versione propria (CONSENT_VERSION, questionario.ts), dichiarata accanto a QUEST_VERSION. + it('il record del consenso porta CONSENT_VERSION, non QUEST_VERSION', () => { + const db = dbPronto(); + salvaDalForm(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte, consensoSanitario: 'Si', liberi: {}, + }); + const nota = db.prepare( + `SELECT testo FROM profilo_note WHERE campo_id = 'consenso_sanitario'` + ).get() as { testo: string }; + expect(nota.testo).toContain(`consenso ${CONSENT_VERSION}`); + expect(nota.testo).not.toMatch(/questionario\s+v/i); + // Le due costanti oggi coincidono di valore ('v1.0'): la prova che conta è che sono + // DUE COSTANTI DISTINTE (non lo stesso import rietichettato) — non che i valori + // divergano oggi. + expect(CONSENT_VERSION).not.toBe(undefined); + expect(QUEST_VERSION).not.toBe(undefined); + }); + + it('il codice cliente si risolve dalla sessione (codicePerUtenteLoggato)', () => { const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8'); - // il codice si prende dalla sessione dell'utente, mai dal corpo della richiesta expect(src).toMatch(/codicePerUtente/); - expect(src).not.toMatch(/body\.client_code|body\.clientId|corpo\.client_code/); + }); + + // PRIMA questo test cercava, nel sorgente, le stringhe letterali "body.client_code" e + // "corpo.client_code" — ma il file chiama la variabile del corpo `data`, non `body` né + // `corpo`: la lista di divieti non nominava nessuna variabile che il file usasse + // davvero, quindi era vacua per costruzione. Provato: aggiungendo all'endpoint + // `if (typeof data.client_code === 'string') codice = data.client_code;` (la vulnerabilità + // vera — un cliente scrive nel fascicolo di un altro) la suite restava tutta verde. + // + // Il fix e' strutturale (il tipo del corpo, in questionario.ts, non ha il campo + // client_code — chi lo leggesse da li' non passerebbe tsc --noEmit) e questo e' il test + // di comportamento che lo dimostra end-to-end: chiama l'endpoint VERO con un client_code + // estraneo nel corpo e verifica dove la sessione finisce scritta. + it('un client_code nel corpo della richiesta viene ignorato: la sessione finisce sul cliente vero, mai su quello iniettato', async () => { + const dir = mkdtempSync(join(tmpdir(), 'lg-api-test-')); + const idPath = join(dir, 'identity.db'); + const lgPath = join(dir, 'longevity.db'); + const primaId = process.env.IDENTITY_DB_PATH; + const primaLg = process.env.LONGEVITY_DB_PATH; + try { + process.env.IDENTITY_DB_PATH = idPath; + process.env.LONGEVITY_DB_PATH = lgPath; + + // Setup con connessioni proprie, sullo stesso file che il singleton dell'endpoint + // aprirà (le due variabili d'ambiente sopra), come in anagrafica.test.ts. + const id = createIdentityDb(idPath); + const lg = createLongevityDb(lgPath); + seedRegistro(lg); + const codiceVero = creaCliente(id, lg, { nome: 'Vera', cognome: 'Utente', sesso: 'F', user_id: 1 }); + const codiceAltro = creaCliente(id, lg, { nome: 'Altro', cognome: 'Cliente', sesso: 'M', user_id: 2 }); + + const { POST } = await import('../../src/pages/api/longevity/questionario'); + const richiesta = new Request('http://localhost/api/longevity/questionario', { + method: 'POST', + body: JSON.stringify({ + client_code: codiceAltro, // tentativo: scrivere nel fascicolo di un altro cliente + risposte: { q_riposato: 8 }, + consensoSanitario: 'Si', + }), + }); + const res = await POST({ request: richiesta, locals: { user: { id: 1 } } } as any); + expect(res.status).toBe(201); + const corpo = await res.json(); + + const riga = lg.prepare(`SELECT client_code FROM sessioni WHERE id = ?`).get(corpo.sessioneId) as + { client_code: string }; + expect(riga.client_code).toBe(codiceVero); + expect(riga.client_code).not.toBe(codiceAltro); + } finally { + if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId; + if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg; + rmSync(dir, { recursive: true, force: true }); + } }); }); diff --git a/tests/longevity/referto.test.ts b/tests/longevity/referto.test.ts index c128885..afdac9c 100644 --- a/tests/longevity/referto.test.ts +++ b/tests/longevity/referto.test.ts @@ -1,6 +1,6 @@ // tests/longevity/referto.test.ts import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; +import { readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { createLongevityDb } from '../../src/lib/longevity/db'; import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; @@ -59,6 +59,18 @@ describe('il referto del cliente', () => { expect(forza.mancano.length).toBeGreaterThan(0); }); + it('espone la copertura della Fitness Age anche quando è insufficiente (serve alla nota in pagina)', () => { + // conCompilazione() non porta nessuna misura del composito Fitness Age (cardio, + // handgrip, hrv, forza_resto, composizione, stabilita: nessuno di questi sotto-domini + // ha dati in questa sessione, solo il questionario), quindi fitnessAge è null — ma + // PRIMA di questa correzione refertoDi scartava anche la copertura insieme al valore: + // la pagina non aveva modo di dire "41%", solo di dire "manca". + const { db, s } = conCompilazione(); + const referto = refertoDi(db, s); + expect(referto.fitnessAge).toBeNull(); + expect(referto.fitnessAgeCopertura).toBe(0); + }); + it('un asse coperto porta il suo numero', () => { const { db, s } = conCompilazione(); const stile = refertoDi(db, s).assi.find((a) => a.nome === 'Stile di Vita & Sonno')!; @@ -95,9 +107,18 @@ describe('il referto del cliente', () => { }); it('nessuna pagina interroga il database da sola', () => { - for (const f of ['src/pages/longevity/io.astro', 'src/pages/longevity/questionario.astro']) { - const src = readFileSync(join(process.cwd(), f), 'utf8'); - expect(src, `${f} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i); + // Scandisce TUTTE le pagine sotto src/pages/longevity/, non un elenco scritto qui: + // un elenco di due nomi non protegge una pagina nuova, che passerebbe indisturbata + // anche con una query dentro (verificato: una prova-query.astro con un + // db.prepare('SELECT 1') non veniva vista dalla versione precedente di questo test). + const dir = join(process.cwd(), 'src/pages/longevity'); + const pagine = (readdirSync(dir, { recursive: true }) as string[]) + .filter((f) => f.endsWith('.astro')); + expect(pagine.length).toBeGreaterThan(0); // se la cartella si svuota, il test non deve tacere per errore + for (const f of pagine) { + const percorso = `src/pages/longevity/${f}`; + const src = readFileSync(join(dir, f), 'utf8'); + expect(src, `${percorso} contiene SQL`).not.toMatch(/\bSELECT\b|\.prepare\(/i); } }); }); diff --git a/tests/longevity/salvataggio-e2e.test.ts b/tests/longevity/salvataggio-e2e.test.ts new file mode 100644 index 0000000..312aa4a --- /dev/null +++ b/tests/longevity/salvataggio-e2e.test.ts @@ -0,0 +1,60 @@ +// Il blocco scoperto nella revisione finale del 22/08: il salvataggio del questionario e la +// lettura dei punteggi esistono entrambi, ma fra i due non calcolava niente nessuno. +// I test esistenti (referto.test.ts) chiamavano calcolaSessione/salvaScore dentro la propria +// fixture — cioè facevano a mano ciò che in produzione non fa nessuno — quindi restavano +// verdi anche con l'endpoint reale del tutto scollegato dal motore. +// +// Questo file parte dal modulo compilato (salvaDalForm, la stessa funzione che chiama +// l'endpoint POST /api/longevity/questionario) e arriva al referto (refertoDi): NON chiama +// mai calcolaSessione o salvaScore a mano. Se un domani qualcuno stacca di nuovo il +// calcolo dal salvataggio, questo è il test che deve diventare rosso. +import { describe, it, expect } from 'vitest'; +import { createLongevityDb } from '../../src/lib/longevity/db'; +import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; +import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista'; + +function dbPronto() { + const db = createLongevityDb(':memory:'); + seedRegistro(db); + seedPesi(db, MODEL_VERSION); + db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); + return db; +} + +describe('salvaDalForm calcola e congela i punteggi da solo', () => { + it('venti risposte compilate producono un referto con assi e macro-score valorizzati, senza ricalcolo a mano', () => { + const db = dbPronto(); + + const esito = salvaDalForm(db, { + client_code: 'ISL-0001', data: '2026-08-22', eta: 35, + risposte: { + q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1, + q_energia_media: 7, q_esaurimento: 0, q_calo_pomeridiano: 3, + q_sopraffatto: 1, q_controllo: 8, q_sicurezza_gestione: 8, q_tensione: 1, q_pensieri_lavoro: 0.5, + q_attivita: 5, q_alimentazione: 8, q_sigarette: 0, q_alcol_life: 2, q_luce: 2, q_schermi: 10, + }, + consensoSanitario: 'Si', liberi: {}, + }); + + expect(esito.ok).toBe(true); + if (!esito.ok) throw new Error(esito.errore); + + // Riprova diretta sulla tabella score: prima di questa correzione restava vuota, + // perché nessuno chiamava calcolaSessione/salvaScore fra salvataggio e lettura. + const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as + { n: number }; + expect(righeScore.n).toBeGreaterThan(0); + + const referto = refertoDi(db, esito.sessioneId); + // 'Stile di Vita & Sonno' ha UN solo sotto-dominio (questionario_lifestyle, peso 1.00): + // con tutte le risposte compilate la copertura è piena. + const stile = referto.assi.find((a) => a.nome === 'Stile di Vita & Sonno')!; + expect(stile.valore).not.toBeNull(); + // 'Energia & Regolazione Stress' pesa hrv 0.50 + questionario_energia_stress 0.50: con + // solo il questionario la copertura vera è 0.50, sopra la soglia del 40%. + const energia = referto.assi.find((a) => a.nome === 'Energia & Regolazione Stress')!; + expect(energia.valore).not.toBeNull(); + const almenoUnMacro = referto.macro.some((m) => m.valore !== null); + expect(almenoUnMacro).toBe(true); + }); +});