Files
InsanityLab-web/apps/platforms/tests/longevity/verifica-misure.test.ts
T
Adriano 922ab40eb2 longevity: verificaMisura, misureDaVerificare, endpoint di verifica dal gestionale
misure.ts: misureDaVerificare(db, clientCode) legge le misure fuori range mai
verificate; verificaMisura(db, {misuraId, esito, operatore}) conferma o scarta.
La conferma ricalcola e ricongela lo score (salvaScore + calcolaSessione) per
la sessione della misura e ogni sessione successiva dello stesso cliente, che
puo' averla ereditata via misureValideAlla; lo scarto non tocca score. Rifiuta
misura inesistente, gia' nel range, o gia' verificata.

pages/api/longevity/gestionale/verifica.ts: POST da form (misura_id, esito),
operatore dalla sessione (locals.user.username). Il client_code del redirect
si legge dalla misura nel database, mai dal form.

tests/longevity/verifica-misure.test.ts: migrazione (ALTER TABLE su un
longevity.db vecchio, idempotente), conferma che ricalcola la sessione e le
successive con sessioniRicalcolate, scarto che non scrive in score, i rifiuti
(doppia verifica, misura nel range, id inesistente), canAccessPath per
cliente/trainer sulla nuova rotta.

Suite: 219 passed (212 + 7 nuovi). tsc --noEmit: 0 errori.

Refs #7

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 13:02:16 +00:00

227 lines
11 KiB
TypeScript

// 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);
});
});