import { eq } from "drizzle-orm" import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest" import { definition } from "../src/configurator/definition.js" import { evaluate, validateAnswers } from "../src/configurator/engine.js" import { PROVINCES, RULES_VERSION } from "../src/configurator/rules.js" import { consents, quoteRequests, users } from "../src/db/schema/index.js" import { hashPassword } from "../src/services/password.js" import { buildTestApp, type TestApp, truncateAll } from "./helpers.js" type Row = { answers: Record code: string size: number | null investment: number | null savings: number | null payback: number | null products: string[] warnings: number } /** * Casi tabellari (valori verificati a mano dalle formule di engine.ts con le regole di rules.ts). * Cambiando una soglia in rules.ts questi valori vanno riallineati. */ const CASES: Record = { acqua: [ // 4 persone, 35 °f: carico 4×0,2×20 = 16 °f·m³/g, resina 16×4/5 = 12,8 L → 20 L; 1.190+300 → 1.500 €; risparmio 4×45×0,8 = 144 € { answers: { percorso: "acqua", acqua_persone: 4, acqua_durezza_nota: "si", acqua_durezza: 35, acqua_abitazione: "casa" }, code: "addolcitore", size: 20, investment: 1500_00, savings: 144_00, payback: 10.4, products: ["addolcitore-domestico-20l"], warnings: 0 }, // 2 persone, durezza ignota (30 °f): resina 4,8 L → 10 L; risparmio 2×45×0,6 = 54 €; avvisi durezza + appartamento { answers: { percorso: "acqua", acqua_persone: 2, acqua_durezza_nota: "no", acqua_abitazione: "appartamento" }, code: "addolcitore", size: 10, investment: 1500_00, savings: 54_00, payback: 27.8, products: ["addolcitore-domestico-20l"], warnings: 2 }, // 12 °f ≤ 15 °f: addolcitore non necessario { answers: { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "si", acqua_durezza: 12, acqua_abitazione: "casa" }, code: "filtrazione", size: null, investment: null, savings: null, payback: null, products: [], warnings: 0 }, // 8 persone, 60 °f: resina 57,6 L > 40 L → sopralluogo { answers: { percorso: "acqua", acqua_persone: 8, acqua_durezza_nota: "si", acqua_durezza: 60, acqua_abitazione: "casa" }, code: "addolcitore_sopralluogo", size: 57.6, investment: null, savings: null, payback: null, products: [], warnings: 0 }, ], fotovoltaico: [ // MI (1150), 4.000 kWh, sud: 3,48 → 3,5 kWp; prod. 4.025; autoc. 40% → 1.610×0,30 + 2.415×0,10 = 724,5 €; 3,5×1.700 → 6.000 € { answers: { percorso: "fotovoltaico", provincia: "MI", fv_input: "kwh", fv_consumo_kwh: 4000, fv_orientamento: "sud", fv_accumulo: "no", fv_uso_diurno: "medio" }, code: "fotovoltaico", size: 3.5, investment: 6000_00, savings: 725_00, payback: 8.3, products: [], warnings: 0 }, // NA (1450), bolletta 150 €/mese → 6.000 kWh: 4,5 kWp, batteria 10 kWh, autoc. 85%; 4,5×1.700 + 10×600 = 13.650 → 13.700 € { answers: { percorso: "fotovoltaico", provincia: "NA", fv_input: "bolletta", fv_bolletta_mensile: 150, fv_orientamento: "sud", fv_accumulo: "si", fv_uso_diurno: "alto" }, code: "fotovoltaico_accumulo", size: 4.5, investment: 13700_00, savings: 1762_00, payback: 7.8, products: ["kit-fotovoltaico-6kw-accumulo"], warnings: 0 }, // RM (1300×0,85), 9.000 kWh: 8,5 kWp limitati dal tetto (30 m² / 5,5 → 5 kWp); batteria 15 kWh; autoc. 60% { answers: { percorso: "fotovoltaico", provincia: "RM", fv_input: "kwh", fv_consumo_kwh: 9000, fv_orientamento: "est_ovest", fv_superficie_tetto: 30, fv_accumulo: "si", fv_uso_diurno: "basso" }, code: "fotovoltaico_accumulo", size: 5, investment: 17500_00, savings: 1216_00, payback: 14.4, products: ["kit-fotovoltaico-6kw-accumulo"], warnings: 1 }, // AO (1150×0,75 = 862,5), 500 kWh: 0,58 kWp → minimo 2 kWp; prod. 1.725, autoc. limitato al consumo (500): // 500×0,30 + 1.225×0,10 = 272,5 €; 2×2.000 = 4.000 €; avviso orientamento { answers: { percorso: "fotovoltaico", provincia: "AO", fv_input: "kwh", fv_consumo_kwh: 500, fv_orientamento: "altro", fv_accumulo: "no", fv_uso_diurno: "basso" }, code: "fotovoltaico", size: 2, investment: 4000_00, savings: 273_00, payback: 14.7, products: [], warnings: 1 }, ], pompa_calore: [ // TO (×1,15), 100 m² ristrutturata + ACS: 6,9 + 1,5 = 8,4 kW → 10 kW; 12.350 kWh; metano 1.572 € vs PdC 1.235 € { answers: { percorso: "pompa_calore", provincia: "TO", pdc_superficie: 100, pdc_isolamento: "ristrutturato", pdc_combustibile: "metano", pdc_terminali: "radiatori", pdc_acs: "si" }, code: "pompa_calore", size: 10, investment: 9500_00, savings: 337_00, payback: 28.2, products: [], warnings: 0 }, // FI (×1), 120 m² nuova, pavimento: 4,8 kW → 6 kW; 6.000 kWh; gasolio 1.164,7 € vs 450 € { answers: { percorso: "pompa_calore", provincia: "FI", pdc_superficie: 120, pdc_isolamento: "nuovo", pdc_combustibile: "gasolio", pdc_terminali: "pavimento", pdc_acs: "no" }, code: "pompa_calore", size: 6, investment: 7500_00, savings: 715_00, payback: 10.5, products: [], warnings: 0 }, // PA (×0,8), 90 m² ristrutturata + ACS: 4,32 + 1,5 = 5,82 kW → 6 kW; 8.480 kWh; elettrico 2.544 € vs fancoil 726,9 € { answers: { percorso: "pompa_calore", provincia: "PA", pdc_superficie: 90, pdc_isolamento: "ristrutturato", pdc_combustibile: "elettrico", pdc_terminali: "fancoil", pdc_acs: "si" }, code: "pompa_calore", size: 6, investment: 7500_00, savings: 1817_00, payback: 4.1, products: [], warnings: 0 }, // BZ, 250 m² non isolata, termosifoni: 27,4 kW → sopralluogo + avviso isolamento { answers: { percorso: "pompa_calore", provincia: "BZ", pdc_superficie: 250, pdc_isolamento: "vecchio", pdc_combustibile: "gpl", pdc_terminali: "radiatori", pdc_acs: "si" }, code: "pompa_calore_sopralluogo", size: 27.4, investment: null, savings: 1755_00, payback: null, products: [], warnings: 1 }, ], azienda: [ // BA (1450), 200.000 kWh: 96,6 → 97 kWp; prod. 140.650, autoc. 70%; 97×1.150 → 111.600 € { answers: { percorso: "azienda", provincia: "BA", az_settore: "industria", az_interessi: ["fotovoltaico", "mobilita"], az_consumo_kwh: 200000 }, code: "consulenza_aziendale", size: 97, investment: 111600_00, savings: 33756_00, payback: 3.3, products: [], warnings: 0 }, // Senza fotovoltaico: solo consulenza, nessuna stima { answers: { percorso: "azienda", provincia: "VR", az_settore: "agricoltura", az_interessi: ["acqua"] }, code: "consulenza_aziendale", size: null, investment: null, savings: null, payback: null, products: [], warnings: 0 }, // PA, 5.000 kWh: 2,3 kWp → minimo aziendale 6 kWp; autoconsumo limitato al consumo { answers: { percorso: "azienda", provincia: "PA", az_settore: "ricettivo", az_interessi: ["fotovoltaico"], az_consumo_kwh: 5000 }, code: "consulenza_aziendale", size: 6, investment: 10200_00, savings: 1900_00, payback: 5.4, products: [], warnings: 0 }, ], } describe("calcolo (funzioni pure, casi tabellari)", () => { for (const [path, rows] of Object.entries(CASES)) { describe(path, () => { it.each(rows.map((r, i) => [i + 1, r] as const))("caso %i", (_i, row) => { const v = validateAnswers(definition, row.answers) expect(v.ok, JSON.stringify(v)).toBe(true) if (!v.ok) return const r = evaluate(v.answers) expect(r.path).toBe(path) expect(r.solution.code).toBe(row.code) expect(r.size?.value ?? null).toBe(row.size) expect(r.estimates).toEqual({ investmentCents: row.investment, annualSavingsCents: row.savings, paybackYears: row.payback }) expect(r.products.map((p) => p.handle)).toEqual(row.products) expect(r.warnings).toHaveLength(row.warnings) expect(r.rulesVersion).toBe(RULES_VERSION) expect(r.disclaimer).toMatch(/indicativa/) expect(r.incentivesNote.length).toBeGreaterThan(20) }) }) } }) describe("validazione risposte", () => { const ok = (a: Record) => validateAnswers(definition, a) const errorsOf = (a: Record) => { const r = ok(a) return r.ok ? [] : r.errors } it("percorso mancante o non valido", () => { expect(errorsOf({})).toEqual([{ path: "answers.percorso", message: "Risposta obbligatoria" }]) expect(errorsOf({ percorso: "nucleare" })[0]?.message).toBe("Opzione non valida") }) it("obbligatorie mancanti nel percorso scelto", () => { const errs = errorsOf({ percorso: "fotovoltaico", provincia: "MI" }) expect(errs.map((e) => e.path).sort()).toEqual(["answers.fv_accumulo", "answers.fv_input", "answers.fv_orientamento", "answers.fv_uso_diurno"]) }) it("risposte incoerenti con il branching: domande di altri percorsi o condizioni non soddisfatte", () => { const water = { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "no", acqua_abitazione: "casa" } expect(errorsOf({ ...water, acqua_durezza: 30 })).toEqual([{ path: "answers.acqua_durezza", message: "Risposta non prevista per le scelte fatte" }]) expect(errorsOf({ ...water, pdc_superficie: 100 })[0]?.path).toBe("answers.pdc_superficie") expect(errorsOf({ ...water, provincia: "MI" })[0]?.path).toBe("answers.provincia") expect( errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: ["acqua"], az_consumo_kwh: 10000 })[0]?.path, ).toBe("answers.az_consumo_kwh") expect(errorsOf({ ...water, inventata: 1 })).toEqual([{ path: "answers.inventata", message: "Domanda inesistente" }]) }) it("tipi, range, opzioni, province", () => { const pv = { percorso: "fotovoltaico", fv_input: "kwh", fv_orientamento: "sud", fv_accumulo: "no", fv_uso_diurno: "medio" } expect(errorsOf({ ...pv, provincia: "XX", fv_consumo_kwh: 4000 })[0]?.message).toBe("Provincia non valida") expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: 100 })[0]?.message).toMatch(/tra 500 e 30000/) expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: "4000" })[0]?.message).toBe("Numero atteso") expect(errorsOf({ ...pv, provincia: "MI", fv_consumo_kwh: 4000.5 })[0]?.message).toMatch(/passi/) expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: [] })[0]?.message).toMatch(/almeno 1/) expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: ["acqua", "acqua"] })[0]?.message).toBe("Opzioni ripetute") expect(errorsOf({ percorso: "azienda", provincia: "MI", az_settore: "altro", az_interessi: "acqua" })[0]?.message).toMatch(/Elenco/) }) it("la definizione è coerente: id unici, condizioni su domande precedenti, province con zona", () => { const ids = definition.steps.flatMap((s) => s.questions.map((q) => q.id)) expect(new Set(ids).size).toBe(ids.length) const seen = new Set() for (const step of definition.steps) { for (const c of step.visibleIf ?? []) expect(seen.has(c.question), `${step.id} → ${c.question}`).toBe(true) for (const q of step.questions) { for (const c of q.visibleIf ?? []) expect(seen.has(c.question), `${q.id} → ${c.question}`).toBe(true) seen.add(q.id) } } expect(PROVINCES).toHaveLength(107) expect(new Set(PROVINCES.map((p) => p.code)).size).toBe(107) }) }) describe("route", () => { let t: TestApp const water = { percorso: "acqua", acqua_persone: 4, acqua_durezza_nota: "si", acqua_durezza: 35, acqua_abitazione: "casa" } const contact = { name: "Mario Rossi", email: " Mario@Example.com ", phone: "3331234567" } beforeAll(async () => { t = await buildTestApp() }) afterAll(() => t.app.close()) beforeEach(async () => { await truncateAll() t.email.clear() await t.app.db .insert(users) .values({ email: "admin@iti.local", passwordHash: await hashPassword("x".repeat(12)), role: "admin", firstName: "A", lastName: "D" }) }) it("GET /configurator/definition", async () => { const res = await t.app.inject({ method: "GET", url: "/configurator/definition" }) expect(res.statusCode).toBe(200) expect(res.json().steps[0].questions[0].id).toBe("percorso") expect(res.json().provinces).toHaveLength(107) }) it("POST /configurator/evaluate calcola senza salvare; risposte incoerenti → 400 con dettagli", async () => { const res = await t.app.inject({ method: "POST", url: "/configurator/evaluate", payload: { answers: water } }) expect(res.statusCode).toBe(200) expect(res.json()).toMatchObject({ path: "acqua", solution: { code: "addolcitore" }, estimates: { investmentCents: 150000 } }) expect(await t.app.db.select().from(quoteRequests)).toHaveLength(0) const bad = await t.app.inject({ method: "POST", url: "/configurator/evaluate", payload: { answers: { ...water, pdc_superficie: 80 } } }) expect(bad.statusCode).toBe(400) expect(bad.json().error).toMatchObject({ code: "VALIDATION_ERROR", details: [{ path: "answers.pdc_superficie", message: "Risposta non prevista per le scelte fatte" }], }) }) it("submit senza consenso privacy → 400, niente salvato", async () => { for (const acceptPrivacy of [undefined, false]) { const res = await t.app.inject({ method: "POST", url: "/configurator/submit", payload: { answers: water, contact, acceptPrivacy } }) expect(res.statusCode).toBe(400) } expect(await t.app.db.select().from(quoteRequests)).toHaveLength(0) expect(t.email.sent).toHaveLength(0) }) it("submit salva input/output e consensi, email all'admin e conferma all'utente", async () => { const res = await t.app.inject({ method: "POST", url: "/configurator/submit", payload: { answers: water, contact, message: "Preferisco essere contattato la mattina", acceptPrivacy: true, acceptMarketing: true }, }) expect(res.statusCode).toBe(201) const { id, result } = res.json() expect(result.solution.code).toBe("addolcitore") const [row] = await t.app.db.select().from(quoteRequests).where(eq(quoteRequests.id, id)) expect(row).toMatchObject({ email: "mario@example.com", name: "Mario Rossi", phone: "3331234567", province: null, status: "new", userId: null }) expect(row!.configuratorInput).toMatchObject({ answers: water, message: "Preferisco essere contattato la mattina" }) expect(row!.configuratorOutput).toEqual(result) const c = await t.app.db.select().from(consents) expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"]) expect(c[0]).toMatchObject({ email: "mario@example.com", source: "configurator" }) const admin = t.email.last("quote-request-admin") expect(admin?.to).toBe("admin@iti.local") expect(admin?.text).toContain("Addolcitore 20 litri") expect(admin?.text).toContain(`/admin/richieste/${id}`) expect(admin?.text).toContain("Preferisco essere contattato la mattina") const user = t.email.last("quote-request-received") expect(user?.to).toBe("mario@example.com") expect(user?.text).toMatch(/Investimento indicativo: 1\.500\s€/) }) it("submit da utente autenticato collega la richiesta; provincia dalle risposte", async () => { await t.app.db .insert(users) .values({ email: "cliente@example.com", passwordHash: await hashPassword("Password-di-test-1"), role: "customer", firstName: "C", lastName: "L" }) const login = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "cliente@example.com", password: "Password-di-test-1" } }) const res = await t.app.inject({ method: "POST", url: "/configurator/submit", headers: { authorization: `Bearer ${login.json().accessToken}` }, payload: { answers: { percorso: "azienda", provincia: "VR", az_settore: "agricoltura", az_interessi: ["acqua"] }, contact: { name: "Cliente", email: "cliente@example.com" }, acceptPrivacy: true, }, }) expect(res.statusCode).toBe(201) const [row] = await t.app.db.select().from(quoteRequests) expect(row!.province).toBe("VR") expect(row!.userId).not.toBeNull() }) })