Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
Deploy stack su Proxmox / deploy (push) Failing after 3s
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
This commit is contained in:
@@ -0,0 +1,240 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user