// Issue #7: verifica delle misure fuori range (confermata/scartata), migrazione dello // schema, e ricalcolo dei punteggi congelati che una conferma cambia. import { describe, it, expect } from 'vitest'; import Database from 'better-sqlite3'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createLongevityDb } from '../../src/lib/longevity/db'; import { seedRegistro } from '../../src/lib/longevity/registro'; import { apriSessione, registraMisure, verificaMisura } from '../../src/lib/longevity/misure'; import { leggiScore, calcolaSessione, salvaScore } from '../../src/lib/longevity/motore'; import { canAccessPath } from '../../src/lib/auth'; function dbPronto() { const db = createLongevityDb(':memory:'); seedRegistro(db); db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); return db; } describe('migrazione: un longevity.db esistente riceve le tre colonne di verifica', () => { it('aggiunge verifica/verificata_da/verificata_at via ALTER TABLE, senza perdere le righe', () => { const dir = mkdtempSync(join(tmpdir(), 'lg-migra-test-')); const path = join(dir, 'longevity.db'); try { // Schema PRIMA di questa issue: la tabella misure senza le colonne di verifica — // lo stesso longevity.db che gira in produzione dal 03/09/2026 (vedi commento su // migra() in db.ts). Costruito a mano, non con createLongevityDb, che oggi crea // gia' le colonne nuove. // Le altre tabelle sono quelle vere (SCHEMA_LONGEVITY in db.ts, invariate da questa // issue): solo `misure` qui e' la versione vecchia, senza le tre colonne. Con uno // schema ridotto a mano seedRegistro/seedPesi (chiamati da createLongevityDb subito // dopo la migrazione) fallirebbero su colonne che si aspettano e non trovano. const vecchio = new Database(path); vecchio.pragma('foreign_keys = ON'); vecchio.exec(` CREATE TABLE soggetti ( client_code TEXT PRIMARY KEY, sesso TEXT NOT NULL CHECK (sesso IN ('M','F')), created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE registro_test ( test_id TEXT PRIMARY KEY, etichetta TEXT NOT NULL, unita TEXT, tipo_valore TEXT NOT NULL CHECK (tipo_valore IN ('num','txt')), curva TEXT, params TEXT, asse TEXT, sotto_dominio TEXT, range_min REAL, range_max REAL, attivo_da TEXT NOT NULL, attivo_a TEXT, note TEXT ); CREATE TABLE sessioni ( id INTEGER PRIMARY KEY AUTOINCREMENT, client_code TEXT NOT NULL REFERENCES soggetti(client_code), data TEXT NOT NULL, tipo TEXT NOT NULL CHECK (tipo IN ('checkup','questionario')), eta_alla_data INTEGER, operatore TEXT, note TEXT, quest_version TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE misure ( id INTEGER PRIMARY KEY AUTOINCREMENT, sessione_id INTEGER NOT NULL REFERENCES sessioni(id) ON DELETE CASCADE, client_code TEXT NOT NULL REFERENCES soggetti(client_code), test_id TEXT NOT NULL REFERENCES registro_test(test_id), valore_num REAL, valore_txt TEXT, unita TEXT, fonte TEXT NOT NULL CHECK (fonte IN ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), fuori_range INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); `); vecchio.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run(); vecchio.prepare( `INSERT INTO registro_test (test_id, etichetta, tipo_valore, attivo_da) VALUES ('q_ore_sonno','Ore di sonno','num','2026-01-01')` ).run(); vecchio.prepare(`INSERT INTO sessioni (client_code, data, tipo) VALUES ('ISL-0001','2026-08-21','questionario')`).run(); vecchio.prepare( `INSERT INTO misure (sessione_id, client_code, test_id, valore_num, fonte, fuori_range) VALUES (1, 'ISL-0001', 'q_ore_sonno', 26, 'questionario', 1)` ).run(); vecchio.close(); const db = createLongevityDb(path); const colonne = (db.prepare(`PRAGMA table_info(misure)`).all() as { name: string }[]).map((c) => c.name); expect(colonne).toEqual(expect.arrayContaining(['verifica', 'verificata_da', 'verificata_at'])); const riga = db.prepare( `SELECT valore_num, fuori_range, verifica FROM misure WHERE test_id = 'q_ore_sonno'` ).get() as { valore_num: number; fuori_range: number; verifica: string | null }; expect(riga.valore_num).toBe(26); expect(riga.fuori_range).toBe(1); expect(riga.verifica).toBeNull(); db.close(); // Riaprirla una seconda volta (le colonne ci sono gia') non deve fallire: migra() // deve saltare l'ALTER TABLE quando non serve, non ripeterlo alla cieca. const db2 = createLongevityDb(path); expect(() => db2.prepare(`SELECT verifica, verificata_da, verificata_at FROM misure`).all()).not.toThrow(); db2.close(); } finally { rmSync(dir, { recursive: true, force: true }); } }); }); describe('verificaMisura: conferma', () => { it('una misura fuori range confermata entra nello score, e la sessione successiva che la eredita viene ricalcolata', () => { const db = dbPronto(); const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); // fuori range (0-14) salvaScore(db, s1, calcolaSessione(db, s1)); const s2 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-15', tipo: 'questionario' }); salvaScore(db, s2, calcolaSessione(db, s2)); // Baseline: 'questionario_sonno' pesa 0.20 nell'asse 'Recupero & Sistema Nervoso' // (registro.ts, PESI_ASSE) e non e' coperto ne' da s1 (misura esclusa, fuori range non // confermato) ne' da s2 (che la erediterebbe da s1 se fosse valida). const asse = 'Recupero & Sistema Nervoso'; expect(leggiScore(db, s1).assi[asse].copertura).toBe(0); expect(leggiScore(db, s2).assi[asse].copertura).toBe(0); const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; const esito = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' }); expect(esito.ok).toBe(true); if (esito.ok) expect(esito.sessioniRicalcolate.sort((a, b) => a - b)).toEqual([s1, s2].sort((a, b) => a - b)); const rigaMisura = db.prepare(`SELECT verifica, verificata_da FROM misure WHERE id = ?`).get(misura.id) as { verifica: string; verificata_da: string }; expect(rigaMisura.verifica).toBe('confermata'); expect(rigaMisura.verificata_da).toBe('nicola'); // Dopo la conferma la copertura sale a 0.20 (il peso di 'questionario_sonno') sia // nella sessione della misura sia in quella successiva, che la eredita da // misureValideAlla — senza aver ripetuto il test. expect(leggiScore(db, s1).assi[asse].copertura).toBe(0.2); expect(leggiScore(db, s2).assi[asse].copertura).toBe(0.2); }); }); describe('verificaMisura: scarto', () => { it('una misura scartata resta esclusa, e non scrive nessuna riga nuova in score', () => { const db = dbPronto(); const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); const primaN = (db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number }).n; const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; const esito = verificaMisura(db, { misuraId: misura.id, esito: 'scartata', operatore: 'nicola' }); expect(esito.ok).toBe(true); if (esito.ok) expect(esito.sessioniRicalcolate).toEqual([]); const dopoN = (db.prepare(`SELECT COUNT(*) n FROM score`).get() as { n: number }).n; expect(dopoN).toBe(primaN); // nessun ricalcolo per uno scarto const rigaMisura = db.prepare(`SELECT verifica FROM misure WHERE id = ?`).get(misura.id) as { verifica: string }; expect(rigaMisura.verifica).toBe('scartata'); // Ancora esclusa da misureValideAlla, esattamente come prima della verifica. const dominio = calcolaSessione(db, s1); expect(dominio.assi['Recupero & Sistema Nervoso'].copertura).toBe(0); }); }); describe('verificaMisura: rifiuti', () => { it('rifiuta una seconda verifica sulla stessa misura', () => { const db = dbPronto(); const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 26 }]); const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; const prima = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' }); expect(prima.ok).toBe(true); const seconda = verificaMisura(db, { misuraId: misura.id, esito: 'scartata', operatore: 'donata' }); expect(seconda.ok).toBe(false); if (!seconda.ok) expect(seconda.errore).toMatch(/gia.*verificat/i); // La seconda chiamata non ha scritto niente: resta la decisione della prima. const riga = db.prepare(`SELECT verifica, verificata_da FROM misure WHERE id = ?`).get(misura.id) as { verifica: string; verificata_da: string }; expect(riga.verifica).toBe('confermata'); expect(riga.verificata_da).toBe('nicola'); }); it('rifiuta una misura che e gia nel range: non c e niente da verificare', () => { const db = dbPronto(); const s1 = apriSessione(db, { client_code: 'ISL-0001', data: '2026-08-01', tipo: 'questionario' }); registraMisure(db, s1, 'questionario', [{ test_id: 'q_ore_sonno', valore_num: 7.5 }]); // nel range 0-14 const misura = db.prepare(`SELECT id FROM misure WHERE test_id = 'q_ore_sonno'`).get() as { id: number }; const esito = verificaMisura(db, { misuraId: misura.id, esito: 'confermata', operatore: 'nicola' }); expect(esito.ok).toBe(false); const riga = db.prepare(`SELECT verifica FROM misure WHERE id = ?`).get(misura.id) as { verifica: string | null }; expect(riga.verifica).toBeNull(); // nessuna scrittura }); it('rifiuta un id di misura inesistente', () => { const db = dbPronto(); const esito = verificaMisura(db, { misuraId: 999999, esito: 'confermata', operatore: 'nicola' }); expect(esito.ok).toBe(false); if (!esito.ok) expect(esito.errore).toMatch(/inesistente/); }); }); describe('accesso: la verifica e roba da trainer, non da cliente', () => { it('canAccessPath chiude /api/longevity/gestionale/verifica al cliente e la apre al trainer', () => { expect(canAccessPath('cliente', '/api/longevity/gestionale/verifica')).toBe(false); expect(canAccessPath('trainer', '/api/longevity/gestionale/verifica')).toBe(true); }); });