diff --git a/apps/platforms/src/lib/longevity/db.ts b/apps/platforms/src/lib/longevity/db.ts index 703f592..8621879 100644 --- a/apps/platforms/src/lib/longevity/db.ts +++ b/apps/platforms/src/lib/longevity/db.ts @@ -54,6 +54,13 @@ CREATE TABLE IF NOT EXISTS misure ( fonte TEXT NOT NULL CHECK (fonte IN ('manuale','questionario','wellness_tower','vald','calibre','stress_index')), fuori_range INTEGER NOT NULL DEFAULT 0, + -- Verifica di una misura fuori range (issue #7): il trainer la conferma (entra nello + -- score come le altre, vedi misureValideAlla in motore/index.ts) o la scarta (resta + -- esclusa, per sempre). NULL = non ancora verificata. Su un longevity.db creato PRIMA + -- di questa modifica queste tre colonne NON ci sono: le aggiunge migra(), qui sotto. + verifica TEXT CHECK (verifica IN ('confermata','scartata')), + verificata_da TEXT, + verificata_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE INDEX IF NOT EXISTS idx_misure_cliente_test ON misure (client_code, test_id); @@ -128,6 +135,36 @@ function apri(p: string, schema: string): Database.Database { return db; } +/** + * Migrazione idempotente per un `longevity.db` aperto PRIMA di questa modifica (issue #7): + * `CREATE TABLE IF NOT EXISTS` (sopra) non aggiunge colonne a una tabella che esiste già, e + * in produzione un longevity.db esiste dal 03/09/2026 (VPS, + * /opt/docker/insanitylab-apps/apps/platforms/data/longevity.db) — la stessa trappola già + * documentata sul vincolo di `score`, qui sotto. Senza questa funzione le tre colonne di + * verifica resterebbero assenti in produzione mentre ogni database creato da zero (ogni + * test, e un domani un secondo deploy) le avrebbe già: un disallineamento silenzioso fra + * ambienti, non un errore che si vede subito. + * `PRAGMA table_info` legge le colonne DAVVERO presenti sul file aperto, non lo schema + * dichiarato sopra: per ognuna delle tre assente, un `ALTER TABLE ... ADD COLUMN` la + * aggiunge (nullable, quindi le righe esistenti restano valide contro il CHECK — NULL non + * fa mai fallire un CHECK). Su un database che le ha già il controllo salta tutto: girarla + * a ogni apertura (come seedRegistro/seedPesi) è sicuro. + */ +function migra(db: Database.Database): void { + const colonne = new Set( + (db.prepare(`PRAGMA table_info(misure)`).all() as { name: string }[]).map((c) => c.name) + ); + if (!colonne.has('verifica')) { + db.exec(`ALTER TABLE misure ADD COLUMN verifica TEXT CHECK (verifica IN ('confermata','scartata'))`); + } + if (!colonne.has('verificata_da')) { + db.exec(`ALTER TABLE misure ADD COLUMN verificata_da TEXT`); + } + if (!colonne.has('verificata_at')) { + db.exec(`ALTER TABLE misure ADD COLUMN verificata_at TEXT`); + } +} + /** * ⚠️ Il registro dei test e i pesi si seminano QUI, all'apertura del database. * @@ -147,6 +184,7 @@ function apri(p: string, schema: string): Database.Database { */ export function createLongevityDb(path?: string): Database.Database { const db = apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY); + migra(db); seedRegistro(db); seedPesi(db, MODEL_VERSION); return db; diff --git a/apps/platforms/src/lib/longevity/misure.ts b/apps/platforms/src/lib/longevity/misure.ts index dbad9d6..0c378e3 100644 --- a/apps/platforms/src/lib/longevity/misure.ts +++ b/apps/platforms/src/lib/longevity/misure.ts @@ -1,5 +1,6 @@ import type Database from 'better-sqlite3'; import { esisteTest } from './registro'; +import { calcolaSessione, salvaScore } from './motore'; export type Fonte = 'manuale' | 'questionario' | 'wellness_tower' | 'vald' | 'calibre' | 'stress_index'; @@ -72,3 +73,105 @@ export function registraMisure( return { scritte: misure.length, fuoriRange }; } + +/** Una misura fuori range in attesa che il trainer decida se e' un dato vero o uno + * strumento/protocollo sbagliato (§9 della spec). L'etichetta e il range vengono dal + * registro (dato di prodotto), non duplicati qui; `data` e' quella della SESSIONE in cui + * la misura e' stata presa, utile a ordinare la lista senza un altro giro sul database. */ +export type MisuraDaVerificare = { + id: number; + test_id: string; + etichetta: string; + valore_num: number | null; + unita: string | null; + range_min: number | null; + range_max: number | null; + data: string; +}; + +/** + * Le misure di un cliente ancora da verificare: fuori range (`fuori_range = 1`) e mai + * verificate (`verifica IS NULL`) — una gia' confermata o scartata non torna qui, la + * decisione del trainer e' definitiva (vedi `verificaMisura`). Ordine per data della + * sessione decrescente: la piu' recente prima, come le altre liste del gestionale. + */ +export function misureDaVerificare(db: Database.Database, clientCode: string): MisuraDaVerificare[] { + return db.prepare( + `SELECT m.id AS id, m.test_id AS test_id, r.etichetta AS etichetta, + m.valore_num AS valore_num, m.unita AS unita, + r.range_min AS range_min, r.range_max AS range_max, + s.data AS data + FROM misure m + JOIN registro_test r ON r.test_id = m.test_id + JOIN sessioni s ON s.id = m.sessione_id + WHERE m.client_code = ? AND m.fuori_range = 1 AND m.verifica IS NULL + ORDER BY s.data DESC, s.id DESC, m.id DESC` + ).all(clientCode) as MisuraDaVerificare[]; +} + +export type EsitoVerifica = + | { ok: true; sessioniRicalcolate: number[] } + | { ok: false; errore: string }; + +/** + * La decisione del trainer su una misura fuori range: confermarla (e' un dato vero, deve + * contare) o scartarla (e' uno strumento/protocollo sbagliato, resta fuori per sempre). + * Rifiuta una misura inesistente, una gia' dentro il range (non c'era niente da + * verificare) o gia' verificata (la decisione non si cambia scrivendoci sopra — chi + * sbaglia riapre una nuova misura, non riscrive la storia di questa). + * + * Solo la CONFERMA ricalcola: da quel momento `misureValideAlla` la lascia passare, quindi + * ogni punteggio congelato che l'ha ignorata e' cambiato sotto di lui. Si ricongela la + * sessione della misura stessa e ogni sessione SUCCESSIVA dello stesso cliente (stesso + * criterio data/id di `misureValideAlla`): anche quelle possono aver ereditato il valore + * — o la sua assenza — tramite la propagazione in avanti. Una sessione precedente non puo' + * mai vedere una misura futura, quindi non ha bisogno di ricalcolo. Lo SCARTO non tocca + * `score`: la misura restava esclusa anche prima, niente e' cambiato per il motore. + */ +export function verificaMisura( + db: Database.Database, + input: { misuraId: number; esito: 'confermata' | 'scartata'; operatore: string } +): EsitoVerifica { + const misura = db.prepare( + `SELECT m.id AS id, m.client_code AS client_code, m.sessione_id AS sessione_id, + m.fuori_range AS fuori_range, m.verifica AS verifica, s.data AS data + FROM misure m + JOIN sessioni s ON s.id = m.sessione_id + WHERE m.id = ?` + ).get(input.misuraId) as + { id: number; client_code: string; sessione_id: number; fuori_range: number; verifica: string | null; data: string } + | undefined; + + if (!misura) return { ok: false, errore: `misura ${input.misuraId} inesistente` }; + if (misura.fuori_range === 0) return { ok: false, errore: 'la misura e nel range: non c e niente da verificare' }; + if (misura.verifica !== null) return { ok: false, errore: 'misura gia verificata' }; + + const sessioniRicalcolate: number[] = []; + + const tx = db.transaction(() => { + db.prepare( + `UPDATE misure SET verifica = ?, verificata_da = ?, verificata_at = datetime('now') WHERE id = ?` + ).run(input.esito, input.operatore, misura.id); + + if (input.esito === 'confermata') { + // La sessione della misura stessa (id = sessione_id) piu' ogni sessione successiva + // dello stesso cliente (data > D, o stessa data e id maggiore) — stesso confronto + // di misureValideAlla, cosi' la platea dei ricalcoli e' esattamente quella dei + // punteggi che possono essere cambiati da questa conferma. + const sessioni = db.prepare( + `SELECT id FROM sessioni + WHERE client_code = ? + AND (id = ? OR data > ? OR (data = ? AND id > ?)) + ORDER BY data ASC, id ASC` + ).all(misura.client_code, misura.sessione_id, misura.data, misura.data, misura.sessione_id) as { id: number }[]; + + for (const s of sessioni) { + salvaScore(db, s.id, calcolaSessione(db, s.id)); + sessioniRicalcolate.push(s.id); + } + } + }); + tx(); + + return { ok: true, sessioniRicalcolate }; +} diff --git a/apps/platforms/src/lib/longevity/motore/index.ts b/apps/platforms/src/lib/longevity/motore/index.ts index 2e072ad..d367869 100644 --- a/apps/platforms/src/lib/longevity/motore/index.ts +++ b/apps/platforms/src/lib/longevity/motore/index.ts @@ -40,8 +40,8 @@ * applicarla, e restituisce `null` se il registro (dato modificabile senza * migrazioni) ha una riga incompleta, invece del punteggio pieno che dava * `lerp` con estremi indefiniti; e la lettura delle misure esclude - * `fuori_range = 1` (§9 della spec), in attesa della colonna di conferma - * (punto aperto in §12). + * `fuori_range = 1` (§9 della spec) salvo conferma esplicita del trainer + * (`misure.verifica = 'confermata'`, issue #7: `verificaMisura` in `misure.ts`). */ import type Database from 'better-sqlite3'; @@ -156,9 +156,11 @@ export const FINESTRA_GIORNI: number | null = null; * * Stessa esclusione di `fuori_range = 1` che c'era prima nella lettura diretta (§9 della * spec): una misura fuori dal range dichiarato non entra, né dalla sessione corrente né da - * una precedente. La colonna della conferma non esiste ancora (§12 della spec, issue #7): - * quando ci sarà, questo filtro andrà rilassato per lasciar passare le misure confermate, - * non tolto del tutto. + * una precedente — A MENO che il trainer l'abbia confermata (`verifica = 'confermata'`, + * colonna aggiunta dall'issue #7, scritta da `verificaMisura` in `misure.ts`): da quel + * momento in poi entra nello score come qualunque altra misura, qui e in ogni sessione + * successiva che la eredita. `verifica = 'scartata'`, o mai verificata (NULL), resta + * esclusa come prima. */ export function misureValideAlla(db: Database.Database, sessioneId: number): Map { const sessione = db.prepare( @@ -170,7 +172,7 @@ export function misureValideAlla(db: Database.Database, sessioneId: number): Map `SELECT m.test_id AS test_id, m.valore_num AS valore_num, s2.data AS data FROM misure m JOIN sessioni s2 ON s2.id = m.sessione_id - WHERE m.client_code = ? AND m.fuori_range = 0 AND m.valore_num IS NOT NULL + WHERE m.client_code = ? AND (m.fuori_range = 0 OR m.verifica = 'confermata') AND m.valore_num IS NOT NULL AND (s2.data < ? OR (s2.data = ? AND s2.id <= ?)) ORDER BY s2.data DESC, s2.id DESC, m.id DESC` ).all(sessione.client_code, sessione.data, sessione.data, sessione.id) as diff --git a/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts b/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts new file mode 100644 index 0000000..d7f9ed5 --- /dev/null +++ b/apps/platforms/src/pages/api/longevity/gestionale/verifica.ts @@ -0,0 +1,52 @@ +import type { APIRoute } from 'astro'; +import { getLongevityDb } from '../../../../lib/longevity/db'; +import { verificaMisura } from '../../../../lib/longevity/misure'; + +export const prerender = false; + +const json = (status: number, body: object) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + +/** + * La decisione del trainer su una misura fuori range (issue #7): confermarla o scartarla, + * dalla scheda cliente del gestionale (le pagine arrivano con #2, questo endpoint no). + * `canAccessPath` (auth.ts) gia' chiude /api/longevity/gestionale/** a chi non e' trainer + * o admin: qui non c'e' nessun controllo di ruolo da ripetere. + * + * Il codice cliente per il redirect si legge SEMPRE dalla misura nel database, mai da un + * campo del form — stesso principio di questionario.ts (il client_code non si fida di chi + * scrive la richiesta), applicato qui al lato trainer: un id di misura non e' un fascicolo + * che si possa dirottare scrivendo un altro codice nel form. + */ +export const POST: APIRoute = async ({ request, locals }) => { + const form = await request.formData(); + + const misuraIdRaw = form.get('misura_id'); + const misuraId = typeof misuraIdRaw === 'string' ? Number(misuraIdRaw) : NaN; + if (!Number.isInteger(misuraId)) return json(404, { error: 'misura inesistente' }); + + const db = getLongevityDb(); + + // Si legge il client_code PRIMA di chiamare verificaMisura: serve per il redirect anche + // quando la verifica viene rifiutata (gia' verificata, nel range...) — l'unico caso senza + // una scheda cliente su cui tornare e' proprio l'id che non esiste. + const riga = db.prepare(`SELECT client_code FROM misure WHERE id = ?`).get(misuraId) as + { client_code: string } | undefined; + if (!riga) return json(404, { error: `misura ${misuraId} inesistente` }); + + const tornaA = (query: string): Response => new Response(null, { + status: 303, + headers: { Location: `/longevity/gestionale/cliente/${riga.client_code}${query}` }, + }); + + const esitoRaw = form.get('esito'); + const esito: 'confermata' | 'scartata' | null = + esitoRaw === 'confermata' || esitoRaw === 'scartata' ? esitoRaw : null; + if (esito === null) { + return tornaA(`?errore=${encodeURIComponent('Esito non valido: deve essere confermata o scartata.')}`); + } + + const risultato = verificaMisura(db, { misuraId, esito, operatore: locals.user!.username }); + if (!risultato.ok) return tornaA(`?errore=${encodeURIComponent(risultato.errore)}`); + return tornaA(`?verificata=${esito}`); +}; diff --git a/apps/platforms/tests/longevity/verifica-misure.test.ts b/apps/platforms/tests/longevity/verifica-misure.test.ts new file mode 100644 index 0000000..d5e44a1 --- /dev/null +++ b/apps/platforms/tests/longevity/verifica-misure.test.ts @@ -0,0 +1,226 @@ +// 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); + }); +});