3 Commits

Author SHA1 Message Date
Adriano 95ffa08197 longevity: API del gestionale (clienti, check-up) e test dell'unità 2a
pages/api/longevity/gestionale/clienti.ts: POST del nuovo fascicolo (nome,
cognome, sesso obbligatori; data di nascita/email/telefono facoltativi) →
apriFascicolo → 303 alla scheda cliente, o 303 al modulo con ?errore=.
pages/api/longevity/gestionale/checkup.ts: POST del check-up manuale, con
misureDalForm (funzione pura, esportata e testata) per il parsing del form —
virgola decimale accettata, campo vuoto = non misurato, valore non numerico
ferma tutto, chiavi che non sono campi f_* attivi ignorate. operatore sempre
da locals.user (mai dal corpo, stesso principio del client_code in
questionario.ts); eta da etaDelCliente. Entrambi riservati a admin+trainer da
RULES in auth.ts (già in vigore, verificato dal test su canAccessPath).

tests/longevity/gestionale.test.ts: 17 test — salvaCheckup e il congelamento
dei punteggi, il referto che combina check-up e questionario di sessioni
diverse (#1), i rifiuti prima della scrittura, le misure fuori range, le
versioni parametrizzate dell'anagrafica, apriFascicolo sul singleton (stesso
trucco env-var di questionario-pagina.test.ts), misureDalForm, canAccessPath.

vitest: 229 passed (212 di partenza + 17). tsc --noEmit: 0 errori.

Refs #2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:53:46 +00:00
Adriano 758463bd26 longevity: gestionale.ts — check-up manuale, sessioni e misure fuori range
campiDelCheckup raggruppa i test fisici attivi per asse nell'ordine dei pesi
(letto dal dato, come contenitoriDi in vista.ts). salvaCheckup segue lo stesso
schema di salvaDalForm: valida tutto (misure presenti, solo test f_*, cliente
esistente) prima di aprire qualunque cosa, poi un'unica transazione — apertura
sessione tipo 'checkup', registraMisure fonte 'manuale', calcolaSessione +
salvaScore — perché il congelamento dei punteggi è parte del salvataggio, non
un passo lasciato a chi legge dopo. sessioniDi e misureFuoriRange sono le due
letture per la scheda cliente del gestionale.

Refs #2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:53:35 +00:00
Adriano 8e0b9e4647 longevity: anagrafica del gestionale — elenco, dettaglio, età, apertura fascicolo
elencoClienti/clienteDaCodice/etaDelCliente/apriFascicolo (versione operativa,
sul singleton) affiancate alle rispettive versioni parametrizzate
(elencoClientiParam/clienteDaCodiceParam/etaDelClienteParam), sullo stesso
schema di codicePerUtente/codicePerUtenteLoggato: la parametrizzata resta
testabile su un DB :memory:, l'operativa è quella che pagine ed endpoint del
gestionale importano. apriFascicolo apre sempre senza user_id — l'accesso è #8,
non questa unità — per costruzione del tipo di `dati`, non per convenzione.

