Files
Adriano b913a2bf90 registri dei piani in git (AI-OS#309)
Refs gitea:Adriano/AI-OS#310

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:36:26 +02:00

670 lines
28 KiB
Diff

# Review package: 2ea822a..HEAD
## Commits
f0629db longevity: l'endpoint del questionario apre solo longevity, e il test anti-giunzione diventa strutturale
f5ca841 longevity: il questionario in pagina, e il consenso che si puo negare davvero
## Files changed
src/components/longevity/Questionario.tsx | 174 ++++++++++++++++++++++++++++
src/lib/longevity/anagrafica.ts | 12 ++
src/lib/longevity/db.ts | 15 +++
src/lib/longevity/vista.ts | 99 ++++++++++++++++
src/pages/api/longevity/questionario.ts | 55 +++++++++
src/pages/longevity/questionario.astro | 22 ++++
src/styles/longevity.css | 32 +++++
tests/longevity/anagrafica.test.ts | 12 +-
tests/longevity/questionario-pagina.test.ts | 87 ++++++++++++++
9 files changed, 506 insertions(+), 2 deletions(-)
## Diff
diff --git a/src/components/longevity/Questionario.tsx b/src/components/longevity/Questionario.tsx
new file mode 100644
index 0000000..e60cfb6
--- /dev/null
+++ b/src/components/longevity/Questionario.tsx
@@ -0,0 +1,174 @@
+// Il questionario in pagina: quattro schermate — le tre aree di domande punteggiate,
+// lette dal registro (props `campi`, costruite da `campiDelQuestionario`), più le note
+// libere e il consenso. Avanti/indietro fra i blocchi, invio solo sull'ultimo.
+//
+// Il consenso qui NON blocca lato client la scelta "No": bloccarlo qui ripeterebbe
+// l'errore del prototipo del cliente in un altro punto (un controllo che sembra
+// proteggere e non protegge). La verifica che conta è quella del server
+// (src/lib/longevity/vista.ts, salvaDalForm): se il consenso non è affermativo, la
+// compilazione non si salva e la risposta lo dice — questa isola si limita a mostrarlo.
+import { useMemo, useState } from 'react';
+import type { CampoQuestionario } from '../../lib/longevity/vista';
+
+interface Props {
+ campi: CampoQuestionario[];
+}
+
+const CAMPI_LIBERI: { id: string; etichetta: string }[] = [
+ { id: 'farmaci', etichetta: 'Farmaci assunti abitualmente' },
+ { id: 'problematiche_attuali', etichetta: 'Problematiche di salute attuali' },
+ { id: 'problematiche_pregresse', etichetta: 'Problematiche di salute pregresse' },
+ { id: 'obiettivi', etichetta: 'Obiettivi del percorso' },
+];
+
+const BLOCCHI = [
+ { titolo: 'Sonno & Recupero', sottoDominio: 'questionario_sonno' },
+ { titolo: 'Energia & Stress', sottoDominio: 'questionario_energia_stress' },
+ { titolo: 'Stile di Vita', sottoDominio: 'questionario_lifestyle' },
+] as const;
+
+type Esito = { tipo: 'ok'; sessioneId: number } | { tipo: 'errore'; messaggio: string };
+
+export default function Questionario({ campi }: Props) {
+ const [passo, setPasso] = useState(0);
+ const [risposte, setRisposte] = useState<Record<string, string>>({});
+ const [liberi, setLiberi] = useState<Record<string, string>>({});
+ const [consenso, setConsenso] = useState<'' | 'Si' | 'No'>('');
+ const [eta, setEta] = useState('');
+ const [inviando, setInviando] = useState(false);
+ const [esito, setEsito] = useState<Esito | null>(null);
+
+ // Le domande arrivano dal registro in ordine di test_id, non raggruppate: qui si
+ // ricostruiscono i tre blocchi filtrando sul sotto-dominio, senza toccare i dati.
+ const gruppi = useMemo(
+ () => BLOCCHI.map((b) => ({ ...b, campi: campi.filter((c) => c.sottoDominio === b.sottoDominio) })),
+ [campi]
+ );
+
+ const totalePassi = gruppi.length + 1; // + il blocco note/consenso
+ const ultimo = passo === totalePassi - 1;
+
+ async function invia() {
+ setInviando(true);
+ setEsito(null);
+
+ const risposteNumeriche: Record<string, number> = {};
+ for (const [id, v] of Object.entries(risposte)) {
+ const n = Number(v);
+ if (v !== '' && Number.isFinite(n)) risposteNumeriche[id] = n;
+ }
+
+ try {
+ const res = await fetch('/api/longevity/questionario', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ risposte: risposteNumeriche,
+ liberi,
+ consensoSanitario: consenso,
+ eta: eta === '' ? undefined : Number(eta),
+ }),
+ });
+ const corpo = await res.json();
+ if (res.ok) setEsito({ tipo: 'ok', sessioneId: corpo.sessioneId });
+ else setEsito({ tipo: 'errore', messaggio: corpo.error ?? 'Invio non riuscito.' });
+ } catch {
+ setEsito({ tipo: 'errore', messaggio: 'Invio non riuscito: verifica la connessione.' });
+ } finally {
+ setInviando(false);
+ }
+ }
+
+ if (esito?.tipo === 'ok') {
+ return (
+ <div className="lg-card">
+ <p>Questionario salvato, grazie.</p>
+ </div>
+ );
+ }
+
+ return (
+ <div className="lg-form">
+ <p className="lg-form__passo">Passo {passo + 1} di {totalePassi}</p>
+
+ {passo < gruppi.length && (
+ <section className="lg-form__blocco">
+ <h2>{gruppi[passo].titolo}</h2>
+ {gruppi[passo].campi.map((c) => (
+ <label className="lg-field" key={c.id}>
+ <span>{c.etichetta}{c.unita ? ` (${c.unita})` : ''}</span>
+ <input
+ type="number"
+ min={c.min}
+ max={c.max}
+ step="any"
+ value={risposte[c.id] ?? ''}
+ onChange={(e) => setRisposte((r) => ({ ...r, [c.id]: e.target.value }))}
+ />
+ </label>
+ ))}
+ </section>
+ )}
+
+ {ultimo && (
+ <section className="lg-form__blocco">
+ <h2>Note e consenso</h2>
+
+ {CAMPI_LIBERI.map((c) => (
+ <label className="lg-field" key={c.id}>
+ <span>{c.etichetta}</span>
+ <textarea
+ value={liberi[c.id] ?? ''}
+ onChange={(e) => setLiberi((l) => ({ ...l, [c.id]: e.target.value }))}
+ />
+ </label>
+ ))}
+
+ <label className="lg-field">
+ <span>La tua età</span>
+ <input type="number" min={0} max={120} value={eta} onChange={(e) => setEta(e.target.value)} />
+ </label>
+
+ <fieldset className="lg-field lg-field--consenso">
+ <legend>
+ Acconsenti al trattamento dei tuoi dati sanitari (farmaci, patologie, pressione,
+ composizione corporea) per il monitoraggio del percorso?
+ </legend>
+ <label>
+ <input
+ type="radio" name="consenso" checked={consenso === 'Si'}
+ onChange={() => setConsenso('Si')}
+ />
+ Sì, acconsento
+ </label>
+ <label>
+ <input
+ type="radio" name="consenso" checked={consenso === 'No'}
+ onChange={() => setConsenso('No')}
+ />
+ No, non acconsento
+ </label>
+ </fieldset>
+
+ {esito?.tipo === 'errore' && <p className="lg-form__errore">{esito.messaggio}</p>}
+ </section>
+ )}
+
+ <div className="lg-form__nav">
+ <button type="button" disabled={passo === 0} onClick={() => setPasso((p) => Math.max(0, p - 1))}>
+ Indietro
+ </button>
+ {!ultimo && (
+ <button type="button" onClick={() => setPasso((p) => Math.min(totalePassi - 1, p + 1))}>
+ Avanti
+ </button>
+ )}
+ {ultimo && (
+ <button type="button" disabled={inviando || consenso === ''} onClick={invia}>
+ {inviando ? 'Invio…' : 'Invia'}
+ </button>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/src/lib/longevity/anagrafica.ts b/src/lib/longevity/anagrafica.ts
index 5d60a0e..9bf8eb3 100644
--- a/src/lib/longevity/anagrafica.ts
+++ b/src/lib/longevity/anagrafica.ts
@@ -1,16 +1,17 @@
//
// QUESTO è l'unico modulo autorizzato ad aprire insieme identity e longevity.
// La separazione dei due database è la garanzia che chi legge i dati clinici non
// 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 { getIdentityDb } from './db';
/**
* Il prossimo codice libero: il massimo fra quelli già usati in identity.clienti E in
* longevity.soggetti. Dedurlo da una sola metà è il bug che permette di riassegnare il
* codice di un cliente cancellato dall'anagrafica (le sue misure restano in longevity)
* a una persona nuova: qui non torna mai disponibile.
*/
function prossimoCodice(identity: Database.Database, longevity: Database.Database): string {
const numero = (code: string) => Number(code.slice(4));
const ultimoIdentity = identity.prepare(
@@ -55,19 +56,30 @@ export function creaCliente(
return code;
}
export function codicePerUtente(identity: Database.Database, userId: number): string | null {
const r = identity.prepare(`SELECT client_code FROM clienti WHERE user_id = ?`).get(userId) as
{ client_code: string } | undefined;
return r?.client_code ?? null;
}
+/**
+ * Il codice cliente a partire dall'utente di sessione del sito: apre lei stessa la
+ * connessione a identity (getIdentityDb), perché QUESTO è il modulo autorizzato a farlo.
+ * Un endpoint che deve solo sapere "chi è" chiama questa funzione e non ha mai bisogno
+ * di aprire identity da sé — riceve una stringa, mai una connessione che potrebbe
+ * incrociarsi con quella di longevity nello stesso file.
+ */
+export function codicePerUtenteLoggato(userId: number): string | null {
+ return codicePerUtente(getIdentityDb(), userId);
+}
+
/** L'eta' al momento della misura: è ciò che serve al motore, e non è un quasi-identificatore. */
export function etaAllaData(dataNascita: string, alla: string): number {
const n = new Date(dataNascita);
const d = new Date(alla);
let eta = d.getFullYear() - n.getFullYear();
const m = d.getMonth() - n.getMonth();
if (m < 0 || (m === 0 && d.getDate() < n.getDate())) eta--;
return eta;
}
diff --git a/src/lib/longevity/db.ts b/src/lib/longevity/db.ts
index 62737f1..a1cd251 100644
--- a/src/lib/longevity/db.ts
+++ b/src/lib/longevity/db.ts
@@ -127,10 +127,25 @@ function apri(p: string, schema: string): Database.Database {
return db;
}
export function createLongevityDb(path?: string): Database.Database {
return apri(path ?? process.env.LONGEVITY_DB_PATH ?? 'data/longevity.db', SCHEMA_LONGEVITY);
}
export function createIdentityDb(path?: string): Database.Database {
return apri(path ?? process.env.IDENTITY_DB_PATH ?? 'data/identity.db', SCHEMA_IDENTITY);
}
+
+// Connessioni singole per processo, come getDb() in src/lib/db.ts: gli endpoint chiedono
+// una connessione già aperta invece di aprirne una nuova a ogni richiesta.
+let longevitySingleton: Database.Database | null = null;
+let identitySingleton: Database.Database | null = null;
+
+export function getLongevityDb(): Database.Database {
+ if (!longevitySingleton) longevitySingleton = createLongevityDb();
+ return longevitySingleton;
+}
+
+export function getIdentityDb(): Database.Database {
+ if (!identitySingleton) identitySingleton = createIdentityDb();
+ return identitySingleton;
+}
diff --git a/src/lib/longevity/vista.ts b/src/lib/longevity/vista.ts
new file mode 100644
index 0000000..4acc370
--- /dev/null
+++ b/src/lib/longevity/vista.ts
@@ -0,0 +1,99 @@
+//
+// La vista che il questionario in pagina usa. Due responsabilità:
+// - campiDelQuestionario: i campi del modulo, letti dal registro (mai una lista nel
+// codice) — aggiungere o disattivare una domanda resta una modifica ai dati.
+// - salvaDalForm: il salvataggio, con la sola regola che conta davvero — il consenso al
+// trattamento dei dati sanitari va DATO, non solo compilato. Il prototipo del cliente
+// salvava anche con "No" perché controllava solo che il campo non fosse vuoto: qui il
+// consenso si verifica PRIMA di scrivere qualsiasi altra cosa, e un rifiuto non apre
+// nessuna sessione. Il rifiuto stesso si registra comunque: è un fatto (chi, quando, a
+// cosa) che un domani va dimostrato tanto quanto un assenso.
+import type Database from 'better-sqlite3';
+import { testAttivi } from './registro';
+import { salvaCompilazione, QUEST_VERSION } from './questionario';
+
+export type CampoQuestionario = {
+ id: string;
+ etichetta: string;
+ tipo: 'num' | 'txt';
+ unita?: string;
+ min?: number;
+ max?: number;
+ sottoDominio?: string;
+};
+
+/** I campi del modulo alla data indicata: solo i test_id del questionario (prefisso
+ * `q_`, per non confonderli con quelli dei test fisici), attivi a quella data. */
+export function campiDelQuestionario(db: Database.Database, alla: string): CampoQuestionario[] {
+ return testAttivi(db, alla)
+ .filter((v) => v.test_id.startsWith('q_'))
+ .map((v) => ({
+ 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,
+ }));
+}
+
+export type EsitoSalvataggio =
+ | { ok: true; sessioneId: number }
+ | { ok: false; errore: string };
+
+const CONSENSI_AFFERMATIVI = new Set(['si', 'sì']);
+
+function consensoDato(risposta: string): boolean {
+ return CONSENSI_AFFERMATIVI.has(risposta.trim().toLowerCase());
+}
+
+/** Registra il fatto del consenso — chi, quando, a che versione del questionario —
+ * indipendentemente dall'esito. Legata al cliente sempre; alla sessione solo quando il
+ * consenso è affermativo e la sessione esiste davvero. */
+function registraConsenso(
+ db: Database.Database, clientCode: string, sessioneId: number | null, risposta: string
+): void {
+ const esito = risposta.trim() === '' ? '(non risposto)' : risposta.trim();
+ const oggi = new Date().toISOString().slice(0, 10);
+ const testo = `Consenso al trattamento dei dati sanitari: ${esito} — questionario ${QUEST_VERSION}, registrato il ${oggi}`;
+ db.prepare(
+ `INSERT INTO profilo_note (client_code, sessione_id, campo_id, testo) VALUES (?, ?, ?, ?)`
+ ).run(clientCode, sessioneId, 'consenso_sanitario', testo);
+}
+
+/**
+ * Il salvataggio dal form del questionario. Il consenso si controlla PRIMA di scrivere
+ * qualunque altra cosa: se non è affermativo, non si apre nessuna sessione — né misure né
+ * note di profilo — e la compilazione non entra nel fascicolo. La nota del consenso si
+ * scrive comunque, con l'esito letterale ricevuto.
+ */
+export function salvaDalForm(
+ db: Database.Database,
+ input: {
+ client_code: string; data: string; eta?: number;
+ risposte: Record<string, number>;
+ consensoSanitario: string;
+ liberi?: Record<string, string>;
+ }
+): EsitoSalvataggio {
+ if (!consensoDato(input.consensoSanitario)) {
+ registraConsenso(db, input.client_code, null, input.consensoSanitario);
+ return {
+ ok: false,
+ errore: 'Senza consenso al trattamento dei dati sanitari la compilazione non viene salvata.',
+ };
+ }
+
+ const sessioneId = salvaCompilazione(db, {
+ client_code: input.client_code,
+ data: input.data,
+ eta: input.eta,
+ risposte: input.risposte,
+ liberi: input.liberi,
+ });
+
+ registraConsenso(db, input.client_code, sessioneId, input.consensoSanitario);
+
+ return { ok: true, sessioneId };
+}
diff --git a/src/pages/api/longevity/questionario.ts b/src/pages/api/longevity/questionario.ts
new file mode 100644
index 0000000..5f0909e
--- /dev/null
+++ b/src/pages/api/longevity/questionario.ts
@@ -0,0 +1,55 @@
+import type { APIRoute } from 'astro';
+import { getLongevityDb } from '../../../lib/longevity/db';
+import { codicePerUtenteLoggato } from '../../../lib/longevity/anagrafica';
+import { salvaDalForm } from '../../../lib/longevity/vista';
+import { CAMPI_LIBERI } from '../../../lib/longevity/questionario';
+
+export const prerender = false;
+
+const json = (status: number, body: object) =>
+ new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
+
+export const POST: APIRoute = async ({ request, locals }) => {
+ // Il codice cliente si ricava SEMPRE dalla sessione dell'utente loggato (spec §8), mai dal
+ // corpo della richiesta: qui infatti il corpo non viene mai letto per questo campo. La
+ // risoluzione passa da anagrafica.ts (l'unico modulo autorizzato a toccare quella
+ // connessione): questo file apre solo longevity, per salvare.
+ const codice = codicePerUtenteLoggato(locals.user!.id);
+ if (!codice) return json(403, { error: 'Nessun fascicolo cliente associato a questo utente.' });
+
+ let data: Record<string, unknown>;
+ try { data = await request.json(); } catch { return json(400, { error: 'Dati non validi.' }); }
+ if (typeof data !== 'object' || data === null) return json(400, { error: 'Dati non validi.' });
+
+ const risposteIn = data.risposte;
+ if (typeof risposteIn !== 'object' || risposteIn === null) {
+ return json(400, { error: 'Risposte mancanti.' });
+ }
+ const risposte: Record<string, number> = {};
+ for (const [id, v] of Object.entries(risposteIn as Record<string, unknown>)) {
+ if (typeof v === 'number' && Number.isFinite(v)) risposte[id] = v;
+ }
+
+ // Solo i campi liberi dichiarati: un corpo con chiavi arbitrarie non ne inventa di nuove.
+ const liberiIn = data.liberi;
+ const liberi: Record<string, string> = {};
+ if (typeof liberiIn === 'object' && liberiIn !== null) {
+ for (const campo of CAMPI_LIBERI) {
+ const v = (liberiIn as Record<string, unknown>)[campo];
+ if (typeof v === 'string') liberi[campo] = v;
+ }
+ }
+
+ const consensoSanitario = typeof data.consensoSanitario === 'string' ? data.consensoSanitario : '';
+ const eta = typeof data.eta === 'number' && Number.isFinite(data.eta) ? data.eta : undefined;
+
+ // La data della compilazione è quella del server, non quella (manomettibile) del client.
+ const oggi = new Date().toISOString().slice(0, 10);
+
+ const esito = salvaDalForm(getLongevityDb(), {
+ client_code: codice, data: oggi, eta, risposte, consensoSanitario, liberi,
+ });
+
+ if (!esito.ok) return json(422, { error: esito.errore });
+ return json(201, { sessioneId: esito.sessioneId });
+};
diff --git a/src/pages/longevity/questionario.astro b/src/pages/longevity/questionario.astro
new file mode 100644
index 0000000..57dd505
--- /dev/null
+++ b/src/pages/longevity/questionario.astro
@@ -0,0 +1,22 @@
+---
+// Il questionario in pagina, sostituisce il prototipo del cliente. La pagina non
+// interroga il database da sola: chiede i campi a `campiDelQuestionario` (letti dal
+// registro), l'isola React fa il resto — quattro blocchi, e l'invio va a
+// /api/longevity/questionario, dove il consenso si verifica davvero.
+import Longevity from '../../layouts/Longevity.astro';
+import Questionario from '../../components/longevity/Questionario';
+import { getLongevityDb } from '../../lib/longevity/db';
+import { campiDelQuestionario } from '../../lib/longevity/vista';
+
+export const prerender = false;
+
+const oggi = new Date().toISOString().slice(0, 10);
+const campi = campiDelQuestionario(getLongevityDb(), oggi);
+---
+<Longevity title="Questionario" crumbs={['Questionario']}>
+ <div class="lg-head">
+ <h1>Questionario</h1>
+ <p>Rispondi con calma: puoi tornare indietro finché non invii.</p>
+ </div>
+ <Questionario client:load campi={campi} />
+</Longevity>
diff --git a/src/styles/longevity.css b/src/styles/longevity.css
index 32362e3..d87b2f7 100644
--- a/src/styles/longevity.css
+++ b/src/styles/longevity.css
@@ -72,13 +72,45 @@
letter-spacing: .16em; text-transform: uppercase; color: var(--c-heading);
}
.lg-score__bar { height: 6px; background: var(--c-bg-alt); overflow: hidden; }
.lg-score__bar-fill { height: 100%; background: var(--c-accent); opacity: var(--lg-intensita, 1); }
.lg-misura--fuori-range { color: var(--lg-fuori-range); font-weight: 600; }
.lg-muted { color: var(--c-text-light); }
+/* --- il questionario: un blocco alla volta, avanti/indietro --- */
+.lg-form { display: flex; flex-direction: column; gap: 24px; }
+.lg-form__passo {
+ font-family: var(--font-heading); font-size: .74rem; letter-spacing: .12em;
+ text-transform: uppercase; color: var(--c-text-light); margin: 0;
+}
+.lg-form__blocco { display: flex; flex-direction: column; gap: 16px; }
+.lg-form__blocco h2 { font-size: 1.1rem; margin: 0; }
+
+.lg-field { display: flex; flex-direction: column; gap: 6px; font-size: .92rem; }
+.lg-field > span { color: var(--c-heading); font-weight: 500; }
+.lg-field input[type="number"],
+.lg-field textarea {
+ border: 1px solid var(--lg-line); padding: 9px 11px; font: inherit; background: var(--lg-surface);
+}
+.lg-field textarea { min-height: 68px; resize: vertical; }
+
+.lg-field--consenso { border: 1px solid var(--lg-line); padding: 16px 18px; gap: 10px; }
+.lg-field--consenso legend { padding: 0 4px; font-weight: 500; color: var(--c-heading); }
+.lg-field--consenso label { display: flex; align-items: center; gap: 8px; font-weight: 400; }
+
+.lg-form__errore { color: var(--c-heading); font-weight: 600; margin: 0; }
+
+.lg-form__nav { display: flex; justify-content: space-between; gap: 12px; }
+.lg-form__nav button {
+ font-family: var(--font-heading); font-size: .78rem; font-weight: 600; letter-spacing: .08em;
+ text-transform: uppercase; padding: 11px 22px; border: 1px solid var(--c-heading);
+ background: transparent; color: var(--c-heading); cursor: pointer;
+}
+.lg-form__nav button:disabled { opacity: .35; cursor: not-allowed; }
+.lg-form__nav button:last-child:not(:only-child) { background: var(--c-accent); border-color: var(--c-accent); color: #fff; }
+
@media (max-width: 640px) {
.lg-top, .lg-main { padding-left: 20px; padding-right: 20px; }
}
diff --git a/tests/longevity/anagrafica.test.ts b/tests/longevity/anagrafica.test.ts
index bf232be..4dfcd0f 100644
--- a/tests/longevity/anagrafica.test.ts
+++ b/tests/longevity/anagrafica.test.ts
@@ -81,35 +81,43 @@ describe('anagrafica pseudonimizzata', () => {
// su longevity fallisce per costruzione, dopo che quella su identity è già avvenuta.
expect(() =>
creaCliente(id, lg, { nome: 'Errato', cognome: 'Errato', sesso: 'Z' as unknown as 'M' | 'F' })
).toThrow();
const dopoIlTentativo = (id.prepare(`SELECT COUNT(*) AS n FROM clienti`).get() as { n: number }).n;
expect(dopoIlTentativo).toBe(primaDelTentativo); // nessun cliente orfano rimasto in identity
expect(id.prepare(`SELECT * FROM clienti WHERE nome = 'Errato'`).get()).toBeUndefined();
});
- it('nessun file in src/ (escluso anagrafica.ts e db.ts) apre entrambe le connessioni', () => {
+ it('nessun file in src/ (escluso anagrafica.ts e db.ts) fa riferimento sia a identity sia a longevity', () => {
+ // Non cerchiamo due nomi di funzione precisi (createIdentityDb/createLongevityDb): un
+ // domani qualcuno aggiunge getIdentityDb, o un terzo accessore con un nome diverso, e
+ // l'elenco invecchia senza che nessuno se ne accorga (è già successo: getIdentityDb e
+ // getLongevityDb sono nati DOPO questo test e sono passati indisturbati). Il verso
+ // giusto è strutturale: qualunque accesso alle due connessioni passa comunque per una
+ // funzione esportata da db.ts il cui nome contiene "Identity" o "Longevity" — quindi
+ // basta cercare quei due riferimenti letterali, in QUALUNQUE forma compaiano, e
+ // segnalare i file che li hanno tutti e due.
const srcDir = join(process.cwd(), 'src');
const colpevoli: string[] = [];
const files = readdirSync(srcDir, { recursive: true, withFileTypes: false }) as string[];
for (const f of files) {
const path = join(srcDir, f);
// Salta node_modules e dist
if (f.includes('node_modules') || f.includes('dist')) continue;
// Accetta solo .ts e .astro
if (!f.endsWith('.ts') && !f.endsWith('.astro')) continue;
// Esclude i moduli autorizzati
if (f === 'lib/longevity/anagrafica.ts' || f === 'lib/longevity/db.ts') continue;
const src = readFileSync(path, 'utf8');
- if (src.includes('createIdentityDb') && src.includes('createLongevityDb')) {
+ if (src.includes('Identity') && src.includes('Longevity')) {
colpevoli.push(f);
}
}
expect(colpevoli).toEqual([]);
});
});
diff --git a/tests/longevity/questionario-pagina.test.ts b/tests/longevity/questionario-pagina.test.ts
new file mode 100644
index 0000000..225de9b
--- /dev/null
+++ b/tests/longevity/questionario-pagina.test.ts
@@ -0,0 +1,87 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { createLongevityDb } from '../../src/lib/longevity/db';
+import { seedRegistro } from '../../src/lib/longevity/registro';
+import { campiDelQuestionario, salvaDalForm } from '../../src/lib/longevity/vista';
+
+function dbPronto() {
+ const db = createLongevityDb(':memory:');
+ seedRegistro(db);
+ db.prepare(`INSERT INTO soggetti (client_code, sesso) VALUES ('ISL-0001','F')`).run();
+ return db;
+}
+
+describe('i campi del questionario vengono dal registro', () => {
+ it('costruisce i campi leggendo il registro, non una lista nel codice', () => {
+ const db = dbPronto();
+ const campi = campiDelQuestionario(db, '2026-08-22');
+ expect(campi.length).toBe(20);
+ const alcol = campi.find((c) => c.id === 'q_alcol_life')!;
+ expect(alcol.etichetta).toMatch(/alcoliche/i);
+ expect(alcol.max).toBe(30);
+ });
+
+ it('una domanda disattivata sparisce dal modulo senza toccare il codice', () => {
+ const db = dbPronto();
+ db.prepare(`UPDATE registro_test SET attivo_a = '2026-01-01' WHERE test_id = 'q_sigarette'`).run();
+ const campi = campiDelQuestionario(db, '2026-08-22');
+ expect(campi.map((c) => c.id)).not.toContain('q_sigarette');
+ expect(campi.length).toBe(19);
+ });
+});
+
+describe('il salvataggio, e il consenso', () => {
+ const risposte = { q_riposato: 8, q_ore_sonno: 7.5 };
+
+ it('salva una compilazione quando il consenso e dato', () => {
+ const db = dbPronto();
+ const esito = salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: 'Si', liberi: {},
+ });
+ expect(esito.ok).toBe(true);
+ const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
+ expect(n.n).toBe(1);
+ });
+
+ it('NON salva se il consenso e rifiutato: "No" non e un consenso', () => {
+ const db = dbPronto();
+ const esito = salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: 'No', liberi: {},
+ });
+ expect(esito.ok).toBe(false);
+ const n = db.prepare(`SELECT COUNT(*) n FROM sessioni`).get() as { n: number };
+ expect(n.n).toBe(0); // niente sessione, niente misure
+ });
+
+ it('NON salva se il consenso manca del tutto', () => {
+ const db = dbPronto();
+ const esito = salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: '', liberi: {},
+ });
+ expect(esito.ok).toBe(false);
+ });
+
+ it('registra il consenso anche quando e rifiutato: il rifiuto va dimostrato', () => {
+ const db = dbPronto();
+ salvaDalForm(db, {
+ client_code: 'ISL-0001', data: '2026-08-22', eta: 35,
+ risposte, consensoSanitario: 'No', liberi: {},
+ });
+ const note = db.prepare(
+ `SELECT campo_id, testo FROM profilo_note WHERE campo_id LIKE 'consenso%'`
+ ).all() as { campo_id: string; testo: string }[];
+ expect(note.length).toBeGreaterThan(0);
+ expect(note[0].testo).toMatch(/No/);
+ });
+
+ it('il codice cliente NON puo arrivare da chi chiama la pagina', () => {
+ const src = readFileSync(join(process.cwd(), 'src/pages/api/longevity/questionario.ts'), 'utf8');
+ // il codice si prende dalla sessione dell'utente, mai dal corpo della richiesta
+ expect(src).toMatch(/codicePerUtente/);
+ expect(src).not.toMatch(/body\.client_code|body\.clientId|corpo\.client_code/);
+ });
+});