// Unità 2a (#2): il modulo del check-up manuale del trainer e le letture dell'anagrafica che // gli servono. Stesso principio di salvataggio-e2e.test.ts — si parte dal modulo vero // (salvaCheckup), mai da calcolaSessione/salvaScore chiamati a mano — più la prova che // questo file esiste apposta a garantire: un check-up e un questionario sono sessioni // separate (#1, misureValideAlla), quindi il referto del secondo deve vedere il primo. import { describe, it, expect } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db'; import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro'; import { salvaCheckup, sessioniDi, correggiCheckup, misureDellaSessione } from '../../src/lib/longevity/gestionale'; import { misureDaVerificare, verificaMisura } from '../../src/lib/longevity/misure'; import { leggiScore } from '../../src/lib/longevity/motore'; import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista'; import { creaCliente, elencoClientiParam, clienteDaCodiceParam, etaDelClienteParam, apriFascicolo, } from '../../src/lib/longevity/anagrafica'; import { canAccessPath } from '../../src/lib/auth'; import { misureDalForm } from '../../src/pages/api/longevity/gestionale/checkup'; function dbPronto(clientCode = 'ISL-0001', sesso: 'M' | 'F' = 'M') { const db = createLongevityDb(':memory:'); seedRegistro(db); seedPesi(db, MODEL_VERSION); db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso); return db; } describe('salvaCheckup scrive la sessione e congela i punteggi da solo', () => { it('una sessione tipo checkup, misure di fonte manuale, almeno una riga in score', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', misure: [ { test_id: 'f_peso_kg', valore_num: 80 }, { test_id: 'f_handgrip_kg', valore_num: 45 }, ], }); expect(esito.ok).toBe(true); if (!esito.ok) throw new Error(esito.errore); const sessione = db.prepare(`SELECT tipo, operatore FROM sessioni WHERE id = ?`).get(esito.sessioneId) as { tipo: string; operatore: string }; expect(sessione.tipo).toBe('checkup'); expect(sessione.operatore).toBe('nicola'); const fonti = db.prepare(`SELECT DISTINCT fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as { fonte: string }[]; expect(fonti).toEqual([{ fonte: 'manuale' }]); const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as { n: number }; expect(righeScore.n).toBeGreaterThan(0); }); it('dopo un questionario, il referto del check-up vede sia gli assi del questionario sia quelli fisici (sessioni combinate, #1)', () => { const db = dbPronto(); const q = salvaDalForm(db, { client_code: 'ISL-0001', data: '2026-09-01', eta: 38, risposte: { q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1 }, consensoSanitario: 'Si', liberi: {}, }); expect(q.ok).toBe(true); const c = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', misure: [ { test_id: 'f_handgrip_kg', valore_num: 48 }, { test_id: 'f_pushup_rip', valore_num: 24 }, ], }); expect(c.ok).toBe(true); if (!c.ok) throw new Error(c.errore); const referto = refertoDi(db, c.sessioneId); // 'Recupero & Sistema Nervoso' pesa questionario_sonno (dal questionario di prima, NON // di questa sessione): la copertura sale sopra zero solo se misureValideAlla porta // avanti la risposta del 2026-09-01 fino alla sessione del 2026-09-26. const recupero = referto.assi.find((a) => a.nome === 'Recupero & Sistema Nervoso')!; expect(recupero.copertura).toBeGreaterThan(0); // 'Forza & Struttura' vede handgrip e spinta, appena registrati in QUESTA sessione. const forza = referto.assi.find((a) => a.nome === 'Forza & Struttura')!; expect(forza.copertura).toBeGreaterThan(0); }); }); describe('salvaCheckup rifiuta prima di scrivere: nessuna traccia parziale', () => { it('un test_id sconosciuto al registro: ok:false, nessuna sessione', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_non_esiste', valore_num: 1 }], }); expect(esito.ok).toBe(false); expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); }); it('un q_* del questionario nel check-up: ok:false, nessuna sessione', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }], }); expect(esito.ok).toBe(false); expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); }); it('nessuna misura: ok:false, nessuna sessione', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [], }); expect(esito.ok).toBe(false); expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); }); it('un soggetto che non esiste in longevity: ok:false, nessuna sessione', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-9999', data: '2026-09-26', eta: null, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); expect(esito.ok).toBe(false); expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0); }); }); describe('le misure fuori range restano nel fascicolo, marcate e leggibili', () => { it('una misura oltre il range di plausibilità (f_peso_kg, max 250) si salva, si marca, ed esce da misureDaVerificare', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 500 }], }); expect(esito.ok).toBe(true); if (!esito.ok) throw new Error(esito.errore); expect(esito.fuoriRange).toEqual(['f_peso_kg']); const riga = db.prepare( `SELECT fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` ).get(esito.sessioneId) as { fuori_range: number }; expect(riga.fuori_range).toBe(1); const elenco = misureDaVerificare(db, 'ISL-0001'); expect(elenco.length).toBe(1); expect(elenco[0]).toMatchObject({ test_id: 'f_peso_kg', valore_num: 500, data: '2026-09-26' }); }); }); describe('sessioniDi', () => { it('elenca le sessioni del cliente, più recente prima, con numero misure e fitness age congelata', () => { const db = dbPronto(); salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); const seconda = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }], }); expect(seconda.ok).toBe(true); const elenco = sessioniDi(db, 'ISL-0001'); expect(elenco.length).toBe(2); expect(elenco[0].data).toBe('2026-09-26'); // la più recente prima expect(elenco[0].numeroMisure).toBe(1); // Con una sola misura la copertura di ogni asse resta troppo bassa per una Fitness Age: // resta null, non un numero congetturato. expect(elenco[0].fitnessAge).toBeNull(); }); }); describe('misureDellaSessione', () => { it('null se la sessione non esiste; altrimenti le misure ordinate per asse come campiDelCheckup', () => { const db = dbPronto(); expect(misureDellaSessione(db, 999999)).toBeNull(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }, { test_id: 'f_handgrip_kg', valore_num: 45 }], }); expect(esito.ok).toBe(true); if (!esito.ok) throw new Error(esito.errore); const dettaglio = misureDellaSessione(db, esito.sessioneId); expect(dettaglio?.tipo).toBe('checkup'); expect(dettaglio?.operatore).toBe('nicola'); // 'Forza & Struttura' precede 'Composizione Corporea' nei pesi (PESI_ASSE, registro.ts): // stesso ordine che userebbe campiDelCheckup per raggruppare il modulo. expect(dettaglio?.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg', 'f_peso_kg']); expect(dettaglio?.misure[1]).toMatchObject({ etichetta: 'Peso corporeo', valore_num: 80, verifica: null }); }); }); describe('correggiCheckup: sovrascrittura, nessuno storico delle misure (#12)', () => { it('corregge un valore: misura aggiornata, score della sessione ricalcolato; la successiva viene ricalcolata, la precedente resta intatta', () => { const db = dbPronto(); const precedente = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'nicola', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); const target = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-10', eta: 38, operatore: 'nicola', misure: [{ test_id: 'f_peso_kg', valore_num: 82 }], }); const successiva = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-20', eta: 38, operatore: 'nicola', misure: [{ test_id: 'f_peso_kg', valore_num: 83 }], }); expect(precedente.ok && target.ok && successiva.ok).toBe(true); if (!precedente.ok || !target.ok || !successiva.ok) throw new Error('setup fallito'); const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n; const primaPrecedente = contaScore(precedente.sessioneId); const primaTarget = contaScore(target.sessioneId); const primaSuccessiva = contaScore(successiva.sessioneId); const esito = correggiCheckup(db, { sessioneId: target.sessioneId, data: '2026-09-10', eta: 38, operatore: 'donata', misure: [{ test_id: 'f_peso_kg', valore_num: 90 }], }); expect(esito.ok).toBe(true); if (!esito.ok) throw new Error(esito.errore); expect(esito.sessioneId).toBe(target.sessioneId); const riga = db.prepare( `SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` ).get(target.sessioneId) as { valore_num: number }; expect(riga.valore_num).toBe(90); const sessioneAggiornata = db.prepare(`SELECT operatore FROM sessioni WHERE id = ?`).get(target.sessioneId) as { operatore: string }; expect(sessioneAggiornata.operatore).toBe('donata'); // l'operatore diventa chi corregge // score ricalcolato (righe nuove in append): target e successiva sì, precedente no. expect(contaScore(target.sessioneId)).toBeGreaterThan(primaTarget); expect(contaScore(successiva.sessioneId)).toBeGreaterThan(primaSuccessiva); expect(contaScore(precedente.sessioneId)).toBe(primaPrecedente); }); it('campo svuotato cancella la misura; campo nuovo la inserisce con fonte manuale', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); expect(esito.ok).toBe(true); if (!esito.ok) throw new Error(esito.errore); const corretto = correggiCheckup(db, { sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola', // f_peso_kg non c'è più (campo svuotato nel form), f_handgrip_kg è nuovo. misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }], }); expect(corretto.ok).toBe(true); const righe = db.prepare(`SELECT test_id, fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as { test_id: string; fonte: string }[]; expect(righe).toEqual([{ test_id: 'f_handgrip_kg', fonte: 'manuale' }]); }); it('valore invariato conserva la verifica; valore cambiato azzera la verifica e ricalcola fuori_range', () => { const db = dbPronto(); const esito = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola', // 500 kg è oltre il range di plausibilità (max 250): fuori range da subito. misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 45 }], }); expect(esito.ok).toBe(true); if (!esito.ok) throw new Error(esito.errore); const misuraPeso = db.prepare( `SELECT id FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` ).get(esito.sessioneId) as { id: number }; const conferma = verificaMisura(db, { misuraId: misuraPeso.id, esito: 'confermata', operatore: 'nicola' }); expect(conferma.ok).toBe(true); // Correzione: f_peso_kg invariato (500), f_handgrip_kg cambiato (45 -> 50). const corretto = correggiCheckup(db, { sessioneId: esito.sessioneId, data: '2026-09-26', eta: 38, operatore: 'nicola', misure: [{ test_id: 'f_peso_kg', valore_num: 500 }, { test_id: 'f_handgrip_kg', valore_num: 50 }], }); expect(corretto.ok).toBe(true); const pesoDopo = db.prepare( `SELECT verifica, fuori_range FROM misure WHERE id = ?` ).get(misuraPeso.id) as { verifica: string | null; fuori_range: number }; expect(pesoDopo.verifica).toBe('confermata'); // invariato: la verifica resta expect(pesoDopo.fuori_range).toBe(1); const handgripDopo = db.prepare( `SELECT valore_num, fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_handgrip_kg'` ).get(esito.sessioneId) as { valore_num: number; fuori_range: number }; expect(handgripDopo.valore_num).toBe(50); expect(handgripDopo.fuori_range).toBe(0); // 50 è nel range (0-100): ricalcolato, non ereditato }); it('cambio data che sposta la sessione dopo un\'altra: entrambe ricalcolate', () => { const db = dbPronto(); const a = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); const b = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-15', eta: 38, operatore: 'n', misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }], }); expect(a.ok && b.ok).toBe(true); if (!a.ok || !b.ok) throw new Error('setup fallito'); const contaScore = (id: number) => (db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(id) as { n: number }).n; const primaA = contaScore(a.sessioneId); const primaB = contaScore(b.sessioneId); // Sposta A dopo B: la posizione minima (min fra vecchia e nuova data di A) resta // 2026-09-01, quindi la ricalcolata copre sia A sia B. const esito = correggiCheckup(db, { sessioneId: a.sessioneId, data: '2026-09-20', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); expect(esito.ok).toBe(true); expect(contaScore(a.sessioneId)).toBeGreaterThan(primaA); expect(contaScore(b.sessioneId)).toBeGreaterThan(primaB); }); it('rifiuti: sessione inesistente, sessione questionario, nessuna misura, test non f_* — nessuna scrittura', () => { const db = dbPronto(); const inesistente = correggiCheckup(db, { sessioneId: 999999, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); expect(inesistente.ok).toBe(false); const q = salvaDalForm(db, { client_code: 'ISL-0001', data: '2026-09-01', eta: 38, risposte: { q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1 }, consensoSanitario: 'Si', liberi: {}, }); expect(q.ok).toBe(true); if (!q.ok) throw new Error('setup questionario fallito'); const suQuestionario = correggiCheckup(db, { sessioneId: q.sessioneId, data: '2026-09-01', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); expect(suQuestionario.ok).toBe(false); const checkup = salvaCheckup(db, { client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'f_peso_kg', valore_num: 80 }], }); expect(checkup.ok).toBe(true); if (!checkup.ok) throw new Error('setup checkup fallito'); const nessunaMisura = correggiCheckup(db, { sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [], }); expect(nessunaMisura.ok).toBe(false); const nonFisico = correggiCheckup(db, { sessioneId: checkup.sessioneId, data: '2026-09-26', eta: 38, operatore: 'n', misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }], }); expect(nonFisico.ok).toBe(false); // Nessuna delle chiamate rifiutate ha scritto: la misura del checkup è ancora quella originale. const rigaFinale = db.prepare( `SELECT valore_num FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'` ).get(checkup.sessioneId) as { valore_num: number }; expect(rigaFinale.valore_num).toBe(80); }); }); describe('anagrafica per il gestionale, versioni parametrizzate (testabili su :memory:, come codicePerUtente accanto a codicePerUtenteLoggato)', () => { it('elencoClientiParam ordina per cognome poi nome, e dice chi ha già un accesso', () => { const id = createIdentityDb(':memory:'); const lg = createLongevityDb(':memory:'); const anna = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F' }); const bruno = creaCliente(id, lg, { nome: 'Bruno', cognome: 'Rossi', sesso: 'M', user_id: 7 }); const elenco = elencoClientiParam(id); expect(elenco.map((c) => c.client_code)).toEqual([bruno, anna]); // Rossi prima di Verdi expect(elenco.find((c) => c.client_code === bruno)!.ha_accesso).toBe(true); expect(elenco.find((c) => c.client_code === anna)!.ha_accesso).toBe(false); }); it('clienteDaCodiceParam restituisce il dettaglio, null se il codice non esiste', () => { const id = createIdentityDb(':memory:'); const lg = createLongevityDb(':memory:'); const code = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F', email: 'a@b.it' }); const dettaglio = clienteDaCodiceParam(id, code); expect(dettaglio?.email).toBe('a@b.it'); expect(dettaglio?.ha_accesso).toBe(false); expect(clienteDaCodiceParam(id, 'ISL-9999')).toBeNull(); }); it('etaDelClienteParam calcola l età alla data, null senza data di nascita in anagrafica', () => { const id = createIdentityDb(':memory:'); const lg = createLongevityDb(':memory:'); const conData = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F', data_nascita: '1988-03-04' }); const senzaData = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' }); expect(etaDelClienteParam(id, conData, '2026-08-21')).toBe(38); expect(etaDelClienteParam(id, senzaData, '2026-08-21')).toBeNull(); }); }); describe('apriFascicolo, la versione operativa sul singleton', () => { it('apre un fascicolo nuovo senza mai collegare uno user_id (l accesso è #8, non questa unità)', () => { const dir = mkdtempSync(join(tmpdir(), 'lg-gestionale-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 { // Stesso trucco di tests/longevity/questionario-pagina.test.ts: il singleton di // getIdentityDb/getLongevityDb si crea alla prima chiamata, quindi le variabili // d'ambiente vanno impostate PRIMA — e questo deve restare l'unico test del file // che chiama una funzione dell'anagrafica sul singleton, non su un DB :memory: proprio. process.env.IDENTITY_DB_PATH = idPath; process.env.LONGEVITY_DB_PATH = lgPath; const code = apriFascicolo({ nome: 'Nuovo', cognome: 'Cliente', sesso: 'F' }); expect(code).toMatch(/^ISL-\d{4}$/); const id = createIdentityDb(idPath); const riga = id.prepare(`SELECT user_id FROM clienti WHERE client_code = ?`).get(code) as { user_id: number | null }; expect(riga.user_id).toBeNull(); } 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 }); } }); }); describe('misureDalForm, il parsing puro del modulo di check-up', () => { const campiAttivi = [ { test_id: 'f_peso_kg', unita: 'kg' }, { test_id: 'f_handgrip_kg', unita: 'kg' }, ]; function formCon(dati: Record): FormData { const f = new FormData(); for (const [k, v] of Object.entries(dati)) f.set(k, v); return f; } it('accetta la virgola decimale', () => { const r = misureDalForm(formCon({ f_peso_kg: '80,5' }), campiAttivi); expect(r.ok).toBe(true); if (r.ok) expect(r.misure).toEqual([{ test_id: 'f_peso_kg', valore_num: 80.5, unita: 'kg' }]); }); it('un campo vuoto = non misurato, nessuna misura per quella chiave', () => { const r = misureDalForm(formCon({ f_peso_kg: '', f_handgrip_kg: '45' }), campiAttivi); expect(r.ok).toBe(true); if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg']); }); it('un valore non numerico ferma tutto: ok:false', () => { const r = misureDalForm(formCon({ f_peso_kg: 'ottanta' }), campiAttivi); expect(r.ok).toBe(false); }); it('una chiave che non è fra i campi attivi (client_code, un q_*, un refuso) viene ignorata in silenzio', () => { const r = misureDalForm( formCon({ client_code: 'ISL-0001', q_ore_sonno: '7', f_peso_kg: '80' }), campiAttivi ); expect(r.ok).toBe(true); if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_peso_kg']); }); }); describe('canAccessPath: il check-up è del trainer, non del cliente', () => { it('/api/longevity/gestionale/checkup nega il cliente, ammette il trainer', () => { expect(canAccessPath('cliente', '/api/longevity/gestionale/checkup')).toBe(false); expect(canAccessPath('trainer', '/api/longevity/gestionale/checkup')).toBe(true); }); });