Refs #2
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:53:27 +00:00
5 changed files with 671 additions and 1 deletions
+86 -1
View File
@@ -4,7 +4,7 @@
// risalga alla persona (richiesta del cliente, 21/08): un secondo punto di giunzione
// la annullerebbe in silenzio. Un test in tests/longevity/anagrafica.test.ts lo verifica.
import type Database from 'better-sqlite3';
import { createIdentityDb } from './db';
import { createIdentityDb, getLongevityDb } from './db';
// Connessione singola a identity, privata di questo modulo: non è esportata, quindi non
// è raggiungibile da nessun'altra parte del codice, nemmeno con un re-export su più file
@@ -94,3 +94,88 @@ export function etaAllaData(dataNascita: string, alla: string): number {
if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--;
return eta;
}
//
// Gestionale del trainer (#2, unità 2a): tre letture sull'anagrafica e l'apertura di un
// fascicolo, tutte sotto lo stesso vincolo di codicePerUtente/codicePerUtenteLoggato qui
// sopra — una versione PARAMETRIZZATA (identity esplicita, esportata solo per i test e per
// comporre da qui dentro) e una versione OPERATIVA (nessun argomento, apre da sola
// getIdentityDb) che è quella che pagine ed endpoint del gestionale importano davvero. La
// versione operativa non si può testare su un DB :memory: — il singleton è uno solo per
// processo — quindi il test vero gira sulla parametrizzata, esattamente come
// anagrafica.test.ts fa già con codicePerUtente invece di codicePerUtenteLoggato.
//
export type ClienteElenco = {
client_code: string;
nome: string;
cognome: string;
data_nascita: string | null;
/** True se il cliente ha già un account per vedere il proprio referto (user_id valorizzato
* in identity.clienti). Creare quell'account è #8, fuori da questa unità: qui si legge
* soltanto se esiste già. */
ha_accesso: boolean;
};
/** L'elenco dei clienti per la tabella del gestionale, ordinato per cognome poi nome —
* l'ordine in cui un trainer cerca una persona, non l'ordine di creazione del fascicolo. */
export function elencoClientiParam(identity: Database.Database): ClienteElenco[] {
const righe = identity.prepare(
`SELECT client_code, nome, cognome, data_nascita, user_id FROM clienti ORDER BY cognome, nome`
).all() as { client_code: string; nome: string; cognome: string; data_nascita: string | null; user_id: number | null }[];
return righe.map(({ user_id, ...resto }) => ({ ...resto, ha_accesso: user_id !== null }));
}
export function elencoClienti(): ClienteElenco[] {
return elencoClientiParam(getIdentityDb());
}
export type ClienteDettaglio = ClienteElenco & {
email: string | null;
telefono: string | null;
};
/** L'anagrafica di un cliente per la sua scheda nel gestionale, `null` se il codice non
* esiste — la pagina lo traduce in un 404, non in uno schermo vuoto. */
export function clienteDaCodiceParam(identity: Database.Database, code: string): ClienteDettaglio | null {
const r = identity.prepare(
`SELECT client_code, nome, cognome, data_nascita, email, telefono, user_id
FROM clienti WHERE client_code = ?`
).get(code) as
{ client_code: string; nome: string; cognome: string; data_nascita: string | null;
email: string | null; telefono: string | null; user_id: number | null } | undefined;
if (!r) return null;
const { user_id, ...resto } = r;
return { ...resto, ha_accesso: user_id !== null };
}
export function clienteDaCodice(code: string): ClienteDettaglio | null {
return clienteDaCodiceParam(getIdentityDb(), code);
}
/** L'età del cliente alla data indicata, `null` se non ha una data di nascita in anagrafica
* (compilazione facoltativa: il fascicolo può nascere senza). Non è un ripiego a zero —
* calcolaSessione tratta già `eta_alla_data: null` come "niente Fitness Age", stessa
* barriera del motore (vedi l'intestazione di motore/index.ts). */
export function etaDelClienteParam(identity: Database.Database, code: string, alla: string): number | null {
const r = identity.prepare(`SELECT data_nascita FROM clienti WHERE client_code = ?`).get(code) as
{ data_nascita: string | null } | undefined;
if (!r?.data_nascita) return null;
return etaAllaData(r.data_nascita, alla);
}
export function etaDelCliente(code: string, alla: string): number | null {
return etaDelClienteParam(getIdentityDb(), code, alla);
}
/** Apre un fascicolo nuovo dal gestionale: SEMPRE senza user_id — creare l'account che
* darebbe accesso al cliente è #8, non questa unità, e il tipo di `dati` qui sotto non
* prevede quel campo (stessa garanzia strutturale di CorpoQuestionario nell'endpoint del
* questionario: chi volesse passare uno user_id da qui non supererebbe `tsc --noEmit`).
* Delega tutto a `creaCliente`, che è già la versione testabile con DB propri. */
export function apriFascicolo(dati: {
nome: string; cognome: string; sesso: 'M' | 'F';
data_nascita?: string; email?: string; telefono?: string;
}): string {
return creaCliente(getIdentityDb(), getLongevityDb(), dati);
}
@@ -0,0 +1,158 @@
//
// Il gestionale del trainer (#2, unità 2a): il modulo del check-up manuale e le letture
// sulle sessioni di un cliente. Solo il database clinico (vedi db.ts) — mai quello
// anagrafico, e mai le due parole riservate a quel confine insieme in un file che non sia
// anagrafica.ts o db.ts (tests/longevity/anagrafica.test.ts lo verifica): un cliente qui è
// solo un client_code, mai un nome. Chi ha bisogno del nome lo chiede ad anagrafica.ts, un
// altro modulo, un'altra chiamata.
import type Database from 'better-sqlite3';
import { testAttivi, esisteTest, MODEL_VERSION } from './registro';
import { apriSessione, registraMisure, type MisuraIn } from './misure';
import { calcolaSessione, salvaScore, leggiScore } from './motore';
import type { CampoQuestionario } from './vista';
export type GruppoCheckup = { asse: string; campi: CampoQuestionario[] };
/**
* I campi del modulo di check-up alla data indicata: solo i test_id fisici (prefisso `f_`,
* per lo stesso motivo per cui `campiDelQuestionario` in vista.ts prende solo `q_*`),
* raggruppati per asse — la sezione del modulo cartaceo che il trainer compila —
* nell'ordine in cui l'asse compare nei pesi (`pesi`, livello 'asse'): stesso criterio di
* `contenitoriDi` in vista.ts, letto dal dato invece di un elenco duplicato qui. Un asse
* fisico che non fosse fra i contenitori pesati (non dovrebbe succedere: FISICI in
* registro.ts usa solo assi che PESI_ASSE dichiara) chiude in coda, nell'ordine in cui
* compare per la prima volta nel registro.
*/
export function campiDelCheckup(db: Database.Database, alla: string): GruppoCheckup[] {
const fisici = testAttivi(db, alla).filter((v) => v.test_id.startsWith('f_'));
const ordineAssi = (db.prepare(
`SELECT contenitore FROM pesi WHERE model_version = ? AND livello = 'asse'
GROUP BY contenitore ORDER BY MIN(rowid)`
).all(MODEL_VERSION) as { contenitore: string }[]).map((r) => r.contenitore);
const perAsse = new Map<string, CampoQuestionario[]>();
for (const v of fisici) {
const asse = v.asse ?? '(senza asse)';
const campo: CampoQuestionario = {
id: v.test_id, etichetta: v.etichetta, tipo: v.tipo_valore,
unita: v.unita, min: v.range_min, max: v.range_max, sottoDominio: v.sotto_dominio,
};
if (!perAsse.has(asse)) perAsse.set(asse, []);
perAsse.get(asse)!.push(campo);
}
const ordine = ordineAssi.filter((a) => perAsse.has(a));
for (const a of perAsse.keys()) if (!ordine.includes(a)) ordine.push(a);
return ordine.map((asse) => ({ asse, campi: perAsse.get(asse)! }));
}
export type EsitoCheckup =
| { ok: true; sessioneId: number; fuoriRange: string[] }
| { ok: false; errore: string };
/**
* Salva un check-up manuale del trainer: stesso schema di `salvaDalForm` (vista.ts) —
* validare TUTTO prima di scrivere qualunque cosa, poi un'unica transazione (apertura
* sessione, misure, calcolo e congelamento dei punteggi) perché il congelamento è parte del
* salvataggio, non un passo lasciato a chi legge dopo (lo stesso difetto già corretto una
* volta in questo modulo, vedi il commento su `salvaDalForm`).
*
* Rifiuta PRIMA di aprire la sessione, quindi senza lasciare righe orfane in `sessioni`:
* nessuna misura, un test_id fuori dal registro, o un test_id che non è un test fisico
* (`f_*` — un `q_*` qui è un errore del chiamante, il check-up non è il questionario), o un
* client_code che non ha un soggetto in longevity.
*/
export function salvaCheckup(
db: Database.Database,
input: {
client_code: string; data: string; eta: number | null;
operatore: string; misure: MisuraIn[];
}
): EsitoCheckup {
if (input.misure.length === 0) {
return { ok: false, errore: 'Nessuna misura da salvare.' };
}
for (const m of input.misure) {
if (!m.test_id.startsWith('f_')) {
return { ok: false, errore: `Il check-up accetta solo test fisici: ${m.test_id}` };
}
if (!esisteTest(db, m.test_id)) {
return { ok: false, errore: `Test non riconosciuto: ${m.test_id}` };
}
}
const soggetto = db.prepare(`SELECT 1 FROM soggetti WHERE client_code = ?`).get(input.client_code);
if (!soggetto) {
return { ok: false, errore: `Cliente inesistente: ${input.client_code}` };
}
const tx = db.transaction(() => {
const sessioneId = apriSessione(db, {
client_code: input.client_code, data: input.data, tipo: 'checkup',
eta_alla_data: input.eta ?? undefined, operatore: input.operatore,
});
const { fuoriRange } = registraMisure(db, sessioneId, 'manuale', input.misure);
// Il calcolo e il congelamento sono parte del salvataggio: vedi salvaDalForm (vista.ts).
salvaScore(db, sessioneId, calcolaSessione(db, sessioneId));
return { sessioneId, fuoriRange };
});
const { sessioneId, fuoriRange } = tx();
return { ok: true, sessioneId, fuoriRange };
}
export type SessioneElenco = {
id: number;
data: string;
tipo: 'checkup' | 'questionario';
operatore: string | null;
numeroMisure: number;
/** La Fitness Age congelata di quella sessione, `null` se insufficiente o non calcolata —
* mai un ricalcolo: legge `leggiScore`, come `refertoDi` in vista.ts. */
fitnessAge: number | null;
};
/** Le sessioni di un cliente, più recente prima (data DESC, a parità id DESC — stesso
* criterio di `misureValideAlla` e `leggiScore` nel motore). */
export function sessioniDi(db: Database.Database, code: string): SessioneElenco[] {
const righe = db.prepare(
`SELECT s.id AS id, s.data AS data, s.tipo AS tipo, s.operatore AS operatore,
(SELECT COUNT(*) FROM misure m WHERE m.sessione_id = s.id) AS numero_misure
FROM sessioni s WHERE s.client_code = ? ORDER BY s.data DESC, s.id DESC`
).all(code) as
{ id: number; data: string; tipo: 'checkup' | 'questionario'; operatore: string | null; numero_misure: number }[];
return righe.map((r) => {
const punteggi = leggiScore(db, r.id);
const fitnessAge = punteggi.fitnessAge?.stato === 'ok' ? punteggi.fitnessAge.valore : null;
return {
id: r.id, data: r.data, tipo: r.tipo, operatore: r.operatore,
numeroMisure: r.numero_misure, fitnessAge,
};
});
}
export type MisuraFuoriRange = {
test_id: string;
etichetta: string;
valore: number | null;
unita: string | null;
data: string;
};
/** Le misure di un cliente marcate fuori dal range di plausibilità del registro
* (`misure.fuori_range = 1`, scritto da `registraMisure`): quelle che oggi non contano per
* nessun calcolo (`misureValideAlla` le esclude) ma restano da mostrare e, un domani,
* confermare (#7). Più recente prima. */
export function misureFuoriRange(db: Database.Database, code: string): MisuraFuoriRange[] {
return db.prepare(
`SELECT m.test_id AS test_id, r.etichetta AS etichetta, m.valore_num AS valore,
m.unita AS unita, s.data AS data
FROM misure m
JOIN sessioni s ON s.id = m.sessione_id
JOIN registro_test r ON r.test_id = m.test_id
WHERE m.client_code = ? AND m.fuori_range = 1
ORDER BY s.data DESC, s.id DESC, m.id DESC`
).all(code) as MisuraFuoriRange[];
}
@@ -0,0 +1,90 @@
import type { APIRoute } from 'astro';
import { getLongevityDb } from '../../../../lib/longevity/db';
import { etaDelCliente } from '../../../../lib/longevity/anagrafica';
import { testAttivi, type VoceRegistro } from '../../../../lib/longevity/registro';
import { salvaCheckup } from '../../../../lib/longevity/gestionale';
import type { MisuraIn } from '../../../../lib/longevity/misure';
export const prerender = false;
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/).
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
const REGEX_CODE = /^ISL-\d{4}$/;
const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } });
/**
* Il parsing del form del check-up, isolato dall'I/O (nessun DB, nessuna Request) apposta
* per essere testato senza fixture: riceve i campi ATTIVI (da `testAttivi`, filtrati su
* `f_*` da chi chiama) e il `FormData` grezzo, e produce le misure da passare a
* `salvaCheckup` — o l'errore che ferma tutto PRIMA che una sessione si apra.
*
* Regole, dalla spec (#2): un campo vuoto = non misurato (non un errore, non uno zero); la
* virgola decimale si accetta ("32,5" come "32.5", scrittura comune su un modulo cartaceo);
* un valore non numerico è un errore che blocca l'intero check-up, non solo quel campo —
* meglio fermarsi che salvare a metà con un tacito zero; le chiavi che non sono fra i campi
* attivi (client_code, data, un q_* del questionario, un refuso) si ignorano in silenzio,
* mai un errore per la loro sola presenza — un `<input>` nascosto o un campo dismesso non
* deve rompere il salvataggio del resto.
*/
export function misureDalForm(
form: FormData,
campiAttivi: Pick<VoceRegistro, 'test_id' | 'unita'>[]
): { ok: true; misure: MisuraIn[] } | { ok: false; errore: string } {
const perTestId = new Map(campiAttivi.map((c) => [c.test_id, c]));
const misure: MisuraIn[] = [];
for (const [chiave, valoreGrezzo] of form.entries()) {
const campo = perTestId.get(chiave);
if (!campo) continue; // chiave estranea: ignorata, non è un errore del form
const testo = String(valoreGrezzo).trim();
if (testo === '') continue; // vuoto = non misurato
const numero = Number(testo.replace(',', '.'));
if (!Number.isFinite(numero)) {
return { ok: false, errore: `Valore non numerico per ${chiave}: "${testo}"` };
}
misure.push({ test_id: chiave, valore_num: numero, unita: campo.unita });
}
return { ok: true, misure };
}
export const POST: APIRoute = async ({ request, locals }) => {
const form = await request.formData();
const clientCode = String(form.get('client_code') ?? '').trim();
const data = String(form.get('data') ?? '').trim();
// Senza un codice valido non c'è una scheda a cui tornare: l'unico ritorno sensato è
// l'indice del gestionale, non /cliente/undefined/checkup.
if (!REGEX_CODE.test(clientCode)) {
return redirect(`/longevity/gestionale?errore=${encodeURIComponent('Codice cliente non valido.')}`);
}
const erroreAllaScheda = (testo: string) =>
redirect(`/longevity/gestionale/cliente/${clientCode}/checkup?errore=${encodeURIComponent(testo)}`);
if (!REGEX_DATA.test(data)) return erroreAllaScheda('Data non valida (formato AAAA-MM-GG).');
const oggi = new Date().toISOString().slice(0, 10);
if (data > oggi) return erroreAllaScheda('La data del check-up non può essere nel futuro.');
const db = getLongevityDb();
const campiAttivi = testAttivi(db, data).filter((v) => v.test_id.startsWith('f_'));
const parse = misureDalForm(form, campiAttivi);
if (!parse.ok) return erroreAllaScheda(parse.errore);
// L'operatore è SEMPRE quello della sessione loggata (garantita da RULES + middleware:
// solo admin/trainer arrivano qui), mai un campo del form — lo stesso principio del
// client_code in pages/api/longevity/questionario.ts, applicato al campo che qui conta.
const operatore = locals.user!.username;
const eta = etaDelCliente(clientCode, data);
const esito = salvaCheckup(db, { client_code: clientCode, data, eta, operatore, misure: parse.misure });
if (!esito.ok) return erroreAllaScheda(esito.errore);
const params = new URLSearchParams({ salvato: String(esito.sessioneId) });
if (esito.fuoriRange.length > 0) params.set('fuori', String(esito.fuoriRange.length));
return redirect(`/longevity/gestionale/cliente/${clientCode}?${params.toString()}`);
};
@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import { apriFascicolo } from '../../../../lib/longevity/anagrafica';
export const prerender = false;
// Riservato a admin+trainer da src/lib/auth.ts (RULES, /^\/api\/longevity\/gestionale(\/|$)/):
// il middleware nega prima che questo file veda la richiesta.
const REGEX_DATA = /^\d{4}-\d{2}-\d{2}$/;
const redirect = (location: string) => new Response(null, { status: 303, headers: { Location: location } });
/** Sempre la stessa pagina di partenza: questo endpoint ha un solo modulo che lo chiama
* (pages/longevity/gestionale/nuovo.astro, unità 2b), quindi il ritorno in caso di errore
* non serve dedurlo da un Referer — si scrive qui, una volta. */
const erroreAlModulo = (testo: string) =>
redirect(`/longevity/gestionale/nuovo?errore=${encodeURIComponent(testo)}`);
function isSesso(v: string): v is 'M' | 'F' {
return v === 'M' || v === 'F';
}
export const POST: APIRoute = async ({ request }) => {
const form = await request.formData();
const leggi = (chiave: string) => String(form.get(chiave) ?? '').trim();
const nome = leggi('nome');
const cognome = leggi('cognome');
const sesso = leggi('sesso');
const dataNascita = leggi('data_nascita');
const email = leggi('email');
const telefono = leggi('telefono');
if (!nome) return erroreAlModulo('Il nome è obbligatorio.');
if (!cognome) return erroreAlModulo('Il cognome è obbligatorio.');
if (!isSesso(sesso)) return erroreAlModulo('Il sesso deve essere M o F.');
if (dataNascita) {
if (!REGEX_DATA.test(dataNascita)) {
return erroreAlModulo('Data di nascita non valida (formato AAAA-MM-GG).');
}
// La data del server, non quella (manomettibile) del form: stesso criterio di
// pages/api/longevity/questionario.ts per "oggi".
const oggi = new Date().toISOString().slice(0, 10);
if (dataNascita > oggi) return erroreAlModulo('La data di nascita non può essere nel futuro.');
}
const code = apriFascicolo({
nome, cognome, sesso,
data_nascita: dataNascita || undefined,
email: email || undefined,
telefono: telefono || undefined,
});
return redirect(`/longevity/gestionale/cliente/${code}`);
};
@@ -0,0 +1,281 @@
// Unità 2a (#2): il modulo del check-up manuale del trainer e le letture dell'anagrafica che
// gli servono. Stesso principio di salvataggio-e2e.test.ts — si parte dal modulo vero
// (salvaCheckup), mai da calcolaSessione/salvaScore chiamati a mano — più la prova che
// questo file esiste apposta a garantire: un check-up e un questionario sono sessioni
// separate (#1, misureValideAlla), quindi il referto del secondo deve vedere il primo.
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createLongevityDb, createIdentityDb } from '../../src/lib/longevity/db';
import { seedRegistro, seedPesi, MODEL_VERSION } from '../../src/lib/longevity/registro';
import { salvaCheckup, sessioniDi, misureFuoriRange } from '../../src/lib/longevity/gestionale';
import { salvaDalForm, refertoDi } from '../../src/lib/longevity/vista';
import {
creaCliente, elencoClientiParam, clienteDaCodiceParam, etaDelClienteParam, apriFascicolo,
} from '../../src/lib/longevity/anagrafica';
import { canAccessPath } from '../../src/lib/auth';
import { misureDalForm } from '../../src/pages/api/longevity/gestionale/checkup';
function dbPronto(clientCode = 'ISL-0001', sesso: 'M' | 'F' = 'M') {
const db = createLongevityDb(':memory:');
seedRegistro(db);
seedPesi(db, MODEL_VERSION);
db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES (?, ?)`).run(clientCode, sesso);
return db;
}
describe('salvaCheckup scrive la sessione e congela i punteggi da solo', () => {
it('una sessione tipo checkup, misure di fonte manuale, almeno una riga in score', () => {
const db = dbPronto();
const esito = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
misure: [
{ test_id: 'f_peso_kg', valore_num: 80 },
{ test_id: 'f_handgrip_kg', valore_num: 45 },
],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
const sessione = db.prepare(`SELECT tipo, operatore FROM sessioni WHERE id = ?`).get(esito.sessioneId) as
{ tipo: string; operatore: string };
expect(sessione.tipo).toBe('checkup');
expect(sessione.operatore).toBe('nicola');
const fonti = db.prepare(`SELECT DISTINCT fonte FROM misure WHERE sessione_id = ?`).all(esito.sessioneId) as
{ fonte: string }[];
expect(fonti).toEqual([{ fonte: 'manuale' }]);
const righeScore = db.prepare(`SELECT COUNT(*) n FROM score WHERE sessione_id = ?`).get(esito.sessioneId) as
{ n: number };
expect(righeScore.n).toBeGreaterThan(0);
});
it('dopo un questionario, il referto del check-up vede sia gli assi del questionario sia quelli fisici (sessioni combinate, #1)', () => {
const db = dbPronto();
const q = salvaDalForm(db, {
client_code: 'ISL-0001', data: '2026-09-01', eta: 38,
risposte: { q_ore_sonno: 7.5, q_min_addorm: 15, q_risvegli: 0, q_riposato: 8, q_caffeina: 1, q_sonnolenza_diurna: 1 },
consensoSanitario: 'Si', liberi: {},
});
expect(q.ok).toBe(true);
const c = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'nicola',
misure: [
{ test_id: 'f_handgrip_kg', valore_num: 48 },
{ test_id: 'f_pushup_rip', valore_num: 24 },
],
});
expect(c.ok).toBe(true);
if (!c.ok) throw new Error(c.errore);
const referto = refertoDi(db, c.sessioneId);
// 'Recupero & Sistema Nervoso' pesa questionario_sonno (dal questionario di prima, NON
// di questa sessione): la copertura sale sopra zero solo se misureValideAlla porta
// avanti la risposta del 2026-09-01 fino alla sessione del 2026-09-26.
const recupero = referto.assi.find((a) => a.nome === 'Recupero & Sistema Nervoso')!;
expect(recupero.copertura).toBeGreaterThan(0);
// 'Forza & Struttura' vede handgrip e spinta, appena registrati in QUESTA sessione.
const forza = referto.assi.find((a) => a.nome === 'Forza & Struttura')!;
expect(forza.copertura).toBeGreaterThan(0);
});
});
describe('salvaCheckup rifiuta prima di scrivere: nessuna traccia parziale', () => {
it('un test_id sconosciuto al registro: ok:false, nessuna sessione', () => {
const db = dbPronto();
const esito = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_non_esiste', valore_num: 1 }],
});
expect(esito.ok).toBe(false);
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
});
it('un q_* del questionario nel check-up: ok:false, nessuna sessione', () => {
const db = dbPronto();
const esito = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
misure: [{ test_id: 'q_ore_sonno', valore_num: 7 }],
});
expect(esito.ok).toBe(false);
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
});
it('nessuna misura: ok:false, nessuna sessione', () => {
const db = dbPronto();
const esito = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n', misure: [],
});
expect(esito.ok).toBe(false);
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
});
it('un soggetto che non esiste in longevity: ok:false, nessuna sessione', () => {
const db = dbPronto();
const esito = salvaCheckup(db, {
client_code: 'ISL-9999', data: '2026-09-26', eta: null, operatore: 'n',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
expect(esito.ok).toBe(false);
expect((db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number }).n).toBe(0);
});
});
describe('le misure fuori range restano nel fascicolo, marcate e leggibili', () => {
it('una misura oltre il range di plausibilità (f_peso_kg, max 250) si salva, si marca, ed esce da misureFuoriRange', () => {
const db = dbPronto();
const esito = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_peso_kg', valore_num: 500 }],
});
expect(esito.ok).toBe(true);
if (!esito.ok) throw new Error(esito.errore);
expect(esito.fuoriRange).toEqual(['f_peso_kg']);
const riga = db.prepare(
`SELECT fuori_range FROM misure WHERE sessione_id = ? AND test_id = 'f_peso_kg'`
).get(esito.sessioneId) as { fuori_range: number };
expect(riga.fuori_range).toBe(1);
const elenco = misureFuoriRange(db, 'ISL-0001');
expect(elenco.length).toBe(1);
expect(elenco[0]).toMatchObject({ test_id: 'f_peso_kg', valore: 500, data: '2026-09-26' });
});
});
describe('sessioniDi', () => {
it('elenca le sessioni del cliente, più recente prima, con numero misure e fitness age congelata', () => {
const db = dbPronto();
salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-01', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_peso_kg', valore_num: 80 }],
});
const seconda = salvaCheckup(db, {
client_code: 'ISL-0001', data: '2026-09-26', eta: 38, operatore: 'n',
misure: [{ test_id: 'f_handgrip_kg', valore_num: 45 }],
});
expect(seconda.ok).toBe(true);
const elenco = sessioniDi(db, 'ISL-0001');
expect(elenco.length).toBe(2);
expect(elenco[0].data).toBe('2026-09-26'); // la più recente prima
expect(elenco[0].numeroMisure).toBe(1);
// Con una sola misura la copertura di ogni asse resta troppo bassa per una Fitness Age:
// resta null, non un numero congetturato.
expect(elenco[0].fitnessAge).toBeNull();
});
});
describe('anagrafica per il gestionale, versioni parametrizzate (testabili su :memory:, come codicePerUtente accanto a codicePerUtenteLoggato)', () => {
it('elencoClientiParam ordina per cognome poi nome, e dice chi ha già un accesso', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const anna = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F' });
const bruno = creaCliente(id, lg, { nome: 'Bruno', cognome: 'Rossi', sesso: 'M', user_id: 7 });
const elenco = elencoClientiParam(id);
expect(elenco.map((c) => c.client_code)).toEqual([bruno, anna]); // Rossi prima di Verdi
expect(elenco.find((c) => c.client_code === bruno)!.ha_accesso).toBe(true);
expect(elenco.find((c) => c.client_code === anna)!.ha_accesso).toBe(false);
});
it('clienteDaCodiceParam restituisce il dettaglio, null se il codice non esiste', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const code = creaCliente(id, lg, { nome: 'Anna', cognome: 'Verdi', sesso: 'F', email: 'a@b.it' });
const dettaglio = clienteDaCodiceParam(id, code);
expect(dettaglio?.email).toBe('a@b.it');
expect(dettaglio?.ha_accesso).toBe(false);
expect(clienteDaCodiceParam(id, 'ISL-9999')).toBeNull();
});
it('etaDelClienteParam calcola l età alla data, null senza data di nascita in anagrafica', () => {
const id = createIdentityDb(':memory:');
const lg = createLongevityDb(':memory:');
const conData = creaCliente(id, lg, { nome: 'A', cognome: 'A', sesso: 'F', data_nascita: '1988-03-04' });
const senzaData = creaCliente(id, lg, { nome: 'B', cognome: 'B', sesso: 'M' });
expect(etaDelClienteParam(id, conData, '2026-08-21')).toBe(38);
expect(etaDelClienteParam(id, senzaData, '2026-08-21')).toBeNull();
});
});
describe('apriFascicolo, la versione operativa sul singleton', () => {
it('apre un fascicolo nuovo senza mai collegare uno user_id (l accesso è #8, non questa unità)', () => {
const dir = mkdtempSync(join(tmpdir(), 'lg-gestionale-test-'));
const idPath = join(dir, 'identity.db');
const lgPath = join(dir, 'longevity.db');
const primaId = process.env.IDENTITY_DB_PATH;
const primaLg = process.env.LONGEVITY_DB_PATH;
try {
// Stesso trucco di tests/longevity/questionario-pagina.test.ts: il singleton di
// getIdentityDb/getLongevityDb si crea alla prima chiamata, quindi le variabili
// d'ambiente vanno impostate PRIMA — e questo deve restare l'unico test del file
// che chiama una funzione dell'anagrafica sul singleton, non su un DB :memory: proprio.
process.env.IDENTITY_DB_PATH = idPath;
process.env.LONGEVITY_DB_PATH = lgPath;
const code = apriFascicolo({ nome: 'Nuovo', cognome: 'Cliente', sesso: 'F' });
expect(code).toMatch(/^ISL-\d{4}$/);
const id = createIdentityDb(idPath);
const riga = id.prepare(`SELECT user_id FROM clienti WHERE client_code = ?`).get(code) as
{ user_id: number | null };
expect(riga.user_id).toBeNull();
} finally {
if (primaId === undefined) delete process.env.IDENTITY_DB_PATH; else process.env.IDENTITY_DB_PATH = primaId;
if (primaLg === undefined) delete process.env.LONGEVITY_DB_PATH; else process.env.LONGEVITY_DB_PATH = primaLg;
rmSync(dir, { recursive: true, force: true });
}
});
});
describe('misureDalForm, il parsing puro del modulo di check-up', () => {
const campiAttivi = [
{ test_id: 'f_peso_kg', unita: 'kg' },
{ test_id: 'f_handgrip_kg', unita: 'kg' },
];
function formCon(dati: Record<string, string>): FormData {
const f = new FormData();
for (const [k, v] of Object.entries(dati)) f.set(k, v);
return f;
}
it('accetta la virgola decimale', () => {
const r = misureDalForm(formCon({ f_peso_kg: '80,5' }), campiAttivi);
expect(r.ok).toBe(true);
if (r.ok) expect(r.misure).toEqual([{ test_id: 'f_peso_kg', valore_num: 80.5, unita: 'kg' }]);
});
it('un campo vuoto = non misurato, nessuna misura per quella chiave', () => {
const r = misureDalForm(formCon({ f_peso_kg: '', f_handgrip_kg: '45' }), campiAttivi);
expect(r.ok).toBe(true);
if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_handgrip_kg']);
});
it('un valore non numerico ferma tutto: ok:false', () => {
const r = misureDalForm(formCon({ f_peso_kg: 'ottanta' }), campiAttivi);
expect(r.ok).toBe(false);
});
it('una chiave che non è fra i campi attivi (client_code, un q_*, un refuso) viene ignorata in silenzio', () => {
const r = misureDalForm(
formCon({ client_code: 'ISL-0001', q_ore_sonno: '7', f_peso_kg: '80' }),
campiAttivi
);
expect(r.ok).toBe(true);
if (r.ok) expect(r.misure.map((m) => m.test_id)).toEqual(['f_peso_kg']);
});
});
describe('canAccessPath: il check-up è del trainer, non del cliente', () => {
it('/api/longevity/gestionale/checkup nega il cliente, ammette il trainer', () => {
expect(canAccessPath('cliente', '/api/longevity/gestionale/checkup')).toBe(false);
expect(canAccessPath('trainer', '/api/longevity/gestionale/checkup')).toBe(true);
});
});