Files
iti-technology/apps/api/test/configurator.test.ts
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

241 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>
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<string, Row[]> = {
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<string, unknown>) => validateAnswers(definition, a)
const errorsOf = (a: Record<string, unknown>) => {
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<string>()
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()
})
})