// Issue #15: prima consulenza per cliente (solo staff, una scheda sola — correggere e // salvare sovrascrive, nessuno storico), e il flusso guidato per un cliente nuovo. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { createLongevityDb, getLongevityDb } from '../../src/lib/longevity/db'; import { salvaCheckup } from '../../src/lib/longevity/gestionale'; import { consulenzaDi, salvaConsulenza, OBIETTIVI } from '../../src/lib/longevity/consulenza'; function dbPronto() { const db = createLongevityDb(':memory:'); db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); return db; } describe('salvaConsulenza e consulenzaDi', () => { it('inserisce e legge la scheda', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '2', obiettivoPrimario: 'dimagrimento', dataTarget: '2027-01-01', disponibilitaGiorni: '3' }, }); expect(esito.ok).toBe(true); const scheda = consulenzaDi(db, 'ISL-0001'); expect(scheda).not.toBeNull(); expect(scheda?.livello).toBe(2); expect(scheda?.obiettivo_primario).toBe('dimagrimento'); expect(scheda?.data_target).toBe('2027-01-01'); expect(scheda?.disponibilita_giorni).toBe(3); expect(scheda?.aggiornata_da).toBe('nicola'); }); it('null se non compilata', () => { const db = dbPronto(); expect(consulenzaDi(db, 'ISL-0001')).toBeNull(); }); it('una seconda chiamata sovrascrive la prima: una riga sola, nessuno storico', () => { const db = dbPronto(); salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } }); salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'donata', campi: { livello: '3' } }); const righe = (db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n; expect(righe).toBe(1); const scheda = consulenzaDi(db, 'ISL-0001'); expect(scheda?.livello).toBe(3); expect(scheda?.aggiornata_da).toBe('donata'); }); it('stringhe vuote diventano NULL', () => { const db = dbPronto(); salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoSpecifico: ' ', noteMediche: '', fasciaOraria: undefined }, }); const scheda = consulenzaDi(db, 'ISL-0001'); expect(scheda?.obiettivo_specifico).toBeNull(); expect(scheda?.note_mediche).toBeNull(); expect(scheda?.fascia_oraria).toBeNull(); }); it('tutti i campi sono facoltativi: una scheda vuota è valida', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: {} }); expect(esito.ok).toBe(true); }); }); describe('salvaConsulenza: rifiuti, nessuna scrittura', () => { const righe = (db: ReturnType) => (db.prepare(`SELECT COUNT(*) n FROM consulenza`).get() as { n: number }).n; it('rifiuta un cliente inesistente', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-9999', autore: 'nicola', campi: {} }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); it('rifiuta un livello fuori 1-3', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '4' } }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); it('rifiuta un livello non intero', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '2.5' } }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); it('rifiuta un obiettivo primario fuori elenco', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: 'volare' } }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); it('accetta ogni chiave dell elenco OBIETTIVI', () => { const db = dbPronto(); for (const o of OBIETTIVI) { const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { obiettivoPrimario: o.chiave } }); expect(esito.ok, o.chiave).toBe(true); } }); it('rifiuta una data target malformata', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { dataTarget: '01-01-2027' } }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); it('rifiuta una disponibilita fuori 0-7', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '8' } }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); it('rifiuta una disponibilita non intera', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { disponibilitaGiorni: '2,5' } }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); it('rifiuta un testo libero troppo lungo', () => { const db = dbPronto(); const esito = salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { noteMediche: 'x'.repeat(5001) } }); expect(esito.ok).toBe(false); expect(righe(db)).toBe(0); }); }); describe('schema: la tabella consulenza si crea su un longevity.db preesistente senza di essa', () => { it('CREATE TABLE IF NOT EXISTS basta: nessuna migrazione a mano serve', () => { const db = createLongevityDb(':memory:'); db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001', 'F')`).run(); expect(salvaConsulenza(db, { clientCode: 'ISL-0001', autore: 'nicola', campi: { livello: '1' } }).ok).toBe(true); expect(() => createLongevityDb(':memory:')).not.toThrow(); }); }); // Le API sotto (consulenza.ts e checkup.ts) chiamano SEMPRE getLongevityDb(), il singleton // di db.ts: aprirlo e chiuderlo dentro ogni singolo test (come fa correzione-checkup-api.test.ts // da solo nel suo file) romperebbe i test successivi dello STESSO file, perché il singleton // resta quello chiuso finché il modulo non si ricarica. Qui si apre una volta sola per // tutte le API, con beforeAll/afterAll, e ogni test usa un client_code diverso per non // pestarsi i piedi — stesso principio, applicato a un file con più test sullo stesso // singleton invece di uno solo. describe('API del flusso guidato (consulenza.ts, checkup.ts)', () => { let dir: string; let db: ReturnType; let primaLg: string | undefined; beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'lg-api-flusso-test-')); primaLg = process.env.LONGEVITY_DB_PATH; process.env.LONGEVITY_DB_PATH = join(dir, 'longevity.db'); db = getLongevityDb(); for (const code of ['ISL-0001', 'ISL-0002', 'ISL-0003', 'ISL-0004']) { db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, 'F')`).run(code); } }); afterAll(() => { db.close(); // libera l'handle sul file: senza, rmSync fallisce con EPERM su Windows if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg; rmSync(dir, { recursive: true, force: true }); }); it('consulenza.ts: salva con autore da locals.user (mai dal form) e torna alla scheda cliente, fuori dal flusso', async () => { const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza'); const form = new FormData(); form.set('client_code', 'ISL-0001'); form.set('livello', '2'); form.set('autore', 'un-impostore'); // ignorato: deve vincere locals.user const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form }); const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); expect(res.status).toBe(303); expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0001?consulenza=salvata#consulenza'); const riga = db.prepare(`SELECT aggiornata_da FROM consulenza WHERE client_code = 'ISL-0001'`).get() as { aggiornata_da: string }; expect(riga.aggiornata_da).toBe('nicola'); }); it('consulenza.ts: con flusso=nuovo prosegue verso il check-up', async () => { const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza'); const form = new FormData(); form.set('client_code', 'ISL-0002'); form.set('flusso', 'nuovo'); const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form }); const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); expect(res.headers.get('Location')).toBe('/longevity/gestionale/cliente/ISL-0002/checkup?flusso=nuovo'); }); it('consulenza.ts: client_code inesistente, errore, torna al modulo conservando il flusso', async () => { const { POST } = await import('../../src/pages/api/longevity/gestionale/consulenza'); const form = new FormData(); form.set('client_code', 'ISL-9999'); form.set('flusso', 'nuovo'); const richiesta = new Request('http://localhost/api/longevity/gestionale/consulenza', { method: 'POST', body: form }); const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); expect(res.status).toBe(303); const loc = res.headers.get('Location')!; expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-9999\/consulenza\?/); expect(loc).toMatch(/flusso=nuovo/); expect(loc).toMatch(/errore=/); }); it('checkup.ts: un check-up NUOVO con flusso=nuovo redirige con flusso=questionario', async () => { const { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); const form = new FormData(); form.set('client_code', 'ISL-0003'); form.set('data', '2026-09-26'); form.set('flusso', 'nuovo'); form.set('f_peso_kg', '80'); // salvaCheckup rifiuta un check-up senza nessuna misura const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form }); const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); expect(res.status).toBe(303); const loc = res.headers.get('Location')!; expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0003\?/); expect(loc).toMatch(/salvato=/); expect(loc).toMatch(/flusso=questionario/); }); it('checkup.ts: una correzione (sessione_id) ignora flusso, nessun flusso nel redirect', async () => { const esito = salvaCheckup(db, { client_code: 'ISL-0004', data: '2026-09-20', eta: 40, 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 { POST } = await import('../../src/pages/api/longevity/gestionale/checkup'); const form = new FormData(); form.set('client_code', 'ISL-0004'); form.set('data', '2026-09-20'); form.set('sessione_id', String(esito.sessioneId)); form.set('f_peso_kg', '82'); form.set('flusso', 'nuovo'); // ignorato: una correzione non è mai nel flusso guidato const richiesta = new Request('http://localhost/api/longevity/gestionale/checkup', { method: 'POST', body: form }); const res = await POST({ request: richiesta, locals: { user: { username: 'nicola' } } } as any); const loc = res.headers.get('Location')!; expect(loc).toMatch(/^\/longevity\/gestionale\/cliente\/ISL-0004\?/); expect(loc).toMatch(/corretto=/); expect(loc).not.toMatch(/flusso=/); }); }); describe('clienti.ts: dopo la creazione il flusso guidato comincia dalla consulenza', () => { it('redirige a .../consulenza?flusso=nuovo, non piu direttamente alla scheda', () => { const fs = require('node:fs') as typeof import('node:fs'); const path = require('node:path') as typeof import('node:path'); const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/api/longevity/gestionale/clienti.ts'), 'utf8'); expect(src).toMatch(/\/consulenza\?flusso=nuovo/); }); }); describe('nessuna pagina cliente importa la consulenza', () => { it('io.astro, progressione.astro e questionario.astro non fanno riferimento a consulenza.ts', () => { const fs = require('node:fs') as typeof import('node:fs'); const path = require('node:path') as typeof import('node:path'); const pagine = ['io.astro', 'progressione.astro', 'questionario.astro']; for (const p of pagine) { const src = fs.readFileSync(path.join(process.cwd(), 'src/pages/longevity', p), 'utf8'); expect(src, `${p} non deve importare consulenza.ts`).not.toMatch(/consulenza/i); } }); });