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}
- 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.'} +
+ L'area piena è quello che sappiamo davvero; il perimetro tratteggiato è il resto + dell'asse, dove il dato non c'è ancora. +
- 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) => (Fitness Age
+ Copertura dati: {Math.round(referto.fitnessAgeCopertura * 100)}% + {referto.fitnessAge === null && ' — non ancora sufficiente per un numero.'} +
+ InsanityLab Longevity monitora il tuo percorso nel tempo: più compilazioni + raccontano come cambi, non solo come stai oggi. + Compila di nuovo il questionario. +
+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); + }); +});