2c9a74f849
Scheda cliente ([code].astro): sezione "Misure da verificare" da misureDaVerificare (id, range) al posto di misureFuoriRange (nessun id, nessuna azione); ogni riga ha due form verso /api/longevity/gestionale/verifica (conferma/scarta), messaggio dopo la decisione, e link "Crea accesso al referto" quando il cliente non ha ancora un accesso. gestionale.ts: rimossa misureFuoriRange (e il tipo MisuraFuoriRange), sostituita ovunque da misureDaVerificare (misure.ts), l'unica con id e range utile alle azioni. accesso.astro: link "Torna alla scheda" in tutti gli stati. Refs #7 Refs #8 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
283 lines
13 KiB
TypeScript
283 lines
13 KiB
TypeScript
// 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 } from '../../src/lib/longevity/gestionale';
|
|
import { misureDaVerificare } from '../../src/lib/longevity/misure';
|
|
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('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<string, string>): 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);
|
|
});
|
|
});
|