longevity: #15 libreria e schema della prima consulenza, con test
Tabella consulenza (client_code, UPSERT, nessuno storico) e modulo lib/longevity/consulenza.ts: consulenzaDi, salvaConsulenza con validazioni (livello 1-3, obiettivo nell'elenco chiuso, data AAAA-MM-GG, disponibilita 0-7, testo max 5000). 22 test: modulo, schema, API consulenza.ts e checkup.ts (flusso guidato), redirect di clienti.ts, nessuna pagina cliente importa consulenza. Refs #15 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
// 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<typeof dbPronto>) =>
|
||||
(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<typeof getLongevityDb>;
|
||||
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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -129,6 +129,7 @@ describe('il gestionale del trainer (Unità 2b)', () => {
|
||||
'src/pages/longevity/gestionale/nuovo.astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code].astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code]/checkup.astro',
|
||||
'src/pages/longevity/gestionale/cliente/[code]/consulenza.astro', // #15
|
||||
];
|
||||
|
||||
it('ogni pagina del gestionale esiste e usa la variante "gestionale" del layout', () => {
|
||||
|
||||
Reference in New Issue
Block a user