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,167 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { buildTestApp, cookieFrom, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
let t: TestApp
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await t.app.close()
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.medusa.reset()
|
||||
})
|
||||
|
||||
describe("PATCH /me", () => {
|
||||
it("aggiorna nome e cognome, senza toccare il resto", async () => {
|
||||
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
|
||||
const res = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { firstName: " Mario ", lastName: "Bianchi" } })
|
||||
expect(res.statusCode, res.body).toBe(200)
|
||||
expect(res.json()).toMatchObject({ firstName: "Mario", lastName: "Bianchi", email: "mario@example.com", role: "customer" })
|
||||
expect((await t.app.inject({ method: "GET", url: "/me", headers })).json().lastName).toBe("Bianchi")
|
||||
})
|
||||
|
||||
it("un privato non ha dati aziendali; email e ruolo non si cambiano", async () => {
|
||||
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
|
||||
const res = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: "Mario Srl" } })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error.details[0].path).toBe("body.companyName")
|
||||
const sneaky = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { role: "admin", email: "x@example.com", firstName: "M" } })
|
||||
expect(sneaky.statusCode).toBe(200)
|
||||
expect(sneaky.json()).toMatchObject({ role: "customer", email: "mario@example.com" })
|
||||
})
|
||||
|
||||
it("azienda: ragione sociale e P.IVA modificabili ma non svuotabili", async () => {
|
||||
const { headers } = await createUserAndLogin(t.app, "srl@example.com", "company", { companyName: "Vecchia Srl", vatNumber: "12345678903" })
|
||||
const ok = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: "Nuova Srl" } })
|
||||
expect(ok.json()).toMatchObject({ companyName: "Nuova Srl", vatNumber: "12345678903" })
|
||||
const empty = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { vatNumber: "" } })
|
||||
expect(empty.statusCode).toBe(400)
|
||||
expect(empty.json().error.details[0].path).toBe("body.vatNumber")
|
||||
})
|
||||
|
||||
it("installatore: P.IVA facoltativa, ragione sociale obbligatoria", async () => {
|
||||
const { headers } = await createUserAndLogin(t.app, "inst@example.com", "installer", { companyName: "Impianti Srl", vatNumber: "12345678903" })
|
||||
expect((await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { vatNumber: null } })).json().vatNumber).toBeNull()
|
||||
expect((await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: " " } })).statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it("serve l'autenticazione", async () => {
|
||||
expect((await t.app.inject({ method: "PATCH", url: "/me", payload: { firstName: "X" } })).statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("POST /auth/change-password", () => {
|
||||
async function loginCookie(email: string, password = TEST_PASSWORD) {
|
||||
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password } })
|
||||
return { status: res.statusCode, cookie: cookieFrom(res.headers["set-cookie"], "iti_rt") }
|
||||
}
|
||||
|
||||
it("con la password attuale: nuova sessione qui, le altre chiuse", async () => {
|
||||
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
|
||||
const other = await loginCookie("mario@example.com")
|
||||
const res = await t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/change-password",
|
||||
headers,
|
||||
payload: { currentPassword: TEST_PASSWORD, newPassword: "Nuova-password-123" },
|
||||
})
|
||||
expect(res.statusCode, res.body).toBe(200)
|
||||
expect(res.json()).toMatchObject({ accessToken: expect.any(String), user: { email: "mario@example.com" } })
|
||||
const fresh = cookieFrom(res.headers["set-cookie"], "iti_rt")
|
||||
expect(fresh).toBeTruthy()
|
||||
|
||||
// l'altro dispositivo perde la sessione, questo no
|
||||
const otherRefresh = await t.app.inject({ method: "POST", url: "/auth/refresh", cookies: { iti_rt: other.cookie! } })
|
||||
expect(otherRefresh.statusCode).toBe(401)
|
||||
const thisRefresh = await t.app.inject({ method: "POST", url: "/auth/refresh", cookies: { iti_rt: fresh! } })
|
||||
expect(thisRefresh.statusCode).toBe(200)
|
||||
|
||||
expect((await loginCookie("mario@example.com")).status).toBe(401)
|
||||
expect((await loginCookie("mario@example.com", "Nuova-password-123")).status).toBe(200)
|
||||
})
|
||||
|
||||
it("password attuale errata → 400 sul campo, nulla cambia", async () => {
|
||||
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
|
||||
const res = await t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/change-password",
|
||||
headers,
|
||||
payload: { currentPassword: "sbagliata-123", newPassword: "Nuova-password-123" },
|
||||
})
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error).toMatchObject({ code: "INVALID_CURRENT_PASSWORD", details: [{ path: "body.currentPassword" }] })
|
||||
expect((await loginCookie("mario@example.com")).status).toBe(200)
|
||||
})
|
||||
|
||||
it("nuova password troppo corta o uguale all'attuale → 400", async () => {
|
||||
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
|
||||
for (const newPassword of ["corta", TEST_PASSWORD]) {
|
||||
const res = await t.app.inject({ method: "POST", url: "/auth/change-password", headers, payload: { currentPassword: TEST_PASSWORD, newPassword } })
|
||||
expect(res.statusCode).toBe(400)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("GET /me/orders/:number", () => {
|
||||
const summary = (id: string, displayId: number) => ({
|
||||
id,
|
||||
display_id: displayId,
|
||||
status: "pending",
|
||||
created_at: "2026-10-01T10:00:00.000Z",
|
||||
payment_status: "captured",
|
||||
fulfillment_status: "shipped",
|
||||
item_total: 1190,
|
||||
shipping_total: 15,
|
||||
tax_total: 217.29,
|
||||
total: 1205,
|
||||
items: [],
|
||||
})
|
||||
|
||||
it("dettaglio del proprio ordine con indirizzo, spedizione e tracking; 404 per gli altri", async () => {
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
const luigi = await createUserAndLogin(t.app, "luigi@example.com")
|
||||
await t.app.inject({ method: "GET", url: "/me/orders", headers: mario.headers })
|
||||
const customerId = t.medusa.accountByEmail("mario@example.com")!.id
|
||||
t.medusa.customerOrderList.set(customerId, [summary("order_1", 12)])
|
||||
t.medusa.orderDetails.set("order_1", {
|
||||
...summary("order_1", 12),
|
||||
email: "mario@example.com",
|
||||
customer_id: customerId,
|
||||
currency_code: "eur",
|
||||
discount_total: 0,
|
||||
items: [
|
||||
{ title: "Standard", product_title: "Addolcitore", variant_title: "Standard", variant_sku: "ADD-20", thumbnail: null, quantity: 1, unit_price: 1190, total: 1190 },
|
||||
],
|
||||
shipping_address: { first_name: "Mario", last_name: "Rossi", address_1: "Via Roma 1", city: "Milano", postal_code: "20100", province: "MI" },
|
||||
shipping_methods: [{ name: "Spedizione standard" }],
|
||||
fulfillments: [{ id: "ful_1", labels: [{ tracking_number: "BRT123", tracking_url: "https://track.example.com/BRT123" }] }],
|
||||
})
|
||||
|
||||
const res = await t.app.inject({ method: "GET", url: "/me/orders/12", headers: mario.headers })
|
||||
expect(res.statusCode, res.body).toBe(200)
|
||||
expect(res.json()).toMatchObject({
|
||||
number: 12,
|
||||
fulfillmentStatus: "shipped",
|
||||
totals: { total: 120500, shipping: 1500, discount: 0 },
|
||||
items: [{ title: "Addolcitore - Standard", sku: "ADD-20", unitPrice: 119000 }],
|
||||
shippingAddress: ["Mario Rossi", "Via Roma 1", "20100 Milano (MI)"],
|
||||
shippingMethods: ["Spedizione standard"],
|
||||
tracking: [{ number: "BRT123", url: "https://track.example.com/BRT123" }],
|
||||
})
|
||||
expect(res.body).not.toContain("order_1")
|
||||
expect(res.body).not.toContain(customerId)
|
||||
|
||||
expect((await t.app.inject({ method: "GET", url: "/me/orders/12", headers: luigi.headers })).statusCode).toBe(404)
|
||||
expect((await t.app.inject({ method: "GET", url: "/me/orders/99", headers: mario.headers })).statusCode).toBe(404)
|
||||
expect((await t.app.inject({ method: "GET", url: "/me/orders/abc", headers: mario.headers })).statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it("Medusa giù → 503", async () => {
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
t.medusa.down = true
|
||||
expect((await t.app.inject({ method: "GET", url: "/me/orders/1", headers: mario.headers })).statusCode).toBe(503)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,148 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { contactRequests, installerProfiles, quoteRequests, quotes, showroomBookings, tickets, users } from "../src/db/schema/index.js"
|
||||
import { buildTestApp, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
let t: TestApp
|
||||
let admin: { user: { id: string }; headers: Record<string, string> }
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.medusa.reset()
|
||||
t.email.clear()
|
||||
admin = await createUserAndLogin(t.app, "admin@iti.local", "admin")
|
||||
})
|
||||
|
||||
const call = (method: "GET" | "POST", url: string, headers = admin.headers) =>
|
||||
t.app.inject({ method, url, headers, ...(method === "POST" ? { payload: {} } : {}) })
|
||||
|
||||
const login = (email: string) => t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: TEST_PASSWORD } })
|
||||
|
||||
describe("accesso all'area admin", () => {
|
||||
it("admin disabilitato o declassato: rifiutato subito, anche con un access token ancora valido", async () => {
|
||||
expect((await call("GET", "/admin/summary")).statusCode).toBe(200)
|
||||
|
||||
await t.app.db.update(users).set({ role: "customer" }).where(eq(users.id, admin.user.id))
|
||||
expect((await call("GET", "/admin/summary")).statusCode).toBe(403)
|
||||
|
||||
await t.app.db.update(users).set({ role: "admin", disabledAt: new Date() }).where(eq(users.id, admin.user.id))
|
||||
expect((await call("GET", "/admin/summary")).statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("admin utenti", () => {
|
||||
it("ricerca per testo, ruolo e stato; solo admin", async () => {
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com", "customer", { firstName: "Mario", lastName: "Rossi" })
|
||||
await createUserAndLogin(t.app, "acme@example.com", "company", { companyName: "Acme Srl", vatNumber: "01234567890" })
|
||||
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
|
||||
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
|
||||
|
||||
const all = await call("GET", "/admin/users")
|
||||
expect(all.statusCode).toBe(200)
|
||||
expect(all.json().total).toBe(4)
|
||||
const item = all.json().items.find((u: { email: string }) => u.email === "inst@example.com")
|
||||
expect(item).toMatchObject({ role: "installer", installerStatus: "pending", disabledAt: null })
|
||||
expect(item).not.toHaveProperty("passwordHash")
|
||||
expect(item).not.toHaveProperty("medusaCustomerId")
|
||||
|
||||
expect((await call("GET", "/admin/users?q=mario%20rossi")).json().items.map((u: { email: string }) => u.email)).toEqual(["mario@example.com"])
|
||||
expect((await call("GET", "/admin/users?q=acme")).json().total).toBe(1)
|
||||
expect((await call("GET", "/admin/users?role=company")).json().items[0].companyName).toBe("Acme Srl")
|
||||
expect((await call("GET", "/admin/users?status=disabled")).json().total).toBe(0)
|
||||
// % e _ sono letterali
|
||||
expect((await call("GET", "/admin/users?q=%25")).json().total).toBe(0)
|
||||
|
||||
expect((await call("GET", `/admin/users/${mario.user.id}`)).json()).toMatchObject({ firstName: "Mario", installerStatus: null })
|
||||
expect((await call("GET", "/admin/users/00000000-0000-4000-8000-000000000000")).statusCode).toBe(404)
|
||||
expect((await call("GET", "/admin/users", mario.headers)).statusCode).toBe(403)
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/users" })).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("disabilita: sessioni chiuse e login rifiutato; riabilita: login di nuovo possibile", async () => {
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
const res = await call("POST", `/admin/users/${mario.user.id}/disable`)
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toMatchObject({ medusaSynced: null })
|
||||
expect(res.json().disabledAt).not.toBeNull()
|
||||
|
||||
expect((await login("mario@example.com")).statusCode).toBe(401)
|
||||
expect((await call("GET", "/me/tickets", mario.headers)).statusCode).toBe(401)
|
||||
expect((await call("GET", "/admin/users?status=disabled")).json().total).toBe(1)
|
||||
|
||||
const enabled = await call("POST", `/admin/users/${mario.user.id}/enable`)
|
||||
expect(enabled.json().disabledAt).toBeNull()
|
||||
expect((await login("mario@example.com")).statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it("installatore disabilitato esce dal gruppo del listino riservato, riabilitato rientra", async () => {
|
||||
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
|
||||
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl", status: "approved", approvedAt: new Date() })
|
||||
expect((await call("POST", `/admin/installers/${inst.user.id}/sync`)).json()).toEqual({ member: true })
|
||||
const customerId = t.medusa.accountByEmail("inst@example.com")!.id
|
||||
|
||||
const off = await call("POST", `/admin/users/${inst.user.id}/disable`)
|
||||
expect(off.json()).toMatchObject({ medusaSynced: true, installerStatus: "approved" })
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(false)
|
||||
|
||||
const on = await call("POST", `/admin/users/${inst.user.id}/enable`)
|
||||
expect(on.json()).toMatchObject({ medusaSynced: true })
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
|
||||
|
||||
// Medusa giù: l'operazione riesce comunque
|
||||
t.medusa.down = true
|
||||
expect((await call("POST", `/admin/users/${inst.user.id}/disable`)).json()).toMatchObject({ medusaSynced: false })
|
||||
})
|
||||
|
||||
it("un admin non può disabilitare se stesso", async () => {
|
||||
const res = await call("POST", `/admin/users/${admin.user.id}/disable`)
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json().error.code).toBe("SELF_DISABLE")
|
||||
})
|
||||
})
|
||||
|
||||
describe("admin dashboard", () => {
|
||||
it("contatori", async () => {
|
||||
const empty = await call("GET", "/admin/summary")
|
||||
expect(empty.statusCode).toBe(200)
|
||||
expect(Object.values(empty.json()).every((v) => v === 0)).toBe(true)
|
||||
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
|
||||
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
|
||||
const base = { email: "x@example.com", name: "X", configuratorInput: {} }
|
||||
await t.app.db.insert(quoteRequests).values([base, base, { ...base, status: "closed" }])
|
||||
await t.app.db.insert(tickets).values([
|
||||
{ userId: mario.user.id, subject: "a", status: "open" },
|
||||
{ userId: mario.user.id, subject: "b", status: "waiting_customer" },
|
||||
{ userId: mario.user.id, subject: "c", status: "closed" },
|
||||
])
|
||||
const contact = { name: "X", email: "x@example.com", message: "Messaggio di prova" }
|
||||
await t.app.db.insert(contactRequests).values([contact, { ...contact, status: "closed" }])
|
||||
const day = 24 * 3600 * 1000
|
||||
const booking = { name: "X", email: "x@example.com", people: 2 }
|
||||
await t.app.db.insert(showroomBookings).values([
|
||||
{ ...booking, requestedAt: new Date(Date.now() + 2 * day), status: "confirmed" },
|
||||
{ ...booking, requestedAt: new Date(Date.now() + 3 * day), status: "cancelled" },
|
||||
{ ...booking, requestedAt: new Date(Date.now() - 2 * day), status: "confirmed" },
|
||||
])
|
||||
await t.app.db.insert(quotes).values([
|
||||
{ userId: mario.user.id, year: 2026, seq: 1, number: "2026-0001", title: "A", amountCents: 1, status: "sent" },
|
||||
{ userId: mario.user.id, year: 2026, seq: 2, number: "2026-0002", title: "B", amountCents: 1, status: "sent", validUntil: "2020-01-01" },
|
||||
])
|
||||
|
||||
expect((await call("GET", "/admin/summary")).json()).toEqual({
|
||||
newQuoteRequests: 2,
|
||||
openTickets: 1,
|
||||
activeTickets: 2,
|
||||
pendingInstallers: 1,
|
||||
upcomingShowroomBookings: 1,
|
||||
newContactRequests: 1,
|
||||
sentQuotes: 1,
|
||||
})
|
||||
expect((await call("GET", "/admin/summary", mario.headers)).statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,181 @@
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { buildApp, type App } from "../src/app.js"
|
||||
import { ConfigError, loadConfig } from "../src/config/env.js"
|
||||
import { AppError } from "../src/errors.js"
|
||||
|
||||
const baseEnv = {
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: "postgres://user:pass@localhost:5432/test",
|
||||
CORS_ORIGINS: "http://localhost:4200,http://localhost:4300",
|
||||
COOKIE_SECRET: "x".repeat(32),
|
||||
MEDUSA_PUBLISHABLE_KEY: "pk_test",
|
||||
INTERNAL_API_SECRET: "i".repeat(32),
|
||||
WEBHOOK_SECRET: "w".repeat(32),
|
||||
JWT_SECRET: "j".repeat(32),
|
||||
PRIVATE_APP_URL: "http://localhost:4300",
|
||||
}
|
||||
|
||||
describe("config", () => {
|
||||
it("elenca tutte le variabili mancanti", () => {
|
||||
try {
|
||||
loadConfig({ NODE_ENV: "test" })
|
||||
expect.unreachable()
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(ConfigError)
|
||||
const vars = (err as ConfigError).problems.map((p) => p.split(":")[0])
|
||||
expect(vars).toEqual(
|
||||
expect.arrayContaining([
|
||||
"CORS_ORIGINS",
|
||||
"COOKIE_SECRET",
|
||||
"MEDUSA_PUBLISHABLE_KEY",
|
||||
"INTERNAL_API_SECRET",
|
||||
"WEBHOOK_SECRET",
|
||||
]),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it("richiede LOG_DIR in produzione", () => {
|
||||
expect(() => loadConfig({ ...baseEnv, NODE_ENV: "production" })).toThrow(/LOG_DIR/)
|
||||
})
|
||||
|
||||
it("in produzione: cookie Secure, origini https e niente outbox delle email", () => {
|
||||
const prod = {
|
||||
...baseEnv,
|
||||
NODE_ENV: "production",
|
||||
LOG_DIR: "/var/log/iti",
|
||||
UPLOAD_DIR: "/data/uploads",
|
||||
PUBLIC_UPLOADS_URL: "https://media.example.com",
|
||||
CORS_ORIGINS: "https://example.com,https://area.example.com",
|
||||
}
|
||||
expect(loadConfig(prod).AUTH_COOKIE_SECURE).toBe(true)
|
||||
expect(() => loadConfig({ ...prod, AUTH_COOKIE_SECURE: "false" })).toThrow(/AUTH_COOKIE_SECURE/)
|
||||
expect(() => loadConfig({ ...prod, CORS_ORIGINS: "https://example.com,http://area.example.com" })).toThrow(/CORS_ORIGINS/)
|
||||
expect(() => loadConfig({ ...prod, EMAIL_OUTBOX_DIR: ".outbox" })).toThrow(/EMAIL_OUTBOX_DIR/)
|
||||
})
|
||||
|
||||
it("TRUST_PROXY: booleano o elenco di indirizzi fidati", () => {
|
||||
expect(loadConfig(baseEnv).TRUST_PROXY).toBe(false)
|
||||
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "true" }).TRUST_PROXY).toBe(true)
|
||||
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "false" }).TRUST_PROXY).toBe(false)
|
||||
expect(loadConfig({ ...baseEnv, TRUST_PROXY: "loopback,uniquelocal" }).TRUST_PROXY).toBe("loopback,uniquelocal")
|
||||
})
|
||||
|
||||
it("separa le origini CORS", () => {
|
||||
expect(loadConfig(baseEnv).CORS_ORIGINS).toEqual(["http://localhost:4200", "http://localhost:4300"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("app", () => {
|
||||
let app: App
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildApp(loadConfig(baseEnv))
|
||||
// Route di prova per il formato degli errori
|
||||
app.post(
|
||||
"/__test/validate",
|
||||
{ schema: { body: z.object({ email: z.email() }) } },
|
||||
async () => ({ ok: true }),
|
||||
)
|
||||
app.get("/__test/app-error", async () => {
|
||||
throw new AppError(409, "EMAIL_TAKEN", "Email già registrata")
|
||||
})
|
||||
app.get("/__test/crash", async () => {
|
||||
throw new Error("dettaglio interno")
|
||||
})
|
||||
await app.ready()
|
||||
})
|
||||
afterAll(() => app.close())
|
||||
|
||||
it("GET /health -> 200", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/health" })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toMatchObject({ status: "ok" })
|
||||
})
|
||||
|
||||
it("404 in formato standard", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/non-esiste" })
|
||||
expect(res.statusCode).toBe(404)
|
||||
expect(res.json()).toEqual({
|
||||
error: { code: "NOT_FOUND", message: "Route GET /non-esiste non trovata" },
|
||||
})
|
||||
})
|
||||
|
||||
it("validazione -> 400 VALIDATION_ERROR con details", async () => {
|
||||
const res = await app.inject({ method: "POST", url: "/__test/validate", payload: { email: "x" } })
|
||||
expect(res.statusCode).toBe(400)
|
||||
const body = res.json()
|
||||
expect(body.error.code).toBe("VALIDATION_ERROR")
|
||||
expect(body.error.details[0].path).toBe("body.email")
|
||||
})
|
||||
|
||||
it("AppError -> status e codice applicativi", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/__test/app-error" })
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json()).toEqual({ error: { code: "EMAIL_TAKEN", message: "Email già registrata" } })
|
||||
})
|
||||
|
||||
it("errore inatteso -> 500 senza stack", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/__test/crash" })
|
||||
expect(res.statusCode).toBe(500)
|
||||
expect(res.json()).toEqual({ error: { code: "INTERNAL_ERROR", message: "dettaglio interno" } })
|
||||
expect(res.body).not.toContain("at ")
|
||||
})
|
||||
|
||||
it("CORS: origine consentita con credenziali, altre no", async () => {
|
||||
const ok = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: "/health",
|
||||
headers: { origin: "http://localhost:4200", "access-control-request-method": "GET" },
|
||||
})
|
||||
expect(ok.headers["access-control-allow-origin"]).toBe("http://localhost:4200")
|
||||
expect(ok.headers["access-control-allow-credentials"]).toBe("true")
|
||||
|
||||
const ko = await app.inject({ method: "GET", url: "/health", headers: { origin: "https://evil.example" } })
|
||||
expect(ko.headers["access-control-allow-origin"]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("header di sicurezza helmet", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/health" })
|
||||
expect(res.headers["x-content-type-options"]).toBe("nosniff")
|
||||
expect(res.headers["cross-origin-resource-policy"]).toBe("same-site")
|
||||
})
|
||||
})
|
||||
|
||||
describe("produzione", () => {
|
||||
it("errore 5xx con messaggio generico", async () => {
|
||||
const app = await buildApp({ ...loadConfig({ ...baseEnv, LOG_DIR: join(tmpdir(), "iti-api-test-logs") }), NODE_ENV: "production", LOG_LEVEL: "silent" })
|
||||
app.get("/__test/crash", async () => {
|
||||
throw new Error("dettaglio interno")
|
||||
})
|
||||
const res = await app.inject({ method: "GET", url: "/__test/crash" })
|
||||
expect(res.json()).toEqual({ error: { code: "INTERNAL_ERROR", message: "Errore interno" } })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("TRUST_PROXY con indirizzi fidati: IP del client da X-Forwarded-For solo se arriva da un proxy fidato", async () => {
|
||||
const app = await buildApp({ ...loadConfig(baseEnv), TRUST_PROXY: "loopback,uniquelocal" })
|
||||
app.get("/ip", async (request) => ({ ip: request.ip }))
|
||||
await app.ready()
|
||||
const ipFrom = async (remoteAddress: string) =>
|
||||
(await app.inject({ method: "GET", url: "/ip", remoteAddress, headers: { "x-forwarded-for": "203.0.113.7" } })).json().ip
|
||||
// Caddy o server SSR sulla rete Docker → IP del visitatore
|
||||
expect(await ipFrom("172.18.0.5")).toBe("203.0.113.7")
|
||||
// Client diretto (non fidato): l'intestazione viene ignorata
|
||||
expect(await ipFrom("198.51.100.20")).toBe("198.51.100.20")
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("rate limit -> 429 RATE_LIMITED", async () => {
|
||||
const app = await buildApp(loadConfig({ ...baseEnv, RATE_LIMIT_MAX: "2" }))
|
||||
app.get("/__test/limited", async () => ({ ok: true }))
|
||||
for (let i = 0; i < 2; i++) await app.inject({ method: "GET", url: "/__test/limited" })
|
||||
const res = await app.inject({ method: "GET", url: "/__test/limited" })
|
||||
expect(res.statusCode).toBe(429)
|
||||
expect(res.json().error.code).toBe("RATE_LIMITED")
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,357 @@
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { consents, installerProfiles, refreshTokens, users } from "../src/db/schema/index.js"
|
||||
import { REFRESH_COOKIE, SESSION_HINT_COOKIE } from "../src/routes/public/auth.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { hashToken } from "../src/services/tokens.js"
|
||||
import { buildTestApp, cookieFrom, type TestApp, tokenFromEmail, truncateAll } from "./helpers.js"
|
||||
|
||||
const PASSWORD = "Password-di-test-1"
|
||||
|
||||
let t: TestApp
|
||||
const db = () => t.app.db
|
||||
|
||||
const register = (body: Record<string, unknown>) =>
|
||||
t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { password: PASSWORD, firstName: "Mario", lastName: "Rossi", acceptPrivacy: true, ...body },
|
||||
})
|
||||
|
||||
const login = (email: string, password = PASSWORD) =>
|
||||
t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password } })
|
||||
|
||||
const refresh = (rt: string | undefined) =>
|
||||
t.app.inject({ method: "POST", url: "/auth/refresh", cookies: rt ? { [REFRESH_COOKIE]: rt } : {} })
|
||||
|
||||
/** Crea un utente direttamente nel DB (es. admin, che non può registrarsi dalla route). */
|
||||
async function createUser(email: string, role: "customer" | "admin" = "customer", extra: Partial<typeof users.$inferInsert> = {}) {
|
||||
const [u] = await db()
|
||||
.insert(users)
|
||||
.values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "T", lastName: "U", ...extra })
|
||||
.returning()
|
||||
return u!
|
||||
}
|
||||
|
||||
/** Login e restituisce access token + refresh token (dal cookie). */
|
||||
async function session(email: string) {
|
||||
const res = await login(email)
|
||||
expect(res.statusCode).toBe(200)
|
||||
return { access: res.json().accessToken as string, rt: cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)! }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp({}, (app) => {
|
||||
// Route di prova per requireRole
|
||||
app.get("/__test/admin-only", { onRequest: [app.requireRole("admin")] }, async () => ({ ok: true }))
|
||||
app.get("/__test/staff", { onRequest: [app.requireRole("admin", "installer")] }, async () => ({ ok: true }))
|
||||
})
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.email.clear()
|
||||
})
|
||||
|
||||
describe("registrazione", () => {
|
||||
it("crea un customer con consenso privacy, email di verifica e password hashata", async () => {
|
||||
const res = await register({ email: " Mario.Rossi@Example.COM " })
|
||||
expect(res.statusCode).toBe(202)
|
||||
|
||||
const [u] = await db().select().from(users)
|
||||
expect(u).toMatchObject({ email: "mario.rossi@example.com", role: "customer", emailVerifiedAt: null })
|
||||
expect(u!.passwordHash.startsWith("$argon2id$")).toBe(true)
|
||||
expect(u!.passwordHash).not.toContain(PASSWORD)
|
||||
|
||||
const c = await db().select().from(consents).where(eq(consents.userId, u!.id))
|
||||
expect(c.map((x) => x.type)).toEqual(["privacy"])
|
||||
|
||||
expect(t.email.last("verify-email")?.to).toBe("mario.rossi@example.com")
|
||||
})
|
||||
|
||||
it("ignora un ruolo admin passato nel body", async () => {
|
||||
await register({ email: "furbo@example.com", role: "admin", accountType: "customer" })
|
||||
const [u] = await db().select().from(users)
|
||||
expect(u!.role).toBe("customer")
|
||||
})
|
||||
|
||||
it("rifiuta accountType admin", async () => {
|
||||
const res = await register({ email: "furbo@example.com", accountType: "admin" })
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it("company con ragione sociale + P.IVA, anche senza accountType", async () => {
|
||||
await register({ email: "azienda@example.com", companyName: "Rossi srl", vatNumber: "IT01234567890" })
|
||||
const [u] = await db().select().from(users)
|
||||
expect(u).toMatchObject({ role: "company", companyName: "Rossi srl", vatNumber: "IT01234567890" })
|
||||
})
|
||||
|
||||
it("company senza P.IVA → 400 con dettaglio", async () => {
|
||||
const res = await register({ email: "azienda@example.com", accountType: "company", companyName: "Rossi srl" })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error.details[0].path).toBe("body.vatNumber")
|
||||
})
|
||||
|
||||
it("installer crea il profilo in stato pending", async () => {
|
||||
await register({
|
||||
email: "inst@example.com",
|
||||
accountType: "installer",
|
||||
companyName: "Impianti Rossi",
|
||||
vatNumber: "IT09876543210",
|
||||
acceptMarketing: true,
|
||||
})
|
||||
const [u] = await db().select().from(users)
|
||||
expect(u!.role).toBe("installer")
|
||||
const [p] = await db().select().from(installerProfiles).where(eq(installerProfiles.userId, u!.id))
|
||||
expect(p).toMatchObject({ businessName: "Impianti Rossi", status: "pending" })
|
||||
const c = await db().select().from(consents).where(eq(consents.userId, u!.id))
|
||||
expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"])
|
||||
})
|
||||
|
||||
it("senza consenso privacy → 400", async () => {
|
||||
const res = await register({ email: "x@example.com", acceptPrivacy: false })
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it("email già registrata: stessa risposta, nessun nuovo utente, avviso al titolare", async () => {
|
||||
const first = await register({ email: "dup@example.com" })
|
||||
t.email.clear()
|
||||
const second = await register({ email: "DUP@example.com", firstName: "Altro" })
|
||||
expect(second.statusCode).toBe(first.statusCode)
|
||||
expect(second.json()).toEqual(first.json())
|
||||
expect(await db().select().from(users)).toHaveLength(1)
|
||||
expect(t.email.last()?.tag).toBe("register-existing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("login", () => {
|
||||
it("ok: access token, utente e cookie di refresh con gli attributi richiesti", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const res = await login("Cliente@Example.com")
|
||||
expect(res.statusCode).toBe(200)
|
||||
const body = res.json()
|
||||
expect(body.user).toMatchObject({ email: "cliente@example.com", role: "customer", emailVerified: false })
|
||||
expect(body.expiresIn).toBe(900)
|
||||
expect(body.accessToken.split(".")).toHaveLength(3)
|
||||
|
||||
const cookie = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${REFRESH_COOKIE}=`))!
|
||||
expect(cookie).toMatch(/HttpOnly/i)
|
||||
expect(cookie).toMatch(/Secure/i)
|
||||
expect(cookie).toMatch(/SameSite=Lax/i)
|
||||
expect(cookie).toMatch(/Path=\/auth/i)
|
||||
|
||||
// Indizio di sessione per i frontend: leggibile da JS, path "/", nessun segreto
|
||||
const hint = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${SESSION_HINT_COOKIE}=`))!
|
||||
expect(hint).toMatch(/^iti_session=1;/)
|
||||
expect(hint).not.toMatch(/HttpOnly/i)
|
||||
expect(hint).toMatch(/Path=\/;/i)
|
||||
expect(hint).toMatch(/SameSite=Lax/i)
|
||||
})
|
||||
|
||||
it("password errata, email inesistente e utente disabilitato → stesso 401", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
await createUser("off@example.com", "customer", { disabledAt: new Date() })
|
||||
const results = await Promise.all([
|
||||
login("cliente@example.com", "sbagliata-123"),
|
||||
login("nessuno@example.com"),
|
||||
login("off@example.com"),
|
||||
])
|
||||
for (const r of results) {
|
||||
expect(r.statusCode).toBe(401)
|
||||
expect(r.json()).toEqual({ error: { code: "INVALID_CREDENTIALS", message: "Email o password non corretti" } })
|
||||
}
|
||||
})
|
||||
|
||||
it("GET /me con e senza access token", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const { access } = await session("cliente@example.com")
|
||||
const me = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}` } })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect(me.json().email).toBe("cliente@example.com")
|
||||
|
||||
const anon = await t.app.inject({ method: "GET", url: "/me" })
|
||||
expect(anon.statusCode).toBe(401)
|
||||
expect(anon.json().error.code).toBe("UNAUTHORIZED")
|
||||
|
||||
const forged = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}x` } })
|
||||
expect(forged.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("refresh", () => {
|
||||
it("ruota il token: nuovo cookie, nuovo access token, il precedente è revocato", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const { rt: rt1 } = await session("cliente@example.com")
|
||||
|
||||
const res = await refresh(rt1)
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json().accessToken).toBeTruthy()
|
||||
const rt2 = cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)!
|
||||
expect(rt2).toBeTruthy()
|
||||
expect(rt2).not.toBe(rt1)
|
||||
|
||||
const rows = await db().select().from(refreshTokens)
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(new Set(rows.map((r) => r.familyId)).size).toBe(1)
|
||||
expect(rows.find((r) => r.tokenHash === hashToken(rt1))?.revokedAt).not.toBeNull()
|
||||
expect(rows.find((r) => r.tokenHash === hashToken(rt2))?.revokedAt).toBeNull()
|
||||
|
||||
// Il nuovo token funziona a sua volta
|
||||
expect((await refresh(rt2)).statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it("riuso di un token revocato → 401 e revoca dell'intera family", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const { rt: rt1 } = await session("cliente@example.com")
|
||||
const rt2 = cookieFrom((await refresh(rt1)).headers["set-cookie"], REFRESH_COOKIE)!
|
||||
|
||||
const reuse = await refresh(rt1)
|
||||
expect(reuse.statusCode).toBe(401)
|
||||
// Anche il token "legittimo" più recente non vale più
|
||||
expect((await refresh(rt2)).statusCode).toBe(401)
|
||||
const active = (await db().select().from(refreshTokens)).filter((r) => r.revokedAt === null)
|
||||
expect(active).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("altre sessioni (family diverse) non sono toccate dal riuso", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const a = await session("cliente@example.com")
|
||||
const b = await session("cliente@example.com")
|
||||
await refresh(a.rt)
|
||||
await refresh(a.rt) // riuso su A
|
||||
expect((await refresh(b.rt)).statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it("senza cookie o con token sconosciuto → 401", async () => {
|
||||
expect((await refresh(undefined)).statusCode).toBe(401)
|
||||
expect((await refresh("token-inventato-abcdefghijklmnopqrstuvwxyz")).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("utente disabilitato dopo il login → refresh rifiutato", async () => {
|
||||
const u = await createUser("cliente@example.com")
|
||||
const { rt } = await session("cliente@example.com")
|
||||
await db().update(users).set({ disabledAt: new Date() }).where(eq(users.id, u.id))
|
||||
expect((await refresh(rt)).statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("logout", () => {
|
||||
it("revoca la sessione e cancella il cookie", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const { rt } = await session("cliente@example.com")
|
||||
|
||||
const res = await t.app.inject({ method: "POST", url: "/auth/logout", cookies: { [REFRESH_COOKIE]: rt } })
|
||||
expect(res.statusCode).toBe(204)
|
||||
const cleared = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${REFRESH_COOKIE}=`))
|
||||
expect(cleared).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i)
|
||||
const hint = ([] as string[]).concat(res.headers["set-cookie"] ?? []).find((c) => c.startsWith(`${SESSION_HINT_COOKIE}=`))
|
||||
expect(hint).toMatch(/Expires=Thu, 01 Jan 1970|Max-Age=0/i)
|
||||
|
||||
expect((await refresh(rt)).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("senza cookie → 204 comunque", async () => {
|
||||
expect((await t.app.inject({ method: "POST", url: "/auth/logout" })).statusCode).toBe(204)
|
||||
})
|
||||
})
|
||||
|
||||
describe("requireRole", () => {
|
||||
it("blocca i ruoli non ammessi, consente quelli ammessi", async () => {
|
||||
await createUser("cliente@example.com", "customer")
|
||||
await createUser("admin@example.com", "admin")
|
||||
const customer = await session("cliente@example.com")
|
||||
const admin = await session("admin@example.com")
|
||||
const get = (url: string, token?: string) =>
|
||||
t.app.inject({ method: "GET", url, headers: token ? { authorization: `Bearer ${token}` } : {} })
|
||||
|
||||
const denied = await get("/__test/admin-only", customer.access)
|
||||
expect(denied.statusCode).toBe(403)
|
||||
expect(denied.json().error.code).toBe("FORBIDDEN")
|
||||
expect((await get("/__test/admin-only", admin.access)).statusCode).toBe(200)
|
||||
expect((await get("/__test/admin-only")).statusCode).toBe(401)
|
||||
expect((await get("/__test/staff", admin.access)).statusCode).toBe(200)
|
||||
expect((await get("/__test/staff", customer.access)).statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe("reset password e verifica email", () => {
|
||||
it("forgot-password: stessa risposta per email esistenti e non, link valido una sola volta", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const { rt } = await session("cliente@example.com")
|
||||
|
||||
const known = await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "cliente@example.com" } })
|
||||
const unknown = await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "x@example.com" } })
|
||||
expect(known.statusCode).toBe(202)
|
||||
expect(unknown.json()).toEqual(known.json())
|
||||
expect(t.email.sent.filter((m) => m.tag === "reset-password")).toHaveLength(1)
|
||||
|
||||
const token = tokenFromEmail(t.email.last("reset-password")?.text)
|
||||
const reset = (password: string) =>
|
||||
t.app.inject({ method: "POST", url: "/auth/reset-password", payload: { token, password } })
|
||||
expect((await reset("Nuova-password-123")).statusCode).toBe(200)
|
||||
expect((await reset("Altra-password-123")).json().error.code).toBe("INVALID_TOKEN")
|
||||
|
||||
expect((await login("cliente@example.com")).statusCode).toBe(401)
|
||||
expect((await login("cliente@example.com", "Nuova-password-123")).statusCode).toBe(200)
|
||||
// Le sessioni precedenti al reset sono chiuse
|
||||
expect((await refresh(rt)).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("verify-email conferma l'indirizzo, il link vale una sola volta", async () => {
|
||||
await register({ email: "nuovo@example.com" })
|
||||
const token = tokenFromEmail(t.email.last("verify-email")?.text)
|
||||
const verify = () => t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token } })
|
||||
|
||||
expect((await verify()).statusCode).toBe(200)
|
||||
expect((await verify()).statusCode).toBe(400)
|
||||
const { access } = await session("nuovo@example.com")
|
||||
const me = await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${access}` } })
|
||||
expect(me.json().emailVerified).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("niente segreti in chiaro", () => {
|
||||
it("token e password non compaiono né nel DB né nei log", async () => {
|
||||
await register({ email: "segreti@example.com" })
|
||||
const verifyToken = tokenFromEmail(t.email.last("verify-email")?.text)
|
||||
await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token: verifyToken } })
|
||||
const res = await login("segreti@example.com")
|
||||
const { accessToken } = res.json()
|
||||
const rt = cookieFrom(res.headers["set-cookie"], REFRESH_COOKIE)!
|
||||
const rotated = cookieFrom((await refresh(rt)).headers["set-cookie"], REFRESH_COOKIE)!
|
||||
await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "segreti@example.com" } })
|
||||
const resetToken = tokenFromEmail(t.email.last("reset-password")?.text)
|
||||
|
||||
const secrets = [PASSWORD, verifyToken, rt, rotated, resetToken, accessToken]
|
||||
|
||||
// DB: dump testuale delle tabelle con token/password
|
||||
const dump = JSON.stringify(
|
||||
await db().execute(
|
||||
sql.raw(`SELECT (SELECT json_agg(u) FROM users u) AS u, (SELECT json_agg(r) FROM refresh_tokens r) AS r,
|
||||
(SELECT json_agg(p) FROM password_reset_tokens p) AS p, (SELECT json_agg(e) FROM email_verification_tokens e) AS e`),
|
||||
),
|
||||
)
|
||||
for (const s of secrets) expect(dump).not.toContain(s)
|
||||
|
||||
// Log: richieste, risposte, header (authorization/cookie sono redatti)
|
||||
await t.app.inject({ method: "GET", url: "/me", headers: { authorization: `Bearer ${accessToken}` } })
|
||||
expect(t.logs.lines.length).toBeGreaterThan(0)
|
||||
for (const s of secrets) expect(t.logs.text).not.toContain(s)
|
||||
})
|
||||
})
|
||||
|
||||
describe("rate limit", () => {
|
||||
it("login: oltre il limite per IP → 429", async () => {
|
||||
const limited = await buildTestApp({ AUTH_LOGIN_RATE_MAX: "3" })
|
||||
try {
|
||||
const codes = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const r = await limited.app.inject({ method: "POST", url: "/auth/login", payload: { email: "a@example.com", password: "x" } })
|
||||
codes.push(r.statusCode)
|
||||
}
|
||||
expect(codes).toEqual([401, 401, 401, 429])
|
||||
} finally {
|
||||
await limited.app.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,227 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { blogPosts, users } from "../src/db/schema/index.js"
|
||||
import { renderMarkdown, slugify } from "../src/services/blog.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
const PASSWORD = "Password-di-test-1"
|
||||
let t: TestApp
|
||||
let admin: string
|
||||
let customer: string
|
||||
|
||||
async function login(email: string, role: "admin" | "customer") {
|
||||
await t.app.db.insert(users).values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "A", lastName: "B" })
|
||||
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
|
||||
return res.json().accessToken as string
|
||||
}
|
||||
|
||||
const asAdmin = (method: "GET" | "POST" | "PATCH" | "DELETE", url: string, payload?: object) =>
|
||||
t.app.inject({ method, url, headers: { authorization: `Bearer ${admin}` }, ...(payload ? { payload } : {}) })
|
||||
|
||||
const createPost = async (body: Record<string, unknown>) => {
|
||||
const res = await asAdmin("POST", "/admin/blog/posts", body)
|
||||
expect(res.statusCode, res.body).toBe(201)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const publicList = async (query = "") => (await t.app.inject({ method: "GET", url: `/blog/posts${query}` })).json()
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
admin = await login("admin@iti.local", "admin")
|
||||
customer = await login("cliente@example.com", "customer")
|
||||
})
|
||||
|
||||
describe("ricerca full-text", () => {
|
||||
it('"pompa calore" trova "Pompe di calore: guida" (stemming italiano + unaccent), ordinato per rilevanza', async () => {
|
||||
await createPost({ title: "Pompe di calore: guida", contentMd: "Come scegliere la pompa di calore.", status: "published" })
|
||||
await createPost({ title: "Fotovoltaico e accumulo", contentMd: "Con una pompa di calore il fotovoltaico rende di più.", status: "published" })
|
||||
await createPost({ title: "Addolcitori per l'acqua", contentMd: "Calcare e durezza.", status: "published" })
|
||||
|
||||
const res = await publicList("?q=pompa%20calore")
|
||||
expect(res.items.map((p: { title: string }) => p.title)).toEqual(["Pompe di calore: guida", "Fotovoltaico e accumulo"])
|
||||
expect(res.total).toBe(2)
|
||||
})
|
||||
|
||||
it("ricerca insensibile agli accenti e sintassi websearch (frasi, esclusioni)", async () => {
|
||||
await createPost({ title: "Perché l'efficienza è importante", contentMd: "Risparmio energetico.", status: "published" })
|
||||
await createPost({ title: "Efficienza della caldaia", contentMd: "Manutenzione.", status: "published" })
|
||||
expect((await publicList("?q=perche")).total).toBe(1)
|
||||
expect((await publicList("?q=efficienza%20-caldaia")).items.map((p: { title: string }) => p.title)).toEqual([
|
||||
"Perché l'efficienza è importante",
|
||||
])
|
||||
expect((await publicList("?q=%22")).total).toBe(0) // query vuota per tsquery: nessun errore
|
||||
})
|
||||
})
|
||||
|
||||
describe("visibilità pubblica", () => {
|
||||
it("bozze e post programmati nel futuro non sono visibili via API pubblica", async () => {
|
||||
const draft = await createPost({ title: "Bozza segreta", contentMd: "x" })
|
||||
const future = await createPost({
|
||||
title: "Programmato",
|
||||
contentMd: "x",
|
||||
status: "published",
|
||||
publishedAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
})
|
||||
const live = await createPost({ title: "Pubblicato", contentMd: "x", status: "published" })
|
||||
|
||||
const list = await publicList()
|
||||
expect(list.items.map((p: { slug: string }) => p.slug)).toEqual([live.slug])
|
||||
expect((await publicList("?q=bozza")).total).toBe(0)
|
||||
for (const slug of [draft.slug, future.slug]) {
|
||||
expect((await t.app.inject({ method: "GET", url: `/blog/posts/${slug}` })).statusCode).toBe(404)
|
||||
}
|
||||
expect((await t.app.inject({ method: "GET", url: `/blog/posts/${live.slug}` })).statusCode).toBe(200)
|
||||
|
||||
const sitemap = (await t.app.inject({ method: "GET", url: "/sitemap-data" })).json()
|
||||
expect(sitemap.posts.map((p: { slug: string }) => p.slug)).toEqual([live.slug])
|
||||
})
|
||||
|
||||
it("filtro per categoria e categorie pubbliche", async () => {
|
||||
const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Energia rinnovabile" })).json()
|
||||
expect(cat.slug).toBe("energia-rinnovabile")
|
||||
await createPost({ title: "Con categoria", contentMd: "x", status: "published", categoryId: cat.id })
|
||||
await createPost({ title: "Senza categoria", contentMd: "x", status: "published" })
|
||||
const res = await publicList("?category=energia-rinnovabile")
|
||||
expect(res.items).toHaveLength(1)
|
||||
expect(res.items[0].category).toMatchObject({ slug: "energia-rinnovabile", name: "Energia rinnovabile" })
|
||||
expect((await t.app.inject({ method: "GET", url: "/blog/categories" })).json()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("dettaglio: markdown + HTML, copertina con URL pubblico", async () => {
|
||||
const p = await createPost({
|
||||
title: "Guida",
|
||||
contentMd: "## Titolo\n\nTesto **forte**.",
|
||||
coverPath: "images/2026/10/abc.png",
|
||||
status: "published",
|
||||
})
|
||||
const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json()
|
||||
expect(detail.contentMd).toBe("## Titolo\n\nTesto **forte**.")
|
||||
expect(detail.contentHtml).toContain("<h2>Titolo</h2>")
|
||||
expect(detail.contentHtml).toContain("<strong>forte</strong>")
|
||||
expect(detail.coverUrl).toBe("http://localhost:3000/uploads/images/2026/10/abc.png")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sanitizzazione", () => {
|
||||
it("script, handler on*, javascript:, iframe, style rimossi", () => {
|
||||
const html = renderMarkdown(
|
||||
[
|
||||
"Testo <script>alert(1)</script>",
|
||||
'<img src="x" onerror="alert(1)">',
|
||||
'<img src="https://cdn.example/a.png" onerror="alert(2)" alt="ok">',
|
||||
"[link](javascript:alert(3))",
|
||||
'<a href="https://example.com" onclick="x()">esterno</a>',
|
||||
'<iframe src="https://evil.example"></iframe>',
|
||||
'<p style="color:red">stile</p>',
|
||||
"",
|
||||
].join("\n\n"),
|
||||
)
|
||||
expect(html).not.toMatch(/<script|onerror|onclick|javascript:|<iframe|style=|data:image/i)
|
||||
expect(html).toContain('<img src="https://cdn.example/a.png" alt="ok" loading="lazy" />')
|
||||
expect(html).toContain('<a href="https://example.com" rel="noopener noreferrer" target="_blank">esterno</a>')
|
||||
})
|
||||
|
||||
it("il dettaglio pubblico e la preview restituiscono HTML ripulito", async () => {
|
||||
const md = 'Ciao <script>alert("x")</script> <img src=x onerror=alert(1)>'
|
||||
const p = await createPost({ title: "XSS", contentMd: md, status: "published" })
|
||||
const detail = (await t.app.inject({ method: "GET", url: `/blog/posts/${p.slug}` })).json()
|
||||
expect(detail.contentHtml).not.toMatch(/<script|onerror/)
|
||||
expect(detail.contentMd).toBe(md) // il sorgente resta invariato
|
||||
|
||||
const preview = await asAdmin("POST", "/admin/blog/preview", { markdown: md })
|
||||
expect(preview.json().html).not.toMatch(/<script|onerror/)
|
||||
expect(preview.json().html).toContain("Ciao")
|
||||
})
|
||||
|
||||
it("markdown standard mantenuto: tabelle, codice, liste", () => {
|
||||
const html = renderMarkdown("| a | b |\n|---|---|\n| 1 | 2 |\n\n```ts\nconst x = 1\n```\n\n- uno\n- due")
|
||||
expect(html).toContain("<table>")
|
||||
expect(html).toContain('<code class="language-ts">')
|
||||
expect(html).toContain("<li>uno</li>")
|
||||
})
|
||||
})
|
||||
|
||||
describe("admin", () => {
|
||||
it("permessi: customer 403, anonimo 401 su tutte le route admin del blog", async () => {
|
||||
const routes: ["GET" | "POST", string][] = [
|
||||
["GET", "/admin/blog/posts"],
|
||||
["POST", "/admin/blog/posts"],
|
||||
["POST", "/admin/blog/preview"],
|
||||
["POST", "/admin/blog/categories"],
|
||||
]
|
||||
for (const [method, url] of routes) {
|
||||
const asCustomer = await t.app.inject({ method, url, headers: { authorization: `Bearer ${customer}` }, payload: {} })
|
||||
expect(asCustomer.statusCode, `${method} ${url}`).toBe(403)
|
||||
expect((await t.app.inject({ method, url, payload: {} })).statusCode, `${method} ${url}`).toBe(401)
|
||||
}
|
||||
})
|
||||
|
||||
it("slug dal titolo, univoco, stabile al cambio titolo; slug esplicito duplicato → 409", async () => {
|
||||
expect(slugify("Pompe di calore: guida 2026!")).toBe("pompe-di-calore-guida-2026")
|
||||
expect(slugify("Perché l'acqua è dura?")).toBe("perche-lacqua-e-dura")
|
||||
|
||||
const a = await createPost({ title: "Pompe di calore: guida" })
|
||||
const b = await createPost({ title: "Pompe di calore: guida" })
|
||||
const c = await createPost({ title: "Pompe di calore guida" })
|
||||
expect([a.slug, b.slug, c.slug]).toEqual(["pompe-di-calore-guida", "pompe-di-calore-guida-2", "pompe-di-calore-guida-3"])
|
||||
|
||||
const renamed = (await asAdmin("PATCH", `/admin/blog/posts/${a.id}`, { title: "Titolo nuovo" })).json()
|
||||
expect(renamed.slug).toBe("pompe-di-calore-guida")
|
||||
|
||||
const dup = await asAdmin("PATCH", `/admin/blog/posts/${b.id}`, { slug: "pompe-di-calore-guida" })
|
||||
expect(dup.statusCode).toBe(409)
|
||||
expect(dup.json().error.code).toBe("SLUG_TAKEN")
|
||||
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", slug: "Non Valido!" })).statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it("stato: pubblicazione imposta published_at, PATCH parziale non tocca stato e contenuto", async () => {
|
||||
const p = await createPost({ title: "Stato", contentMd: "Contenuto originale" })
|
||||
expect(p).toMatchObject({ status: "draft", publishedAt: null })
|
||||
|
||||
const pub = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "published" })).json()
|
||||
expect(pub.status).toBe("published")
|
||||
expect(Date.now() - Date.parse(pub.publishedAt)).toBeLessThan(10_000)
|
||||
|
||||
// Regressione: un PATCH del solo titolo non deve rimettere in bozza né svuotare il contenuto
|
||||
const titled = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { title: "Stato aggiornato" })).json()
|
||||
expect(titled).toMatchObject({ status: "published", contentMd: "Contenuto originale", publishedAt: pub.publishedAt })
|
||||
|
||||
const back = (await asAdmin("PATCH", `/admin/blog/posts/${p.id}`, { status: "draft" })).json()
|
||||
expect(back.status).toBe("draft")
|
||||
expect((await publicList()).total).toBe(0)
|
||||
})
|
||||
|
||||
it("lista admin con bozze e filtro stato; DELETE; autore salvato", async () => {
|
||||
await createPost({ title: "Bozza" })
|
||||
const live = await createPost({ title: "Live", status: "published" })
|
||||
expect((await asAdmin("GET", "/admin/blog/posts")).json().total).toBe(2)
|
||||
expect((await asAdmin("GET", "/admin/blog/posts?status=draft")).json().items.map((p: { title: string }) => p.title)).toEqual(["Bozza"])
|
||||
|
||||
const [row] = await t.app.db.select().from(blogPosts).where(eq(blogPosts.id, live.id))
|
||||
expect(row!.authorId).not.toBeNull()
|
||||
|
||||
expect((await asAdmin("DELETE", `/admin/blog/posts/${live.id}`)).statusCode).toBe(204)
|
||||
expect((await asAdmin("GET", `/admin/blog/posts/${live.id}`)).statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it("categoria inesistente → 400; eliminare una categoria lascia i post senza categoria", async () => {
|
||||
const bad = await asAdmin("POST", "/admin/blog/posts", { title: "X", categoryId: "00000000-0000-4000-8000-000000000000" })
|
||||
expect(bad.json().error.code).toBe("CATEGORY_NOT_FOUND")
|
||||
|
||||
const cat = (await asAdmin("POST", "/admin/blog/categories", { name: "Acqua" })).json()
|
||||
const p = await createPost({ title: "Con cat", categoryId: cat.id })
|
||||
expect((await asAdmin("DELETE", `/admin/blog/categories/${cat.id}`)).statusCode).toBe(204)
|
||||
expect((await asAdmin("GET", `/admin/blog/posts/${p.id}`)).json().category).toBeNull()
|
||||
})
|
||||
|
||||
it("copertina: solo percorsi dell'area immagini", async () => {
|
||||
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "../private/documents/x.pdf" })).statusCode).toBe(400)
|
||||
expect((await asAdmin("POST", "/admin/blog/posts", { title: "X", coverPath: "documents/2026/10/x.pdf" })).statusCode).toBe(400)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { parseEwkbPoint } from "../src/db/schema/columns.js"
|
||||
|
||||
describe("parseEwkbPoint", () => {
|
||||
it("EWKB little-endian con SRID (formato restituito da Postgres)", () => {
|
||||
expect(parseEwkbPoint("0101000020E6100000E17A14AE47612240CF66D5E76ABB4640")).toEqual({ lng: 9.19, lat: 45.4642 })
|
||||
})
|
||||
it("EWKB big-endian con SRID", () => {
|
||||
expect(parseEwkbPoint("0020000001000010e6c05280624dd2f1aa40445b3d07c84b5e")).toEqual({ lng: -74.006, lat: 40.7128 })
|
||||
})
|
||||
it("rifiuta tipi diversi da Point", () => {
|
||||
// LineString (tipo 2), little-endian, senza SRID
|
||||
expect(() => parseEwkbPoint("010200000000000000")).toThrow(/Point/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { consents, contactRequests, refreshTokens, users } from "../src/db/schema/index.js"
|
||||
import { checkFormToken, createFormToken, FORM_TOKEN_TTL_MS } from "../src/services/form-guard.js"
|
||||
import { buildTestApp, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
let t: TestApp
|
||||
let ipSeq = 0
|
||||
const SECRET = "c".repeat(32)
|
||||
const oldToken = () => createFormToken(SECRET, new Date(Date.now() - 10_000)).token
|
||||
|
||||
const contact = (payload: Record<string, unknown>, headers: Record<string, string> = {}) =>
|
||||
t.app.inject({
|
||||
method: "POST",
|
||||
url: "/contact",
|
||||
headers,
|
||||
remoteAddress: `10.1.0.${++ipSeq % 250}`,
|
||||
payload: {
|
||||
name: "Anna Bianchi",
|
||||
email: " Anna@Example.com ",
|
||||
message: "Vorrei informazioni sugli addolcitori",
|
||||
acceptPrivacy: true,
|
||||
formToken: oldToken(),
|
||||
...payload,
|
||||
},
|
||||
})
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.email.clear()
|
||||
})
|
||||
|
||||
describe("token anti-spam", () => {
|
||||
it("firma, tempo minimo e scadenza", () => {
|
||||
const now = new Date("2026-10-03T10:00:00Z")
|
||||
const { token } = createFormToken(SECRET, now)
|
||||
expect(checkFormToken(SECRET, token, new Date(now.getTime() + 1000))).toBe("too_fast")
|
||||
expect(checkFormToken(SECRET, token, new Date(now.getTime() - 60_000))).toBe("too_fast") // dal futuro
|
||||
expect(checkFormToken(SECRET, token, new Date(now.getTime() + 5000))).toBe("ok")
|
||||
expect(checkFormToken(SECRET, token, new Date(now.getTime() + FORM_TOKEN_TTL_MS + 1))).toBe("expired")
|
||||
expect(checkFormToken("x".repeat(32), token, new Date(now.getTime() + 5000))).toBe("invalid")
|
||||
const [issued, sig] = token.split(".")
|
||||
expect(checkFormToken(SECRET, `${Number(issued) - 10_000}.${sig}`, now)).toBe("invalid") // data alterata
|
||||
expect(checkFormToken(SECRET, "abc", now)).toBe("invalid")
|
||||
})
|
||||
|
||||
it("GET /forms/token restituisce un token valido, non in cache", async () => {
|
||||
const res = await t.app.inject({ method: "GET", url: "/forms/token" })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers["cache-control"]).toBe("no-store")
|
||||
expect(res.json().minSeconds).toBe(3)
|
||||
expect(checkFormToken(SECRET, res.json().token, new Date(Date.now() + 5000))).toBe("ok")
|
||||
})
|
||||
})
|
||||
|
||||
describe("form contatti", () => {
|
||||
it("salva la richiesta (email normalizzata), i consensi e avvisa lo staff", async () => {
|
||||
await createUserAndLogin(t.app, "admin@iti.local", "admin")
|
||||
t.email.clear()
|
||||
const res = await contact({ subject: "Addolcitore", phone: "0391234567", acceptMarketing: true })
|
||||
expect(res.statusCode, res.body).toBe(200)
|
||||
|
||||
const [saved] = await t.app.db.select().from(contactRequests)
|
||||
expect(saved).toMatchObject({ email: "anna@example.com", subject: "Addolcitore", status: "new", userId: null })
|
||||
const c = await t.app.db.select().from(consents)
|
||||
expect(c.map((x) => x.type).sort()).toEqual(["marketing", "privacy"])
|
||||
expect(c.every((x) => x.source === "contact" && x.version === "privacy-v1" && x.email === "anna@example.com")).toBe(true)
|
||||
const mail = t.email.last("staff-notification")
|
||||
expect(mail?.to).toBe("admin@iti.local")
|
||||
expect(mail?.text).toContain("Vorrei informazioni sugli addolcitori")
|
||||
expect(mail?.text).toContain(`/admin/contatti/${saved!.id}`)
|
||||
})
|
||||
|
||||
it("senza consenso privacy → 400, nulla salvato", async () => {
|
||||
for (const acceptPrivacy of [false, undefined]) {
|
||||
const res = await contact({ acceptPrivacy })
|
||||
expect(res.statusCode).toBe(400)
|
||||
expect(res.json().error.code).toBe("VALIDATION_ERROR")
|
||||
}
|
||||
expect(await t.app.db.select().from(contactRequests)).toHaveLength(0)
|
||||
expect(await t.app.db.select().from(consents)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("honeypot compilato o invio troppo rapido → 200, nulla salvato né inviato", async () => {
|
||||
const honeypot = await contact({ website: "http://spam.example" })
|
||||
expect(honeypot.statusCode).toBe(200)
|
||||
const fast = await contact({ formToken: createFormToken(SECRET).token })
|
||||
expect(fast.statusCode).toBe(200)
|
||||
expect(fast.json()).toEqual(honeypot.json())
|
||||
expect(await t.app.db.select().from(contactRequests)).toHaveLength(0)
|
||||
expect(await t.app.db.select().from(consents)).toHaveLength(0)
|
||||
expect(t.email.sent).toHaveLength(0)
|
||||
|
||||
const expired = await contact({ formToken: createFormToken(SECRET, new Date(Date.now() - FORM_TOKEN_TTL_MS - 1000)).token })
|
||||
expect(expired.statusCode).toBe(400)
|
||||
expect(expired.json().error.code).toBe("FORM_EXPIRED")
|
||||
expect((await contact({ formToken: undefined })).statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it("rate limit per IP: 5 invii ogni 15 minuti", async () => {
|
||||
const send = () =>
|
||||
t.app.inject({
|
||||
method: "POST",
|
||||
url: "/contact",
|
||||
remoteAddress: "10.9.9.9",
|
||||
payload: { name: "Bot", email: "bot@example.com", message: "x".repeat(20), acceptPrivacy: true, formToken: oldToken() },
|
||||
})
|
||||
for (let i = 0; i < 5; i++) expect((await send()).statusCode).toBe(200)
|
||||
expect((await send()).statusCode).toBe(429)
|
||||
})
|
||||
|
||||
it("utente autenticato collegato; admin: elenco, ricerca e cambio stato", async () => {
|
||||
const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
await contact({ email: "mario@example.com", subject: "Fotovoltaico" }, mario.headers)
|
||||
await contact({})
|
||||
|
||||
const list = await t.app.inject({ method: "GET", url: "/admin/contact-requests?q=mario", headers: admin })
|
||||
expect(list.json()).toMatchObject({ total: 1, items: [{ userId: mario.user.id, subject: "Fotovoltaico" }] })
|
||||
const id = list.json().items[0].id
|
||||
const patched = await t.app.inject({ method: "PATCH", url: `/admin/contact-requests/${id}`, headers: admin, payload: { status: "closed" } })
|
||||
expect(patched.json().status).toBe("closed")
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/contact-requests?status=new", headers: admin })).json().total).toBe(1)
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/contact-requests", headers: mario.headers })).statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe("consensi dell'utente", () => {
|
||||
it("elenco con i consensi anonimi della stessa email solo se verificata; revoca e nuovo consenso marketing", async () => {
|
||||
// consensi dati da anonimo prima della registrazione
|
||||
await contact({ email: "mario@example.com", acceptMarketing: true })
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
await t.app.db.insert(consents).values({ userId: mario.user.id, type: "privacy", version: "privacy-v1", source: "register" })
|
||||
|
||||
const get = () => t.app.inject({ method: "GET", url: "/me/consents", headers: mario.headers })
|
||||
const before = (await get()).json()
|
||||
expect(before.marketing).toBe(true)
|
||||
expect(before.items).toHaveLength(3)
|
||||
expect(before.items[0]).toEqual({
|
||||
id: expect.any(String),
|
||||
type: expect.any(String),
|
||||
version: "privacy-v1",
|
||||
source: expect.any(String),
|
||||
acceptedAt: expect.any(String),
|
||||
revokedAt: null,
|
||||
})
|
||||
|
||||
const revoked = await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: mario.headers })
|
||||
expect(revoked.json().marketing).toBe(false)
|
||||
const marketing = revoked.json().items.filter((c: { type: string }) => c.type === "marketing")
|
||||
expect(marketing).toHaveLength(1)
|
||||
expect(marketing[0].revokedAt).not.toBeNull()
|
||||
expect((await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: mario.headers })).statusCode).toBe(200)
|
||||
|
||||
const granted = await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers })
|
||||
expect(granted.json().marketing).toBe(true)
|
||||
await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers }) // idempotente
|
||||
const rows = await t.app.db.select().from(consents).where(eq(consents.type, "marketing"))
|
||||
expect(rows.filter((c) => !c.revokedAt)).toHaveLength(1)
|
||||
expect(rows.find((c) => !c.revokedAt)).toMatchObject({ source: "account", userId: mario.user.id })
|
||||
|
||||
// email non verificata: i consensi anonimi della stessa email non sono suoi
|
||||
await contact({ email: "luigi@example.com", acceptMarketing: true })
|
||||
const luigi = await createUserAndLogin(t.app, "luigi@example.com", "customer", { emailVerifiedAt: null })
|
||||
const luigiConsents = (await t.app.inject({ method: "GET", url: "/me/consents", headers: luigi.headers })).json()
|
||||
expect(luigiConsents).toEqual({ marketing: false, items: [] })
|
||||
await t.app.inject({ method: "DELETE", url: "/me/consents/marketing", headers: luigi.headers })
|
||||
const anon = await t.app.db.select().from(consents).where(eq(consents.email, "luigi@example.com"))
|
||||
expect(anon.every((c) => c.revokedAt === null)).toBe(true)
|
||||
|
||||
expect((await t.app.inject({ method: "GET", url: "/me/consents" })).statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("richiesta di cancellazione dell'account", () => {
|
||||
it("disabilita l'account, chiude le sessioni, revoca il marketing e avvisa lo staff", async () => {
|
||||
await createUserAndLogin(t.app, "admin@iti.local", "admin")
|
||||
const mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
await t.app.inject({ method: "POST", url: "/me/consents/marketing", headers: mario.headers })
|
||||
t.email.clear()
|
||||
|
||||
const del = (password: string) =>
|
||||
t.app.inject({ method: "POST", url: "/me/delete-account", headers: mario.headers, payload: { password, reason: "Non uso più il servizio" } })
|
||||
const wrong = await del("password-sbagliata")
|
||||
expect(wrong.statusCode).toBe(401)
|
||||
expect((await t.app.db.select().from(users).where(eq(users.id, mario.user.id)))[0]!.disabledAt).toBeNull()
|
||||
|
||||
expect((await del(TEST_PASSWORD)).statusCode).toBe(202)
|
||||
const [user] = await t.app.db.select().from(users).where(eq(users.id, mario.user.id))
|
||||
expect(user!.disabledAt).not.toBeNull()
|
||||
const tokens = await t.app.db.select().from(refreshTokens).where(eq(refreshTokens.userId, mario.user.id))
|
||||
expect(tokens.every((r) => r.revokedAt !== null)).toBe(true)
|
||||
const marketing = await t.app.db.select().from(consents).where(eq(consents.userId, mario.user.id))
|
||||
expect(marketing.filter((c) => c.type === "marketing").every((c) => c.revokedAt !== null)).toBe(true)
|
||||
|
||||
const mail = t.email.last("staff-notification")
|
||||
expect(mail?.to).toBe("admin@iti.local")
|
||||
expect(mail?.text).toContain("mario@example.com")
|
||||
expect(mail?.text).toContain("Non uso più il servizio")
|
||||
|
||||
// account non più utilizzabile
|
||||
expect((await t.app.inject({ method: "GET", url: "/me", headers: mario.headers })).statusCode).toBe(401)
|
||||
const login = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "mario@example.com", password: TEST_PASSWORD } })
|
||||
expect(login.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("un admin non può cancellarsi da solo", async () => {
|
||||
const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
|
||||
const res = await t.app.inject({ method: "POST", url: "/me/delete-account", headers: admin, payload: { password: TEST_PASSWORD } })
|
||||
expect(res.statusCode).toBe(409)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { getTableColumns } from "drizzle-orm"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { emailVerificationTokens, passwordResetTokens, refreshTokens, users } from "../src/db/schema/index.js"
|
||||
import { hashPassword, verifyPassword } from "../src/services/password.js"
|
||||
import { generateToken, hashToken } from "../src/services/tokens.js"
|
||||
|
||||
describe("token opachi", () => {
|
||||
it("256 bit casuali, hash SHA-256 diverso dal token", () => {
|
||||
const { token, hash } = generateToken()
|
||||
expect(Buffer.from(token, "base64url")).toHaveLength(32)
|
||||
expect(hash).toMatch(/^[0-9a-f]{64}$/)
|
||||
expect(hash).not.toContain(token)
|
||||
expect(hashToken(token)).toBe(hash)
|
||||
expect(generateToken().token).not.toBe(token)
|
||||
})
|
||||
})
|
||||
|
||||
describe("password", () => {
|
||||
it("argon2id, verifica ok/ko, hash malformato -> false", async () => {
|
||||
const h = await hashPassword("una password lunga")
|
||||
expect(h.startsWith("$argon2id$")).toBe(true)
|
||||
expect(await verifyPassword(h, "una password lunga")).toBe(true)
|
||||
expect(await verifyPassword(h, "sbagliata")).toBe(false)
|
||||
expect(await verifyPassword("non-un-hash", "x")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("schema: niente segreti in chiaro", () => {
|
||||
it.each([
|
||||
["refresh_tokens", refreshTokens],
|
||||
["password_reset_tokens", passwordResetTokens],
|
||||
["email_verification_tokens", emailVerificationTokens],
|
||||
])("%s ha solo token_hash", (_name, table) => {
|
||||
const columns = Object.values(getTableColumns(table)).map((c) => c.name)
|
||||
expect(columns).toContain("token_hash")
|
||||
expect(columns.filter((c) => /token|secret/.test(c) && c !== "token_hash")).toEqual([])
|
||||
})
|
||||
|
||||
it("users ha password_hash e nessuna colonna password in chiaro", () => {
|
||||
const columns = Object.values(getTableColumns(users)).map((c) => c.name)
|
||||
expect(columns).toContain("password_hash")
|
||||
expect(columns).not.toContain("password")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { readdir, rm } from "node:fs/promises"
|
||||
import { join } from "node:path"
|
||||
import { Readable } from "node:stream"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { documents, installerProfiles, users } from "../src/db/schema/index.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { checkFileKind, InvalidStorageKeyError, LocalDiskStorage, sniffKind } from "../src/services/storage.js"
|
||||
import { buildTestApp, multipartBody, TEST_UPLOAD_DIR, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
const PASSWORD = "Password-di-test-1"
|
||||
const PDF = Buffer.from("%PDF-1.7\n1 0 obj << >> endobj\ntrailer << >>\n%%EOF\n")
|
||||
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(32, 1)])
|
||||
const ZIP = Buffer.concat([Buffer.from("PK\x03\x04", "latin1"), Buffer.alloc(30)])
|
||||
const DWG = Buffer.concat([Buffer.from("AC1032"), Buffer.alloc(30)])
|
||||
const DXF = Buffer.from(" 0\r\nSECTION\r\n 2\r\nHEADER\r\n")
|
||||
const HTML = Buffer.from("<!doctype html><script>alert(1)</script>")
|
||||
|
||||
let t: TestApp
|
||||
const tokens: Record<string, string> = {}
|
||||
|
||||
async function user(email: string, role: "customer" | "company" | "installer" | "admin", installerStatus?: "pending" | "approved") {
|
||||
const [u] = await t.app.db
|
||||
.insert(users)
|
||||
.values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "T", lastName: "U" })
|
||||
.returning()
|
||||
if (installerStatus) await t.app.db.insert(installerProfiles).values({ userId: u!.id, businessName: "Srl", status: installerStatus })
|
||||
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
|
||||
tokens[email] = res.json().accessToken
|
||||
return u!
|
||||
}
|
||||
|
||||
const auth = (email?: string) => (email ? { authorization: `Bearer ${tokens[email]}` } : {})
|
||||
|
||||
function upload(
|
||||
file: { filename: string; contentType: string; content: Buffer | string } | null,
|
||||
fields: Record<string, string> = { title: "Scheda tecnica", visibility: "installer" },
|
||||
as = "admin@iti.local",
|
||||
url = "/admin/documents",
|
||||
) {
|
||||
const body = multipartBody(fields, file ? [file] : [])
|
||||
return t.app.inject({ method: "POST", url, payload: body.payload, headers: { ...body.headers, ...auth(as) } })
|
||||
}
|
||||
|
||||
async function filesOnDisk(): Promise<string[]> {
|
||||
const out: string[] = []
|
||||
const walk = async (dir: string) => {
|
||||
if (!existsSync(dir)) return
|
||||
for (const e of await readdir(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, e.name)
|
||||
if (e.isDirectory()) await walk(p)
|
||||
else out.push(p)
|
||||
}
|
||||
}
|
||||
await walk(TEST_UPLOAD_DIR)
|
||||
return out
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await t.app.close()
|
||||
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
|
||||
await user("admin@iti.local", "admin")
|
||||
await user("cliente@example.com", "customer")
|
||||
await user("inst@example.com", "installer", "approved")
|
||||
await user("attesa@example.com", "installer", "pending")
|
||||
})
|
||||
|
||||
describe("upload e download per ruolo", () => {
|
||||
it("un installatore approvato scarica i documenti installer; customer 403, installatore in attesa 403, anonimo 401", async () => {
|
||||
const res = await upload({ filename: "Scheda PDC 8kW.pdf", contentType: "application/pdf", content: PDF })
|
||||
expect(res.statusCode).toBe(201)
|
||||
const doc = res.json()
|
||||
expect(doc).toMatchObject({ title: "Scheda tecnica", visibility: "installer", mimeType: "application/pdf", sizeBytes: PDF.length })
|
||||
expect(JSON.stringify(doc)).not.toMatch(/file_?path|documents\//i)
|
||||
|
||||
const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("inst@example.com") })
|
||||
expect(dl.statusCode).toBe(200)
|
||||
expect(dl.rawPayload.equals(PDF)).toBe(true)
|
||||
expect(dl.headers["content-type"]).toBe("application/pdf")
|
||||
expect(dl.headers["content-disposition"]).toBe(`attachment; filename="Scheda tecnica.pdf"; filename*=UTF-8''Scheda%20tecnica.pdf`)
|
||||
expect(dl.headers["cache-control"]).toBe("private, no-store")
|
||||
|
||||
const forbidden = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("cliente@example.com") })
|
||||
expect(forbidden.statusCode).toBe(403)
|
||||
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("attesa@example.com") })).statusCode).toBe(403)
|
||||
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` })).statusCode).toBe(401)
|
||||
expect((await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download`, headers: auth("admin@iti.local") })).statusCode).toBe(200)
|
||||
})
|
||||
|
||||
it("documento pubblico scaricabile da anonimo; documento inesistente 404", async () => {
|
||||
const doc = (await upload({ filename: "catalogo.pdf", contentType: "application/pdf", content: PDF }, { title: "Catalogo", visibility: "public" })).json()
|
||||
const dl = await t.app.inject({ method: "GET", url: `/documents/${doc.id}/download` })
|
||||
expect(dl.statusCode).toBe(200)
|
||||
expect(dl.headers["cache-control"]).toBe("public, max-age=300")
|
||||
expect((await t.app.inject({ method: "GET", url: "/documents/00000000-0000-4000-8000-000000000000/download" })).statusCode).toBe(404)
|
||||
})
|
||||
|
||||
it("GET /documents elenca solo i livelli visibili al ruolo", async () => {
|
||||
for (const visibility of ["public", "customer", "installer", "admin"]) {
|
||||
await upload({ filename: "x.pdf", contentType: "application/pdf", content: PDF }, { title: visibility, visibility, category: "schede" })
|
||||
}
|
||||
const titles = async (email?: string) =>
|
||||
((await t.app.inject({ method: "GET", url: "/documents", headers: auth(email) })).json().items as { title: string }[])
|
||||
.map((d) => d.title)
|
||||
.sort()
|
||||
expect(await titles()).toEqual(["public"])
|
||||
expect(await titles("cliente@example.com")).toEqual(["customer", "public"])
|
||||
expect(await titles("attesa@example.com")).toEqual(["customer", "public"])
|
||||
expect(await titles("inst@example.com")).toEqual(["customer", "installer", "public"])
|
||||
expect(await titles("admin@iti.local")).toEqual(["admin", "customer", "installer", "public"])
|
||||
})
|
||||
|
||||
it("tutti i tipi ammessi: immagini, PDF, ZIP, DWG, DXF (MIME canonico)", async () => {
|
||||
const cases = [
|
||||
{ filename: "foto.PNG", contentType: "image/png", content: PNG, mime: "image/png" },
|
||||
{ filename: "progetto.zip", contentType: "application/x-zip-compressed", content: ZIP, mime: "application/zip" },
|
||||
{ filename: "pianta.dwg", contentType: "application/octet-stream", content: DWG, mime: "image/vnd.dwg" },
|
||||
{ filename: "pianta.dxf", contentType: "application/dxf", content: DXF, mime: "image/vnd.dxf" },
|
||||
]
|
||||
for (const c of cases) {
|
||||
const res = await upload(c, { title: c.filename, visibility: "admin" })
|
||||
expect(res.statusCode, c.filename).toBe(201)
|
||||
expect(res.json().mimeType).toBe(c.mime)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("rifiuti", () => {
|
||||
it("estensione o MIME non consentiti, contenuto non coerente → 415 e niente su disco", async () => {
|
||||
const rejected = [
|
||||
{ filename: "virus.exe", contentType: "application/octet-stream", content: Buffer.from("MZ...") },
|
||||
{ filename: "logo.svg", contentType: "image/svg+xml", content: Buffer.from("<svg onload=alert(1)/>") },
|
||||
{ filename: "pagina.html", contentType: "text/html", content: HTML },
|
||||
{ filename: "finto.pdf", contentType: "application/pdf", content: HTML },
|
||||
{ filename: "finta.jpg", contentType: "image/jpeg", content: PNG },
|
||||
{ filename: "doc.pdf", contentType: "text/html", content: PDF },
|
||||
{ filename: "senza-estensione", contentType: "application/pdf", content: PDF },
|
||||
{ filename: "vuoto.pdf", contentType: "application/pdf", content: Buffer.alloc(0) },
|
||||
]
|
||||
for (const file of rejected) {
|
||||
const res = await upload(file)
|
||||
expect(res.statusCode, file.filename).toBe(415)
|
||||
expect(res.json().error.code).toBe("UNSUPPORTED_FILE_TYPE")
|
||||
}
|
||||
expect(await filesOnDisk()).toEqual([])
|
||||
expect(await t.app.db.select().from(documents)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("file oltre UPLOAD_MAX_BYTES → 413 e file parziale cancellato", async () => {
|
||||
const small = await buildTestApp({ UPLOAD_MAX_BYTES: "100" })
|
||||
try {
|
||||
const login = await small.app.inject({ method: "POST", url: "/auth/login", payload: { email: "admin@iti.local", password: PASSWORD } })
|
||||
const body = multipartBody({ title: "Grande", visibility: "admin" }, [
|
||||
{ filename: "grande.pdf", contentType: "application/pdf", content: Buffer.concat([PDF, Buffer.alloc(500)]) },
|
||||
])
|
||||
const res = await small.app.inject({
|
||||
method: "POST",
|
||||
url: "/admin/documents",
|
||||
payload: body.payload,
|
||||
headers: { ...body.headers, authorization: `Bearer ${login.json().accessToken}` },
|
||||
})
|
||||
expect(res.statusCode).toBe(413)
|
||||
expect(res.json().error.code).toBe("FILE_TOO_LARGE")
|
||||
} finally {
|
||||
await small.app.close()
|
||||
}
|
||||
expect(await filesOnDisk()).toEqual([])
|
||||
})
|
||||
|
||||
it("nessun file, due file, metadati non validi → 400 senza file residui", async () => {
|
||||
expect((await upload(null)).json().error.code).toBe("FILE_REQUIRED")
|
||||
|
||||
const two = multipartBody({ title: "Due" }, [
|
||||
{ filename: "a.pdf", contentType: "application/pdf", content: PDF },
|
||||
{ filename: "b.pdf", contentType: "application/pdf", content: PDF },
|
||||
])
|
||||
const res = await t.app.inject({ method: "POST", url: "/admin/documents", payload: two.payload, headers: { ...two.headers, ...auth("admin@iti.local") } })
|
||||
expect(res.statusCode).toBe(400)
|
||||
|
||||
const bad = await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "", visibility: "segreto" })
|
||||
expect(bad.statusCode).toBe(400)
|
||||
expect(bad.json().error.code).toBe("VALIDATION_ERROR")
|
||||
expect(await filesOnDisk()).toEqual([])
|
||||
})
|
||||
|
||||
it("solo admin: customer 403, anonimo 401", async () => {
|
||||
expect((await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, undefined, "cliente@example.com")).statusCode).toBe(403)
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/documents" })).statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("path traversal e accesso statico", () => {
|
||||
it("il nome file del client non entra nel percorso: chiave generata dal server", async () => {
|
||||
const res = await upload({ filename: "../../../../etc/passwd.pdf", contentType: "application/pdf", content: PDF })
|
||||
expect(res.statusCode).toBe(201)
|
||||
const [row] = await t.app.db.select().from(documents).where(eq(documents.id, res.json().id))
|
||||
expect(row!.filePath).toMatch(/^documents\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.pdf$/)
|
||||
const files = await filesOnDisk()
|
||||
expect(files).toHaveLength(1)
|
||||
expect(files[0]!.startsWith(join(TEST_UPLOAD_DIR, "private", "documents"))).toBe(true)
|
||||
})
|
||||
|
||||
it("LocalDiskStorage rifiuta chiavi con .., assolute o con caratteri non ammessi", async () => {
|
||||
const storage = new LocalDiskStorage(TEST_UPLOAD_DIR, "http://x")
|
||||
for (const key of ["../fuori.pdf", "a/../../fuori.pdf", "/etc/passwd", "C:\\Windows\\x", "a//b.pdf", "a/./b.pdf", "A.PDF", "%2e%2e/x"]) {
|
||||
await expect(storage.put("private", key, Readable.from([PDF])), key).rejects.toBeInstanceOf(InvalidStorageKeyError)
|
||||
await expect(storage.get("private", key), key).rejects.toBeInstanceOf(InvalidStorageKeyError)
|
||||
}
|
||||
})
|
||||
|
||||
it("i file privati non sono raggiungibili via /uploads; le immagini pubbliche sì", async () => {
|
||||
const doc = await upload({ filename: "riservato.pdf", contentType: "application/pdf", content: PDF })
|
||||
const [row] = await t.app.db.select().from(documents).where(eq(documents.id, doc.json().id))
|
||||
for (const url of [
|
||||
`/uploads/${row!.filePath}`,
|
||||
`/uploads/../private/${row!.filePath}`,
|
||||
`/uploads/%2e%2e/private/${row!.filePath}`,
|
||||
`/uploads/..%2fprivate/${row!.filePath}`,
|
||||
`/uploads/%2e%2e%2fprivate%2f${row!.filePath.replaceAll("/", "%2f")}`,
|
||||
]) {
|
||||
const res = await t.app.inject({ method: "GET", url })
|
||||
expect(res.statusCode, url).not.toBe(200)
|
||||
expect(res.rawPayload.includes(Buffer.from("%PDF"))).toBe(false)
|
||||
}
|
||||
|
||||
const img = await upload({ filename: "copertina.png", contentType: "image/png", content: PNG }, {}, "admin@iti.local", "/admin/uploads/image")
|
||||
expect(img.statusCode).toBe(201)
|
||||
const { url, path } = img.json()
|
||||
expect(path).toMatch(/^images\/\d{4}\/\d{2}\/[0-9a-f-]{36}\.png$/)
|
||||
expect(url).toBe(`http://localhost:3000/uploads/${path}`)
|
||||
const served = await t.app.inject({ method: "GET", url: `/uploads/${path}` })
|
||||
expect(served.statusCode).toBe(200)
|
||||
expect(served.rawPayload.equals(PNG)).toBe(true)
|
||||
})
|
||||
|
||||
it("upload immagine pubblica: solo immagini", async () => {
|
||||
const res = await upload({ filename: "doc.pdf", contentType: "application/pdf", content: PDF }, {}, "admin@iti.local", "/admin/uploads/image")
|
||||
expect(res.statusCode).toBe(415)
|
||||
})
|
||||
})
|
||||
|
||||
describe("gestione admin", () => {
|
||||
it("elenco con filtri, PATCH metadati, DELETE cancella anche il file", async () => {
|
||||
const a = (await upload({ filename: "a.pdf", contentType: "application/pdf", content: PDF }, { title: "Manuale addolcitore", visibility: "customer", category: "manuali", productRef: "addolcitore-30l" })).json()
|
||||
await upload({ filename: "b.pdf", contentType: "application/pdf", content: PDF }, { title: "Listino 2026", visibility: "installer" })
|
||||
|
||||
const list = await t.app.inject({ method: "GET", url: "/admin/documents?q=manuale", headers: auth("admin@iti.local") })
|
||||
expect(list.json()).toMatchObject({ total: 1, items: [{ title: "Manuale addolcitore", productRef: "addolcitore-30l", category: "manuali" }] })
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/documents?visibility=installer", headers: auth("admin@iti.local") })).json().total).toBe(1)
|
||||
|
||||
const patched = await t.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/admin/documents/${a.id}`,
|
||||
headers: auth("admin@iti.local"),
|
||||
payload: { visibility: "public", category: null },
|
||||
})
|
||||
expect(patched.json()).toMatchObject({ visibility: "public", category: null, title: "Manuale addolcitore" })
|
||||
expect((await t.app.inject({ method: "GET", url: `/documents/${a.id}/download` })).statusCode).toBe(200)
|
||||
|
||||
// Regressione: un PATCH senza visibility non deve riportarla al default "admin"
|
||||
const onlyTitle = await t.app.inject({
|
||||
method: "PATCH",
|
||||
url: `/admin/documents/${a.id}`,
|
||||
headers: auth("admin@iti.local"),
|
||||
payload: { title: "Manuale addolcitore v2" },
|
||||
})
|
||||
expect(onlyTitle.json()).toMatchObject({ title: "Manuale addolcitore v2", visibility: "public" })
|
||||
|
||||
expect(await filesOnDisk()).toHaveLength(2)
|
||||
expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(204)
|
||||
expect(await filesOnDisk()).toHaveLength(1)
|
||||
expect((await t.app.inject({ method: "DELETE", url: `/admin/documents/${a.id}`, headers: auth("admin@iti.local") })).statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe("riconoscimento tipo file", () => {
|
||||
it("sniffKind e checkFileKind", () => {
|
||||
expect(sniffKind(PDF)).toBe("pdf")
|
||||
expect(sniffKind(PNG)).toBe("png")
|
||||
expect(sniffKind(Buffer.from([0xff, 0xd8, 0xff, 0xe0]))).toBe("jpeg")
|
||||
expect(sniffKind(Buffer.from("RIFF\0\0\0\0WEBPVP8 "))).toBe("webp")
|
||||
expect(sniffKind(DWG)).toBe("dwg")
|
||||
expect(sniffKind(DXF)).toBe("dxf")
|
||||
expect(sniffKind(HTML)).toBeNull()
|
||||
expect(checkFileKind("a.pdf", "application/pdf", PDF, ["pdf"])).toEqual({ ok: true, kind: "pdf", ext: ".pdf" })
|
||||
expect(checkFileKind("a.pdf", "application/pdf", PDF, ["png"]).ok).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,266 @@
|
||||
import { mkdtemp, readdir, readFile } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import pino from "pino"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { loadConfig } from "../src/config/env.js"
|
||||
import { EMAIL_TEMPLATES, type EmailTemplate, type EmailTemplates, type OrderEmailData, renderEmail } from "../src/emails/index.js"
|
||||
import {
|
||||
BrevoTransport,
|
||||
ConsoleTransport,
|
||||
EmailDeliveryError,
|
||||
EmailSender,
|
||||
type EmailTransport,
|
||||
renderMessage,
|
||||
ResendTransport,
|
||||
} from "../src/services/email.js"
|
||||
import { LogCapture, testConfig } from "./helpers.js"
|
||||
|
||||
const XSS = `<script>alert("x")</script>&"'`
|
||||
const EVIL_URL = "javascript:alert(1)"
|
||||
|
||||
const order: OrderEmailData = {
|
||||
number: 12,
|
||||
email: "cliente@example.com",
|
||||
customerName: `Mario ${XSS}`,
|
||||
guest: false,
|
||||
items: [{ name: `Addolcitore ${XSS}`, quantity: 2, total: 238000 }],
|
||||
itemTotal: 238000,
|
||||
shippingTotal: 1500,
|
||||
discountTotal: 0,
|
||||
taxTotal: 43188,
|
||||
total: 239500,
|
||||
shippingAddress: ["Mario Rossi", `Via Roma 1 ${XSS}`, "20100 Milano (MI)"],
|
||||
}
|
||||
|
||||
/** Dati di esempio per ogni template, con contenuti ostili nei campi testuali. */
|
||||
const samples: { [K in EmailTemplate]: EmailTemplates[K] } = {
|
||||
"verify-email": { firstName: `Mario ${XSS}`, link: "https://area.example.it/verify-email?token=abc" },
|
||||
"reset-password": { firstName: `Mario ${XSS}`, link: "https://area.example.it/reset-password?token=abc" },
|
||||
"register-existing": { firstName: `Mario ${XSS}`, forgotPasswordUrl: "https://area.example.it/forgot-password" },
|
||||
"order-confirmation": { order, ordersUrl: "https://area.example.it/ordini" },
|
||||
"order-placed-admin": { order },
|
||||
"order-canceled": { order },
|
||||
"order-shipped": { order, tracking: [{ number: `BRT${XSS}`, url: "https://track.example/BRT1" }] },
|
||||
"quote-request-admin": {
|
||||
name: `Ugo ${XSS}`,
|
||||
email: "ugo@example.com",
|
||||
phone: "333 1234567",
|
||||
province: "TO",
|
||||
summary: [{ label: "Soluzione", value: `Fotovoltaico ${XSS}` }],
|
||||
adminUrl: "https://area.example.it/admin/preventivi/1",
|
||||
},
|
||||
"quote-request-received": {
|
||||
name: `Ugo ${XSS}`,
|
||||
solutionTitle: `Addolcitore ${XSS}`,
|
||||
summary: [{ label: "Investimento", value: `1.500 € ${XSS}` }],
|
||||
disclaimer: "Stima indicativa",
|
||||
},
|
||||
"quote-sent": {
|
||||
firstName: `Ugo ${XSS}`,
|
||||
quoteNumber: "2026-0007",
|
||||
title: `Impianto FV 6 kW ${XSS}`,
|
||||
amountCents: 990000,
|
||||
validUntil: "2026-11-30",
|
||||
link: "https://area.example.it/preventivi/7",
|
||||
},
|
||||
"ticket-reply": {
|
||||
firstName: `Ugo ${XSS}`,
|
||||
ticketSubject: `Perdita ${XSS}`,
|
||||
message: `Riga 1\nRiga 2 ${XSS}`,
|
||||
link: "https://area.example.it/ticket/3",
|
||||
},
|
||||
"staff-notification": {
|
||||
title: `Nuovo ticket ${XSS}`,
|
||||
intro: `Ticket da ${XSS}`,
|
||||
rows: [{ label: "Oggetto", value: `Perdita ${XSS}` }],
|
||||
url: "https://area.example.it/admin/ticket/1",
|
||||
},
|
||||
"installer-approved": { firstName: `Paolo ${XSS}`, businessName: `Impianti ${XSS}`, link: "https://area.example.it/installatori" },
|
||||
"installer-rejected": { firstName: `Paolo ${XSS}`, businessName: `Impianti ${XSS}`, reason: `Motivo ${XSS}` },
|
||||
"showroom-booking-confirmed": {
|
||||
name: `Ugo ${XSS}`,
|
||||
requestedAt: "2026-10-10T13:00:00.000Z",
|
||||
people: 3,
|
||||
notes: `Parcheggio ${XSS}`,
|
||||
},
|
||||
}
|
||||
|
||||
describe("template", () => {
|
||||
it("c'è un esempio per ogni template registrato", () => {
|
||||
expect(Object.keys(samples).sort()).toEqual([...EMAIL_TEMPLATES].sort())
|
||||
})
|
||||
|
||||
for (const template of EMAIL_TEMPLATES) {
|
||||
it(`${template}: oggetto, HTML con layout, testo; dati escapati`, () => {
|
||||
const r = renderEmail(template, samples[template] as never)
|
||||
expect(r.subject.length).toBeGreaterThan(5)
|
||||
expect(r.subject).not.toMatch(/[\r\n]/)
|
||||
expect(r.html).toMatch(/^<!doctype html>/)
|
||||
expect(r.html).toContain("ITI TECHNOLOGY")
|
||||
expect(r.text).toContain("ITI TECHNOLOGY")
|
||||
// Nessun contenuto ostile non escapato nell'HTML
|
||||
expect(r.html).not.toContain("<script>")
|
||||
expect(r.html).not.toContain(`"x")`)
|
||||
expect(r.html).toContain("<script>alert("x")</script>&"'")
|
||||
})
|
||||
}
|
||||
|
||||
it("contenuti specifici: link, importi, date", () => {
|
||||
const verify = renderEmail("verify-email", samples["verify-email"])
|
||||
expect(verify.html).toContain('href="https://area.example.it/verify-email?token=abc"')
|
||||
expect(verify.text).toContain("https://area.example.it/verify-email?token=abc")
|
||||
|
||||
const conf = renderEmail("order-confirmation", samples["order-confirmation"])
|
||||
expect(conf.subject).toBe("Conferma ordine n. 12 - ITI TECHNOLOGY")
|
||||
expect(conf.text).toMatch(/2 x Addolcitore .*: 2\.380,00\s€/)
|
||||
expect(conf.html).toMatch(/2\.395,00\s€/)
|
||||
expect(conf.html).toContain("https://area.example.it/ordini")
|
||||
expect(renderEmail("order-confirmation", { ...samples["order-confirmation"], order: { ...order, guest: true } }).html).not.toContain(
|
||||
"/ordini",
|
||||
)
|
||||
|
||||
expect(renderEmail("order-shipped", samples["order-shipped"]).html).toContain('href="https://track.example/BRT1"')
|
||||
expect(renderEmail("quote-sent", samples["quote-sent"]).text).toMatch(/9\.900,00\s€/)
|
||||
expect(renderEmail("quote-sent", samples["quote-sent"]).text).toContain("30 novembre 2026")
|
||||
// 13:00 UTC = 15:00 a Roma (ora legale)
|
||||
expect(renderEmail("showroom-booking-confirmed", samples["showroom-booking-confirmed"]).text).toContain("15:00")
|
||||
})
|
||||
|
||||
it("URL non http(s) neutralizzati; a capo rimossi dall'oggetto", () => {
|
||||
const r = renderEmail("verify-email", { firstName: "A", link: EVIL_URL })
|
||||
expect(r.html).not.toContain("javascript:")
|
||||
expect(r.html).toContain('href="#"')
|
||||
const t = renderEmail("ticket-reply", { ...samples["ticket-reply"], ticketSubject: "a\r\nBcc: x@evil.com" })
|
||||
expect(t.subject).not.toMatch(/[\r\n]/)
|
||||
})
|
||||
})
|
||||
|
||||
describe("trasporti", () => {
|
||||
const message = renderMessage({ to: "a@example.com", template: "verify-email", data: samples["verify-email"] })
|
||||
const sender = { email: "noreply@iti.it", name: "ITI TECHNOLOGY", replyTo: "info@iti.it" }
|
||||
|
||||
it("Brevo: endpoint, api-key, corpo", async () => {
|
||||
const fetchFn = vi.fn(async () => new Response("{}", { status: 201 }))
|
||||
await new BrevoTransport("brevo-key", sender, fetchFn).deliver(message)
|
||||
const [url, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe("https://api.brevo.com/v3/smtp/email")
|
||||
expect((init.headers as Record<string, string>)["api-key"]).toBe("brevo-key")
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
sender: { email: "noreply@iti.it", name: "ITI TECHNOLOGY" },
|
||||
to: [{ email: "a@example.com" }],
|
||||
subject: message.subject,
|
||||
htmlContent: message.html,
|
||||
textContent: message.text,
|
||||
tags: ["verify-email"],
|
||||
replyTo: { email: "info@iti.it" },
|
||||
})
|
||||
})
|
||||
|
||||
it("Resend: endpoint, Bearer, corpo", async () => {
|
||||
const fetchFn = vi.fn(async () => new Response("{}", { status: 200 }))
|
||||
await new ResendTransport("re_key", sender, fetchFn).deliver(message)
|
||||
const [url, init] = fetchFn.mock.calls[0] as unknown as [string, RequestInit]
|
||||
expect(url).toBe("https://api.resend.com/emails")
|
||||
expect((init.headers as Record<string, string>).authorization).toBe("Bearer re_key")
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
from: "ITI TECHNOLOGY <noreply@iti.it>",
|
||||
to: ["a@example.com"],
|
||||
html: message.html,
|
||||
text: message.text,
|
||||
tags: [{ name: "template", value: "verify-email" }],
|
||||
reply_to: "info@iti.it",
|
||||
})
|
||||
})
|
||||
|
||||
it("errori provider: 429/5xx/rete ritentabili, 4xx no", async () => {
|
||||
const fail = (status: number) => new ResendTransport("k", sender, async () => new Response("no", { status })).deliver(message)
|
||||
await expect(fail(500)).rejects.toMatchObject({ retryable: true })
|
||||
await expect(fail(429)).rejects.toMatchObject({ retryable: true })
|
||||
await expect(fail(422)).rejects.toMatchObject({ retryable: false })
|
||||
const network = new BrevoTransport("k", sender, async () => {
|
||||
throw new TypeError("fetch failed")
|
||||
})
|
||||
await expect(network.deliver(message)).rejects.toBeInstanceOf(EmailDeliveryError)
|
||||
})
|
||||
|
||||
it("console: log senza corpo, outbox con .txt e .html", async () => {
|
||||
const logs = new LogCapture()
|
||||
const dir = await mkdtemp(join(tmpdir(), "iti-outbox-"))
|
||||
await new ConsoleTransport(pino({}, logs), dir).deliver(message)
|
||||
expect(logs.text).toContain("verify-email")
|
||||
expect(logs.text).not.toContain("token=abc")
|
||||
const files = await readdir(dir)
|
||||
expect(files.some((f) => f.endsWith("-verify-email.txt"))).toBe(true)
|
||||
const htmlFile = files.find((f) => f.endsWith(".html"))!
|
||||
expect(await readFile(join(dir, htmlFile), "utf8")).toContain("<!doctype html>")
|
||||
})
|
||||
})
|
||||
|
||||
describe("EmailSender", () => {
|
||||
const request = { to: "a@example.com", template: "verify-email" as const, data: samples["verify-email"] }
|
||||
|
||||
it("send non fallisce se il provider fallisce: ritenta e logga l'errore", async () => {
|
||||
const logs = new LogCapture()
|
||||
const deliver = vi.fn(async () => {
|
||||
throw new EmailDeliveryError("Brevo: HTTP 503", true)
|
||||
})
|
||||
const transport: EmailTransport = { name: "fake", deliver }
|
||||
const email = new EmailSender(transport, pino({}, logs), { attempts: 3, retryBaseMs: 1 })
|
||||
await expect(email.send(request)).resolves.toBeUndefined()
|
||||
await email.flush()
|
||||
expect(deliver).toHaveBeenCalledTimes(3)
|
||||
expect(logs.text).toContain("invio email non riuscito")
|
||||
})
|
||||
|
||||
it("errore non ritentabile: un solo tentativo; successo al secondo tentativo dopo un 5xx", async () => {
|
||||
const logs = new LogCapture()
|
||||
const once = vi.fn(async () => {
|
||||
throw new EmailDeliveryError("HTTP 401", false)
|
||||
})
|
||||
const a = new EmailSender({ name: "fake", deliver: once }, pino({}, logs), { retryBaseMs: 1 })
|
||||
await a.send(request)
|
||||
await a.flush()
|
||||
expect(once).toHaveBeenCalledTimes(1)
|
||||
|
||||
let n = 0
|
||||
const flaky = vi.fn(async () => {
|
||||
if (n++ === 0) throw new EmailDeliveryError("HTTP 502", true)
|
||||
})
|
||||
const b = new EmailSender({ name: "fake", deliver: flaky }, pino({}, logs), { retryBaseMs: 1 })
|
||||
await b.send(request)
|
||||
await b.flush()
|
||||
expect(flaky).toHaveBeenCalledTimes(2)
|
||||
expect(logs.text).toContain("email inviata")
|
||||
})
|
||||
|
||||
it("un errore di template è un bug: send lancia subito", async () => {
|
||||
const email = new EmailSender({ name: "fake", deliver: async () => {} }, pino({ level: "silent" }))
|
||||
await expect(email.send({ to: "a@example.com", template: "order-confirmation", data: {} as never })).rejects.toThrow(TypeError)
|
||||
})
|
||||
})
|
||||
|
||||
describe("configurazione", () => {
|
||||
const base = () => {
|
||||
const c = testConfig()
|
||||
return {
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: c.DATABASE_URL,
|
||||
CORS_ORIGINS: "http://localhost:4200",
|
||||
COOKIE_SECRET: "c".repeat(32),
|
||||
JWT_SECRET: "j".repeat(32),
|
||||
MEDUSA_PUBLISHABLE_KEY: "pk",
|
||||
INTERNAL_API_SECRET: "i".repeat(32),
|
||||
WEBHOOK_SECRET: "w".repeat(32),
|
||||
PRIVATE_APP_URL: "http://localhost:4300",
|
||||
}
|
||||
}
|
||||
|
||||
it("cambiare provider richiede solo variabili d'ambiente (con validazione)", () => {
|
||||
expect(loadConfig(base()).EMAIL_PROVIDER).toBe("console")
|
||||
expect(() => loadConfig({ ...base(), EMAIL_PROVIDER: "brevo" })).toThrow(/EMAIL_FROM.*\n.*BREVO_API_KEY|BREVO_API_KEY/s)
|
||||
expect(() => loadConfig({ ...base(), EMAIL_PROVIDER: "resend", EMAIL_FROM: "noreply@iti.it" })).toThrow(/RESEND_API_KEY/)
|
||||
const ok = loadConfig({ ...base(), EMAIL_PROVIDER: "resend", EMAIL_FROM: "NoReply@ITI.it", RESEND_API_KEY: "re_x" })
|
||||
expect(ok).toMatchObject({ EMAIL_PROVIDER: "resend", EMAIL_FROM: "noreply@iti.it", EMAIL_FROM_NAME: "ITI TECHNOLOGY" })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator"
|
||||
import { createDb } from "../src/db/client.js"
|
||||
import { testDatabaseUrl } from "./helpers.js"
|
||||
|
||||
/** Applica le migrazioni al DB di test prima di tutti i test. */
|
||||
export default async function setup() {
|
||||
const { db, close } = createDb(testDatabaseUrl(), { max: 1 })
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: fileURLToPath(new URL("../src/db/migrations", import.meta.url)) })
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Writable } from "node:stream"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { buildApp, type App } from "../src/app.js"
|
||||
import { dbEnvSchema, loadConfig, parseEnv, type Config } from "../src/config/env.js"
|
||||
import { createDb } from "../src/db/client.js"
|
||||
import { users } from "../src/db/schema/index.js"
|
||||
import { MemoryEmailService } from "../src/services/email.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { MedusaMock } from "./medusa-mock.js"
|
||||
|
||||
/**
|
||||
* URL del DB di test: TEST_DATABASE_URL oppure DATABASE_URL con suffisso "_test" sul nome del DB.
|
||||
* Rifiuta qualunque database il cui nome non finisca con "_test" (i test lo svuotano).
|
||||
*/
|
||||
export function testDatabaseUrl(): string {
|
||||
const explicit = process.env.TEST_DATABASE_URL
|
||||
const url = new URL(explicit ?? parseEnv(dbEnvSchema).DATABASE_URL)
|
||||
if (!explicit) url.pathname = `${url.pathname}_test`
|
||||
if (!url.pathname.endsWith("_test")) {
|
||||
throw new Error(`Il DB dei test deve terminare con "_test" (trovato ${url.pathname.slice(1)})`)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/** Radice dello storage nei test (per processo). */
|
||||
export const TEST_UPLOAD_DIR = join(tmpdir(), `iti-api-test-uploads-${process.pid}`)
|
||||
|
||||
/** Config completa per i test: valori fissi, indipendenti dal .env (tranne il DB). */
|
||||
export function testConfig(overrides: Record<string, string> = {}): Config {
|
||||
return loadConfig({
|
||||
NODE_ENV: "test",
|
||||
DATABASE_URL: testDatabaseUrl(),
|
||||
CORS_ORIGINS: "http://localhost:4200",
|
||||
COOKIE_SECRET: "c".repeat(32),
|
||||
JWT_SECRET: "j".repeat(32),
|
||||
MEDUSA_PUBLISHABLE_KEY: "pk_test",
|
||||
INTERNAL_API_SECRET: "i".repeat(32),
|
||||
WEBHOOK_SECRET: "w".repeat(32),
|
||||
PRIVATE_APP_URL: "http://localhost:4300",
|
||||
AUTH_LOGIN_RATE_MAX: "1000",
|
||||
AUTH_EMAIL_RATE_MAX: "1000",
|
||||
RATE_LIMIT_MAX: "10000",
|
||||
// Storage dei test in una cartella temporanea (mai la uploads/ di sviluppo)
|
||||
UPLOAD_DIR: TEST_UPLOAD_DIR,
|
||||
PUBLIC_UPLOADS_URL: "http://localhost:3000/uploads",
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
/** Stream che accumula le righe di log (per verificare che non contengano segreti). */
|
||||
export class LogCapture extends Writable {
|
||||
readonly lines: string[] = []
|
||||
override _write(chunk: Buffer, _enc: BufferEncoding, cb: () => void) {
|
||||
this.lines.push(chunk.toString())
|
||||
cb()
|
||||
}
|
||||
get text() {
|
||||
return this.lines.join("")
|
||||
}
|
||||
}
|
||||
|
||||
export type TestApp = { app: App; email: MemoryEmailService; logs: LogCapture; medusa: MedusaMock }
|
||||
|
||||
/**
|
||||
* App di test con email in memoria, log catturati e Medusa mockato (nessuna chiamata di rete);
|
||||
* `setup` aggiunge route di prova prima di ready().
|
||||
*/
|
||||
export async function buildTestApp(
|
||||
overrides: Record<string, string> = {},
|
||||
setup?: (app: App) => void,
|
||||
): Promise<TestApp> {
|
||||
const email = new MemoryEmailService()
|
||||
const logs = new LogCapture()
|
||||
const config = testConfig(overrides)
|
||||
const medusa = new MedusaMock(config.INTERNAL_API_SECRET)
|
||||
const app = await buildApp(config, { email, medusaFetch: medusa.fetch, logger: { level: "trace", stream: logs } })
|
||||
setup?.(app)
|
||||
await app.ready()
|
||||
return { app, email, logs, medusa }
|
||||
}
|
||||
|
||||
/** Svuota tutte le tabelle applicative del DB di test (non la tabella delle migrazioni). */
|
||||
export async function truncateAll(): Promise<void> {
|
||||
const { db, close } = createDb(testDatabaseUrl(), { max: 1 })
|
||||
try {
|
||||
await db.execute(sql`
|
||||
DO $$ DECLARE t text; BEGIN
|
||||
SELECT string_agg(format('%I', tablename), ', ') INTO t
|
||||
FROM pg_tables WHERE schemaname = 'public' AND tablename <> 'spatial_ref_sys';
|
||||
IF t IS NOT NULL THEN EXECUTE 'TRUNCATE ' || t || ' RESTART IDENTITY CASCADE'; END IF;
|
||||
END $$`)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Estrae il token da un link presente nel testo di un'email (?token=...). */
|
||||
export function tokenFromEmail(text: string | undefined): string {
|
||||
const match = text?.match(/[?&]token=([^\s&]+)/)
|
||||
if (!match?.[1]) throw new Error("token non trovato nell'email")
|
||||
return decodeURIComponent(match[1])
|
||||
}
|
||||
|
||||
/** Valore del cookie indicato dalla risposta (header set-cookie). */
|
||||
export function cookieFrom(setCookie: string | string[] | undefined, name: string): string | undefined {
|
||||
const all = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : []
|
||||
const raw = all.find((c) => c.startsWith(`${name}=`))
|
||||
return raw?.split(";")[0]?.slice(name.length + 1)
|
||||
}
|
||||
|
||||
export const TEST_PASSWORD = "Password-di-test-1"
|
||||
|
||||
/** Crea un utente (email verificata salvo indicazione) e fa login: restituisce utente e header Authorization. */
|
||||
export async function createUserAndLogin(
|
||||
app: App,
|
||||
email: string,
|
||||
role: "customer" | "company" | "installer" | "admin" = "customer",
|
||||
extra: Partial<typeof users.$inferInsert> = {},
|
||||
) {
|
||||
const [user] = await app.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email,
|
||||
passwordHash: await hashPassword(TEST_PASSWORD),
|
||||
role,
|
||||
firstName: "Nome",
|
||||
lastName: "Cognome",
|
||||
emailVerifiedAt: new Date(),
|
||||
...extra,
|
||||
})
|
||||
.returning()
|
||||
const res = await app.inject({ method: "POST", url: "/auth/login", payload: { email, password: TEST_PASSWORD } })
|
||||
if (res.statusCode !== 200) throw new Error(`login fallito per ${email}: ${res.body}`)
|
||||
return { user: user!, headers: { authorization: `Bearer ${res.json().accessToken as string}` } }
|
||||
}
|
||||
|
||||
/** Body multipart/form-data per app.inject: campi testuali e file ({ filename, contentType, content }). */
|
||||
export function multipartBody(
|
||||
fields: Record<string, string>,
|
||||
files: { field?: string; filename: string; contentType: string; content: Buffer | string }[],
|
||||
) {
|
||||
const boundary = `----iti${Math.random().toString(16).slice(2)}`
|
||||
const CRLF = "\r\n"
|
||||
const chunks: Buffer[] = []
|
||||
for (const [name, value] of Object.entries(fields)) {
|
||||
chunks.push(Buffer.from(`--${boundary}${CRLF}Content-Disposition: form-data; name="${name}"${CRLF}${CRLF}${value}${CRLF}`))
|
||||
}
|
||||
for (const f of files) {
|
||||
chunks.push(
|
||||
Buffer.from(
|
||||
`--${boundary}${CRLF}Content-Disposition: form-data; name="${f.field ?? "file"}"; filename="${f.filename}"${CRLF}` +
|
||||
`Content-Type: ${f.contentType}${CRLF}${CRLF}`,
|
||||
),
|
||||
Buffer.isBuffer(f.content) ? f.content : Buffer.from(f.content),
|
||||
Buffer.from(CRLF),
|
||||
)
|
||||
}
|
||||
chunks.push(Buffer.from(`--${boundary}--${CRLF}`))
|
||||
return { payload: Buffer.concat(chunks), headers: { "content-type": `multipart/form-data; boundary=${boundary}` } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { rm } from "node:fs/promises"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { PROVINCES } from "../src/configurator/rules.js"
|
||||
import { installerProfiles, quoteRequests, users } from "../src/db/schema/index.js"
|
||||
import { PROVINCE_CENTROIDS } from "../src/services/geo.js"
|
||||
import { buildTestApp, createUserAndLogin, multipartBody, TEST_UPLOAD_DIR, type TestApp, truncateAll } from "./helpers.js"
|
||||
import { MOCK_PRODUCT, MOCK_VARIANT } from "./medusa-mock.js"
|
||||
|
||||
const MONZA = { lat: 45.585, lng: 9.274 }
|
||||
let t: TestApp
|
||||
let admin: Record<string, string>
|
||||
|
||||
type Seed = { email: string; city: { lat: number; lng: number } | null; radius: number; status?: "pending" | "approved" | "rejected"; disabled?: boolean }
|
||||
|
||||
async function seedInstaller(s: Seed) {
|
||||
const [u] = await t.app.db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: s.email,
|
||||
passwordHash: "x",
|
||||
role: "installer",
|
||||
firstName: "I",
|
||||
lastName: s.email,
|
||||
disabledAt: s.disabled ? new Date() : null,
|
||||
})
|
||||
.returning()
|
||||
await t.app.db.insert(installerProfiles).values({
|
||||
userId: u!.id,
|
||||
businessName: `Impianti ${s.email.split("@")[0]}`,
|
||||
location: s.city,
|
||||
serviceRadiusKm: s.radius,
|
||||
status: s.status ?? "approved",
|
||||
})
|
||||
return u!
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await t.app.close()
|
||||
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await t.app.flushSyncs()
|
||||
await truncateAll()
|
||||
t.medusa.reset()
|
||||
t.email.clear()
|
||||
t.app.shop.clearCache()
|
||||
admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
|
||||
})
|
||||
|
||||
describe("ricerca per zona (PostGIS)", () => {
|
||||
beforeEach(async () => {
|
||||
await seedInstaller({ email: "torino@example.com", city: { lat: 45.07, lng: 7.687 }, radius: 20 }) // ~135 km
|
||||
await seedInstaller({ email: "bergamo@example.com", city: { lat: 45.698, lng: 9.677 }, radius: 40 }) // ~34 km
|
||||
await seedInstaller({ email: "lecco@example.com", city: { lat: 45.856, lng: 9.398 }, radius: 10 }) // ~31 km, raggio piccolo
|
||||
await seedInstaller({ email: "milano@example.com", city: { lat: 45.464, lng: 9.19 }, radius: 50 }) // ~15 km
|
||||
await seedInstaller({ email: "attesa@example.com", city: MONZA, radius: 50, status: "pending" })
|
||||
await seedInstaller({ email: "disabilitato@example.com", city: MONZA, radius: 50, disabled: true })
|
||||
await seedInstaller({ email: "senzasede@example.com", city: null, radius: 300 })
|
||||
})
|
||||
|
||||
it("solo gli installatori approvati entro il proprio raggio, in ordine di distanza", async () => {
|
||||
const res = await t.app.inject({ method: "GET", url: `/admin/installers/nearby?lat=${MONZA.lat}&lng=${MONZA.lng}`, headers: admin })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const list = res.json() as { email: string; distanceKm: number }[]
|
||||
expect(list.map((i) => i.email)).toEqual(["milano@example.com", "bergamo@example.com"])
|
||||
expect(list[0]!.distanceKm).toBeGreaterThan(13)
|
||||
expect(list[0]!.distanceKm).toBeLessThan(17)
|
||||
expect(list[1]!.distanceKm).toBeGreaterThan(30)
|
||||
expect(list[1]!.distanceKm).toBeLessThan(37)
|
||||
})
|
||||
|
||||
it("da una richiesta di preventivo: provincia → capoluogo → installatori", async () => {
|
||||
const [r] = await t.app.db
|
||||
.insert(quoteRequests)
|
||||
.values({ email: "c@example.com", name: "C", province: "MB", configuratorInput: {} })
|
||||
.returning()
|
||||
const res = await t.app.inject({ method: "GET", url: `/admin/quote-requests/${r!.id}/installers`, headers: admin })
|
||||
expect(res.json()).toMatchObject({ province: "MB", point: PROVINCE_CENTROIDS.MB })
|
||||
expect(res.json().installers.map((i: { email: string }) => i.email)).toEqual(["milano@example.com", "bergamo@example.com"])
|
||||
|
||||
const [noProv] = await t.app.db.insert(quoteRequests).values({ email: "d@example.com", name: "D", configuratorInput: {} }).returning()
|
||||
expect((await t.app.inject({ method: "GET", url: `/admin/quote-requests/${noProv!.id}/installers`, headers: admin })).json().error.code).toBe(
|
||||
"PROVINCE_MISSING",
|
||||
)
|
||||
})
|
||||
|
||||
it("coordinate non valide o fuori dall'Italia → 400; solo admin", async () => {
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=48.85&lng=2.35", headers: admin })).statusCode).toBe(400)
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=abc&lng=9", headers: admin })).statusCode).toBe(400)
|
||||
const customer = (await createUserAndLogin(t.app, "c@example.com")).headers
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/installers/nearby?lat=45&lng=9", headers: customer })).statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it("ogni provincia ha le coordinate del capoluogo, in Italia", () => {
|
||||
expect(PROVINCES.filter((p) => !PROVINCE_CENTROIDS[p.code])).toEqual([])
|
||||
expect(Object.keys(PROVINCE_CENTROIDS)).toHaveLength(PROVINCES.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe("profilo installatore", () => {
|
||||
it("GET/PUT del proprio profilo; coordinate validate; stato e note non modificabili", async () => {
|
||||
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
|
||||
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl", notes: "nota interna" })
|
||||
|
||||
const got = await t.app.inject({ method: "GET", url: "/installer/profile", headers: inst.headers })
|
||||
expect(got.json()).toMatchObject({ businessName: "Impianti Srl", status: "pending", location: null, serviceRadiusKm: 50 })
|
||||
expect(got.body).not.toContain("nota interna")
|
||||
|
||||
const body = {
|
||||
businessName: "Impianti Rossi Srl",
|
||||
vatNumber: "IT01234567890",
|
||||
phone: "0391234567",
|
||||
address: "Via Italia 1, Monza",
|
||||
location: MONZA,
|
||||
serviceRadiusKm: 35,
|
||||
certifications: ["FER", "F-GAS", "FER"],
|
||||
status: "approved",
|
||||
}
|
||||
const put = await t.app.inject({ method: "PUT", url: "/installer/profile", headers: inst.headers, payload: body })
|
||||
expect(put.statusCode).toBe(200)
|
||||
expect(put.json()).toMatchObject({ businessName: "Impianti Rossi Srl", location: MONZA, serviceRadiusKm: 35, certifications: ["FER", "F-GAS"], status: "pending" })
|
||||
|
||||
for (const bad of [{ location: { lat: 48.85, lng: 2.35 } }, { location: { lat: 95, lng: 9 } }, { serviceRadiusKm: 0 }]) {
|
||||
expect((await t.app.inject({ method: "PUT", url: "/installer/profile", headers: inst.headers, payload: { ...body, ...bad } })).statusCode).toBe(400)
|
||||
}
|
||||
const customer = (await createUserAndLogin(t.app, "c@example.com")).headers
|
||||
expect((await t.app.inject({ method: "GET", url: "/installer/profile", headers: customer })).statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe("approvazione", () => {
|
||||
const PDF = Buffer.from("%PDF-1.7\n%%EOF\n")
|
||||
|
||||
async function installerDoc() {
|
||||
const body = multipartBody({ title: "Listino tecnico", visibility: "installer" }, [{ filename: "l.pdf", contentType: "application/pdf", content: PDF }])
|
||||
const res = await t.app.inject({ method: "POST", url: "/admin/documents", payload: body.payload, headers: { ...body.headers, ...admin } })
|
||||
return res.json().id as string
|
||||
}
|
||||
|
||||
const price = async (headers: Record<string, string>) =>
|
||||
(await t.app.inject({ method: "GET", url: `/shop/products/${MOCK_PRODUCT.handle}`, headers })).json().variants[0].price
|
||||
|
||||
it("pending: niente documenti installer né prezzi riservati; dopo l'approvazione sì (stesso token); dopo il rifiuto no", async () => {
|
||||
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
|
||||
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
|
||||
const docId = await installerDoc()
|
||||
await t.app.flushSyncs()
|
||||
|
||||
expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(403)
|
||||
expect(await price(inst.headers)).toBe(MOCK_VARIANT.price * 100)
|
||||
|
||||
const approved = await t.app.inject({ method: "POST", url: `/admin/installers/${inst.user.id}/approve`, headers: admin, payload: {} })
|
||||
expect(approved.statusCode).toBe(200)
|
||||
expect(approved.json()).toMatchObject({ status: "approved", medusaSynced: true })
|
||||
expect(approved.json().approvedAt).not.toBeNull()
|
||||
const customerId = t.medusa.accountByEmail("inst@example.com")!.id
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
|
||||
expect(t.email.last("installer-approved")?.to).toBe("inst@example.com")
|
||||
|
||||
t.app.shop.clearCache()
|
||||
expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(200)
|
||||
expect(await price(inst.headers)).toBe(MOCK_VARIANT.installerPrice * 100)
|
||||
|
||||
const rejected = await t.app.inject({
|
||||
method: "POST",
|
||||
url: `/admin/installers/${inst.user.id}/reject`,
|
||||
headers: admin,
|
||||
payload: { reason: "Certificazioni scadute" },
|
||||
})
|
||||
expect(rejected.json()).toMatchObject({ status: "rejected", approvedAt: null, medusaSynced: true })
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(false)
|
||||
expect(t.email.last("installer-rejected")?.text).toContain("Certificazioni scadute")
|
||||
t.app.shop.clearCache()
|
||||
expect((await t.app.inject({ method: "GET", url: `/documents/${docId}/download`, headers: inst.headers })).statusCode).toBe(403)
|
||||
expect(await price(inst.headers)).toBe(MOCK_VARIANT.price * 100)
|
||||
})
|
||||
|
||||
it("Medusa giù all'approvazione: approvato comunque (medusaSynced false), gruppo allineato al login successivo", async () => {
|
||||
const inst = await createUserAndLogin(t.app, "inst@example.com", "installer")
|
||||
await t.app.db.insert(installerProfiles).values({ userId: inst.user.id, businessName: "Impianti Srl" })
|
||||
await t.app.flushSyncs()
|
||||
|
||||
t.medusa.down = true
|
||||
const res = await t.app.inject({ method: "POST", url: `/admin/installers/${inst.user.id}/approve`, headers: admin, payload: {} })
|
||||
expect(res.json()).toMatchObject({ status: "approved", medusaSynced: false })
|
||||
|
||||
t.medusa.down = false
|
||||
await t.app.inject({ method: "POST", url: "/auth/login", payload: { email: "inst@example.com", password: "Password-di-test-1" } })
|
||||
await t.app.flushSyncs()
|
||||
const customerId = t.medusa.accountByEmail("inst@example.com")!.id
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
|
||||
})
|
||||
|
||||
it("approvare un customer che ha un profilo lo rende installer; elenco per stato e note interne", async () => {
|
||||
const c = await createUserAndLogin(t.app, "ex-cliente@example.com", "customer")
|
||||
await t.app.db.insert(installerProfiles).values({ userId: c.user.id, businessName: "Nuova Ditta" })
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/installers?status=pending", headers: admin })).json().total).toBe(1)
|
||||
|
||||
await t.app.inject({ method: "POST", url: `/admin/installers/${c.user.id}/approve`, headers: admin, payload: { notes: "Verificata visura" } })
|
||||
const [u] = await t.app.db.select().from(users).where(eq(users.id, c.user.id))
|
||||
expect(u!.role).toBe("installer")
|
||||
const detail = (await t.app.inject({ method: "GET", url: `/admin/installers/${c.user.id}`, headers: admin })).json()
|
||||
expect(detail).toMatchObject({ status: "approved", notes: "Verificata visura", email: "ex-cliente@example.com" })
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/installers?status=pending", headers: admin })).json().total).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,398 @@
|
||||
import type { FetchFn } from "../src/services/medusa-client.js"
|
||||
|
||||
type Customer = { id: string; email: string; first_name: string; last_name: string; company_name: string | null; has_account: boolean }
|
||||
type Order = { id: string; email: string; customer_id: string }
|
||||
type Cart = {
|
||||
id: string
|
||||
email: string | null
|
||||
customer_id: string | null
|
||||
items: { id: string; variant_id: string; quantity: number }[]
|
||||
shipping_address: Record<string, string> | null
|
||||
billing_address: Record<string, string> | null
|
||||
shipping_option_id: string | null
|
||||
completed_at: string | null
|
||||
payment_session: boolean
|
||||
metadata: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
/** Catalogo finto: un prodotto con una variante. Importi in euro IVA inclusa, come Medusa. */
|
||||
export const MOCK_PRODUCT = { handle: "pompa-di-calore", title: "Pompa di calore" }
|
||||
export const MOCK_VARIANT = { id: "variant_1", price: 5490, installerPrice: 4650, stock: 5 }
|
||||
export const MOCK_SHIPPING = { id: "so_1", name: "Spedizione standard", amount: 15 }
|
||||
const REGION = "reg_it"
|
||||
|
||||
/**
|
||||
* Finto Medusa per i test, con stato in memoria: route /internal/* usate da apps/api e la parte di
|
||||
* Store API usata dal proxy shop (regione, catalogo, carrello, spedizione, pagamento, completamento).
|
||||
* `down = true` simula Medusa spento (errore di rete, come fetch su una porta chiusa).
|
||||
*/
|
||||
export class MedusaMock {
|
||||
down = false
|
||||
/** Se impostato, ogni richiesta risponde con questo status (es. 500). */
|
||||
failStatus: number | undefined
|
||||
/** Se false, il completamento risponde type "cart" (pagamento non autorizzato). */
|
||||
paymentAuthorized = true
|
||||
readonly customers = new Map<string, Customer>()
|
||||
readonly orders: Order[] = []
|
||||
readonly installerGroup = new Set<string>()
|
||||
readonly carts = new Map<string, Cart>()
|
||||
/** Ordini restituiti da GET /internal/customers/:id/orders, per customer. */
|
||||
readonly customerOrderList = new Map<string, Record<string, unknown>[]>()
|
||||
/** Dettagli ordine restituiti da GET /internal/orders/:id (per i webhook). */
|
||||
readonly orderDetails = new Map<string, Record<string, unknown>>()
|
||||
readonly calls: { method: string; path: string; headers: Record<string, string>; body: unknown }[] = []
|
||||
private seq = 0
|
||||
|
||||
constructor(readonly internalSecret: string) {}
|
||||
|
||||
reset() {
|
||||
this.down = false
|
||||
this.failStatus = undefined
|
||||
this.paymentAuthorized = true
|
||||
this.customers.clear()
|
||||
this.orders.length = 0
|
||||
this.installerGroup.clear()
|
||||
this.carts.clear()
|
||||
this.orderDetails.clear()
|
||||
this.customerOrderList.clear()
|
||||
this.calls.length = 0
|
||||
}
|
||||
|
||||
/** Ordine guest (fatto senza account) con l'email indicata. */
|
||||
addGuestOrder(email: string): string {
|
||||
const guest: Customer = { id: `cus_guest_${++this.seq}`, email, first_name: "G", last_name: "G", company_name: null, has_account: false }
|
||||
this.customers.set(guest.id, guest)
|
||||
const order = { id: `order_${++this.seq}`, email, customer_id: guest.id }
|
||||
this.orders.push(order)
|
||||
return order.id
|
||||
}
|
||||
|
||||
ordersOf(customerId: string) {
|
||||
return this.orders.filter((o) => o.customer_id === customerId).map((o) => o.id)
|
||||
}
|
||||
|
||||
accountByEmail(email: string) {
|
||||
return [...this.customers.values()].find((c) => c.has_account && c.email === email)
|
||||
}
|
||||
|
||||
calledPaths() {
|
||||
return this.calls.map((c) => `${c.method} ${c.path}`)
|
||||
}
|
||||
|
||||
readonly fetch: FetchFn = async (input, init) => {
|
||||
const url = new URL(input instanceof Request ? input.url : String(input))
|
||||
const method = init?.method ?? "GET"
|
||||
const headers = Object.fromEntries(Object.entries((init?.headers ?? {}) as Record<string, string>))
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined
|
||||
this.calls.push({ method, path: url.pathname, headers, body })
|
||||
|
||||
if (this.down) throw new TypeError("fetch failed")
|
||||
if (this.failStatus) return json(this.failStatus, { type: "unexpected_state", message: "errore simulato" })
|
||||
|
||||
if (url.pathname.startsWith("/internal")) {
|
||||
if (headers["x-internal-secret"] !== this.internalSecret) return json(401, { type: "unauthorized" })
|
||||
return this.internal(method, url.pathname, body, url.searchParams)
|
||||
}
|
||||
if (url.pathname.startsWith("/store")) {
|
||||
if (!headers["x-publishable-api-key"]) return json(400, { type: "not_allowed", message: "publishable key mancante" })
|
||||
return this.store(method, url, body)
|
||||
}
|
||||
return json(404, { type: "not_found", message: "route non simulata" })
|
||||
}
|
||||
|
||||
// --- /internal/* ---
|
||||
|
||||
private internal(method: string, path: string, body: any, query: URLSearchParams = new URLSearchParams()): Response {
|
||||
const customerOrders = path.match(/^\/internal\/customers\/([^/]+)\/orders$/)
|
||||
if (method === "GET" && customerOrders?.[1]) {
|
||||
let all = this.customerOrderList.get(decodeURIComponent(customerOrders[1])) ?? []
|
||||
const displayId = query.get("display_id")
|
||||
if (displayId) all = all.filter((o) => String(o["display_id"]) === displayId)
|
||||
return json(200, { orders: all, count: all.length, offset: 0, limit: 20 })
|
||||
}
|
||||
|
||||
const orderDetail = path.match(/^\/internal\/orders\/([^/]+)$/)
|
||||
if (method === "GET" && orderDetail?.[1]) {
|
||||
const order = this.orderDetails.get(decodeURIComponent(orderDetail[1]))
|
||||
return order ? json(200, { order }) : json(404, { type: "not_found", message: "Order not found" })
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/internal/customers") {
|
||||
const existing = this.accountByEmail(body.email)
|
||||
if (existing) return json(200, { customer: existing, created: false })
|
||||
const customer: Customer = { id: `cus_${++this.seq}`, has_account: true, company_name: null, ...body }
|
||||
this.customers.set(customer.id, customer)
|
||||
return json(201, { customer, created: true })
|
||||
}
|
||||
|
||||
const group = path.match(/^\/internal\/customers\/([^/]+)\/groups$/)
|
||||
if (method === "POST" && group?.[1]) {
|
||||
const id = decodeURIComponent(group[1])
|
||||
if (!this.customers.has(id)) return json(404, { type: "not_found", message: "customer non trovato" })
|
||||
if (body.action === "add") this.installerGroup.add(id)
|
||||
else this.installerGroup.delete(id)
|
||||
return json(200, { customer_id: id, group: { id: "cusgroup_1", name: "installatori" }, member: body.action === "add" })
|
||||
}
|
||||
|
||||
const cartCustomer = path.match(/^\/internal\/carts\/([^/]+)\/customer$/)
|
||||
if (method === "POST" && cartCustomer?.[1]) {
|
||||
const cart = this.carts.get(cartCustomer[1])
|
||||
const customer = this.customers.get(body.customer_id)
|
||||
if (!cart || !customer) return json(404, { type: "not_found", message: "non trovato" })
|
||||
cart.customer_id = customer.id
|
||||
cart.email = customer.email
|
||||
return json(200, { cart: { id: cart.id } })
|
||||
}
|
||||
|
||||
const cartGuest = path.match(/^\/internal\/carts\/([^/]+)\/guest$/)
|
||||
if (method === "POST" && cartGuest?.[1]) {
|
||||
const cart = this.carts.get(cartGuest[1])
|
||||
if (!cart) return json(404, { type: "not_found", message: "non trovato" })
|
||||
if (cart.customer_id && this.customers.get(cart.customer_id)?.has_account) {
|
||||
return json(400, { type: "not_allowed", message: "carrello di un account" })
|
||||
}
|
||||
let guest = [...this.customers.values()].find((c) => !c.has_account && c.email === body.email)
|
||||
if (!guest) {
|
||||
guest = { id: `cus_guest_${++this.seq}`, email: body.email, first_name: "", last_name: "", company_name: null, has_account: false }
|
||||
this.customers.set(guest.id, guest)
|
||||
}
|
||||
cart.customer_id = guest.id
|
||||
cart.email = guest.email
|
||||
return json(200, { cart_id: cart.id, email: guest.email })
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/internal/prices") {
|
||||
const installer = this.installerGroup.has(body.customer_id)
|
||||
const prices: Record<string, unknown> = {}
|
||||
for (const id of body.variant_ids as string[]) {
|
||||
if (id !== MOCK_VARIANT.id) continue
|
||||
// Come Medusa con un listino "override": original_amount = prezzo del listino
|
||||
prices[id] = installer
|
||||
? { amount: MOCK_VARIANT.installerPrice, original_amount: MOCK_VARIANT.installerPrice, price_list_type: "override" }
|
||||
: { amount: MOCK_VARIANT.price, original_amount: MOCK_VARIANT.price, price_list_type: null }
|
||||
}
|
||||
return json(200, { prices })
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/internal/orders/claim") {
|
||||
const customer = this.customers.get(body.customer_id)
|
||||
if (!customer) return json(404, { type: "not_found", message: "customer non trovato" })
|
||||
if (!customer.has_account || customer.email !== body.email) return json(400, { type: "invalid_data", message: "email diversa" })
|
||||
const claimed: string[] = []
|
||||
for (const order of this.orders) {
|
||||
const owner = this.customers.get(order.customer_id)
|
||||
if (order.email === body.email && !owner?.has_account) {
|
||||
order.customer_id = customer.id
|
||||
claimed.push(order.id)
|
||||
}
|
||||
}
|
||||
return json(200, { claimed, failed: [] })
|
||||
}
|
||||
|
||||
return json(404, { type: "not_found", message: "route non simulata" })
|
||||
}
|
||||
|
||||
// --- Store API ---
|
||||
|
||||
private unitPrice(cart: Cart) {
|
||||
return cart.customer_id && this.installerGroup.has(cart.customer_id) ? MOCK_VARIANT.installerPrice : MOCK_VARIANT.price
|
||||
}
|
||||
|
||||
private cartBody(cart: Cart) {
|
||||
const price = this.unitPrice(cart)
|
||||
const items = cart.items.map((i) => ({
|
||||
id: i.id,
|
||||
variant_id: i.variant_id,
|
||||
product_handle: MOCK_PRODUCT.handle,
|
||||
product_title: MOCK_PRODUCT.title,
|
||||
title: "Standard",
|
||||
variant_title: "Standard",
|
||||
variant_sku: "SKU-1",
|
||||
thumbnail: null,
|
||||
quantity: i.quantity,
|
||||
unit_price: price,
|
||||
total: price * i.quantity,
|
||||
}))
|
||||
const itemTotal = items.reduce((n, i) => n + i.total, 0)
|
||||
const shipping = cart.shipping_option_id ? MOCK_SHIPPING.amount : 0
|
||||
const total = itemTotal + shipping
|
||||
return {
|
||||
id: cart.id,
|
||||
email: cart.email,
|
||||
customer_id: cart.customer_id,
|
||||
completed_at: cart.completed_at,
|
||||
region_id: REGION,
|
||||
item_total: itemTotal,
|
||||
shipping_total: shipping,
|
||||
discount_total: 0,
|
||||
tax_total: Math.round(((total * 22) / 122) * 100) / 100,
|
||||
total,
|
||||
items,
|
||||
shipping_address: cart.shipping_address,
|
||||
billing_address: cart.billing_address,
|
||||
metadata: cart.metadata,
|
||||
shipping_methods: cart.shipping_option_id
|
||||
? [{ shipping_option_id: cart.shipping_option_id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount }]
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
private store(method: string, url: URL, body: any): Response {
|
||||
const path = url.pathname
|
||||
|
||||
if (method === "GET" && path === "/store/regions") {
|
||||
return json(200, { regions: [{ id: REGION, countries: [{ iso_2: "it" }] }] })
|
||||
}
|
||||
if (method === "GET" && path === "/store/product-categories") {
|
||||
return json(200, {
|
||||
product_categories: [{ id: "pcat_1", handle: "energia", name: "Energia", description: "", parent_category_id: null }],
|
||||
})
|
||||
}
|
||||
if (method === "GET" && path === "/store/products") {
|
||||
const handle = url.searchParams.get("handle")
|
||||
const category = url.searchParams.get("category_id[]")
|
||||
const product = {
|
||||
id: "prod_1",
|
||||
...MOCK_PRODUCT,
|
||||
subtitle: null,
|
||||
description: "Descrizione",
|
||||
thumbnail: null,
|
||||
images: [],
|
||||
categories: [{ handle: "energia", name: "Energia" }],
|
||||
variants: [
|
||||
{
|
||||
id: MOCK_VARIANT.id,
|
||||
title: "Standard",
|
||||
sku: "SKU-1",
|
||||
manage_inventory: true,
|
||||
allow_backorder: false,
|
||||
inventory_quantity: MOCK_VARIANT.stock,
|
||||
calculated_price: { calculated_amount: MOCK_VARIANT.price, original_amount: MOCK_VARIANT.price },
|
||||
options: [{ value: "Standard", option: { title: "Modello" } }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const match = (!handle || handle === MOCK_PRODUCT.handle) && (!category || category === "pcat_1")
|
||||
const products = match ? [product] : []
|
||||
return json(200, { products, count: products.length, offset: 0, limit: 12 })
|
||||
}
|
||||
|
||||
if (method === "POST" && path === "/store/carts") {
|
||||
const cart: Cart = {
|
||||
id: `cart_${++this.seq}`,
|
||||
email: null,
|
||||
customer_id: null,
|
||||
items: [],
|
||||
shipping_address: null,
|
||||
billing_address: null,
|
||||
shipping_option_id: null,
|
||||
completed_at: null,
|
||||
payment_session: false,
|
||||
metadata: null,
|
||||
}
|
||||
this.carts.set(cart.id, cart)
|
||||
return json(200, { cart: this.cartBody(cart) })
|
||||
}
|
||||
|
||||
const m = path.match(/^\/store\/carts\/([^/]+)(\/.*)?$/)
|
||||
if (m?.[1]) {
|
||||
const cart = this.carts.get(m[1])
|
||||
if (!cart) return json(404, { type: "not_found", message: `Cart with id '${m[1]}' not found` })
|
||||
return this.cartRoute(cart, method, m[2] ?? "", body)
|
||||
}
|
||||
|
||||
if (method === "GET" && path === "/store/shipping-options") {
|
||||
return json(200, {
|
||||
shipping_options: [{ id: MOCK_SHIPPING.id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount, insufficient_inventory: false }],
|
||||
})
|
||||
}
|
||||
if (method === "POST" && path === "/store/payment-collections") {
|
||||
const cart = this.carts.get(body.cart_id)
|
||||
if (!cart) return json(404, { type: "not_found", message: "cart" })
|
||||
return json(200, { payment_collection: { id: `paycol_${cart.id}`, amount: this.cartBody(cart).total, payment_sessions: [] } })
|
||||
}
|
||||
const ps = path.match(/^\/store\/payment-collections\/paycol_([^/]+)\/payment-sessions$/)
|
||||
if (method === "POST" && ps?.[1]) {
|
||||
const cart = this.carts.get(ps[1])
|
||||
if (!cart) return json(404, { type: "not_found", message: "collection" })
|
||||
cart.payment_session = true
|
||||
return json(200, {
|
||||
payment_collection: {
|
||||
id: `paycol_${cart.id}`,
|
||||
amount: this.cartBody(cart).total,
|
||||
payment_sessions: [{ provider_id: body.provider_id, data: { client_secret: `pi_secret_${cart.id}` } }],
|
||||
},
|
||||
})
|
||||
}
|
||||
return json(404, { type: "not_found", message: "route non simulata" })
|
||||
}
|
||||
|
||||
private cartRoute(cart: Cart, method: string, rest: string, body: any): Response {
|
||||
if (method === "GET" && rest === "") return json(200, { cart: this.cartBody(cart) })
|
||||
if (method === "POST" && rest === "") {
|
||||
if (body.shipping_address) cart.shipping_address = body.shipping_address
|
||||
if (body.billing_address) cart.billing_address = body.billing_address
|
||||
// Come Medusa: i metadata inviati si fondono con quelli esistenti
|
||||
if (body.metadata) cart.metadata = { ...cart.metadata, ...body.metadata }
|
||||
return json(200, { cart: this.cartBody(cart) })
|
||||
}
|
||||
if (method === "POST" && rest === "/line-items") {
|
||||
if (body.variant_id !== MOCK_VARIANT.id) return json(404, { type: "not_found", message: "Variant not found" })
|
||||
const existing = cart.items.find((i) => i.variant_id === body.variant_id)
|
||||
const quantity = (existing?.quantity ?? 0) + body.quantity
|
||||
if (quantity > MOCK_VARIANT.stock) {
|
||||
return json(400, { type: "not_allowed", message: "Some variant does not have the required inventory" })
|
||||
}
|
||||
if (existing) existing.quantity = quantity
|
||||
else cart.items.push({ id: `cali_${++this.seq}`, variant_id: body.variant_id, quantity: body.quantity })
|
||||
return json(200, { cart: this.cartBody(cart) })
|
||||
}
|
||||
const li = rest.match(/^\/line-items\/([^/]+)$/)
|
||||
if (li?.[1]) {
|
||||
const item = cart.items.find((i) => i.id === li[1])
|
||||
if (!item) return json(404, { type: "not_found", message: "Line item not found" })
|
||||
if (method === "DELETE") {
|
||||
cart.items = cart.items.filter((i) => i !== item)
|
||||
return json(200, { id: item.id, deleted: true, parent: this.cartBody(cart) })
|
||||
}
|
||||
item.quantity = body.quantity
|
||||
return json(200, { cart: this.cartBody(cart) })
|
||||
}
|
||||
if (method === "POST" && rest === "/shipping-methods") {
|
||||
if (body.option_id !== MOCK_SHIPPING.id) return json(400, { type: "invalid_data", message: "Shipping option not valid" })
|
||||
cart.shipping_option_id = body.option_id
|
||||
return json(200, { cart: this.cartBody(cart) })
|
||||
}
|
||||
if (method === "POST" && rest === "/complete") {
|
||||
if (!cart.payment_session) return json(400, { type: "invalid_data", message: "Payment sessions are required to complete cart" })
|
||||
if (!this.paymentAuthorized) {
|
||||
return json(200, {
|
||||
type: "cart",
|
||||
cart: this.cartBody(cart),
|
||||
error: { message: "Payment authorization failed", type: "payment_authorization_error" },
|
||||
})
|
||||
}
|
||||
cart.completed_at = new Date().toISOString()
|
||||
const b = this.cartBody(cart)
|
||||
return json(200, {
|
||||
type: "order",
|
||||
order: {
|
||||
id: `order_${++this.seq}`,
|
||||
display_id: 42,
|
||||
email: cart.email,
|
||||
created_at: cart.completed_at,
|
||||
item_total: b.item_total,
|
||||
shipping_total: b.shipping_total,
|
||||
tax_total: b.tax_total,
|
||||
total: b.total,
|
||||
items: b.items,
|
||||
metadata: cart.metadata,
|
||||
},
|
||||
})
|
||||
}
|
||||
return json(404, { type: "not_found", message: "route non simulata" })
|
||||
}
|
||||
}
|
||||
|
||||
const json = (status: number, data: unknown) =>
|
||||
new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } })
|
||||
@@ -0,0 +1,245 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { z } from "zod"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { installerProfiles, users } from "../src/db/schema/index.js"
|
||||
import { type FetchFn, MedusaClient, MedusaError } from "../src/services/medusa-client.js"
|
||||
import { ensureMedusaCustomer, syncInstallerGroup } from "../src/services/medusa-sync.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { buildTestApp, type TestApp, tokenFromEmail, truncateAll } from "./helpers.js"
|
||||
|
||||
const PASSWORD = "Password-di-test-1"
|
||||
|
||||
let t: TestApp
|
||||
const db = () => t.app.db
|
||||
|
||||
const register = (body: Record<string, unknown>) =>
|
||||
t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { password: PASSWORD, firstName: "Mario", lastName: "Rossi", acceptPrivacy: true, ...body },
|
||||
})
|
||||
|
||||
const login = (email: string) => t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password: PASSWORD } })
|
||||
|
||||
const userByEmail = async (email: string) => (await db().select().from(users).where(eq(users.email, email)))[0]!
|
||||
|
||||
/** Registra e conferma l'email con il link ricevuto. */
|
||||
async function verifyEmailOf(to: string) {
|
||||
const mail = t.email.sent.filter((m) => m.to === to && m.tag === "verify-email").at(-1)
|
||||
const res = await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token: tokenFromEmail(mail?.text) } })
|
||||
expect(res.statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp({}, (app) => {
|
||||
app.get("/__test/medusa-down", async () => {
|
||||
throw new MedusaError("unavailable", undefined, undefined, "Medusa non raggiungibile (GET /segreto)")
|
||||
})
|
||||
})
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.email.clear()
|
||||
t.medusa.reset()
|
||||
})
|
||||
|
||||
describe("client Medusa", () => {
|
||||
const ok = z.object({ ok: z.boolean() })
|
||||
const client = (fetch: FetchFn, timeoutMs = 1000) =>
|
||||
new MedusaClient({ baseUrl: "http://medusa.test/", publishableKey: "pk_x", internalSecret: "s".repeat(32), timeoutMs, fetch })
|
||||
|
||||
it("usa la publishable key per la Store API e il segreto per le route interne", async () => {
|
||||
const seen: { url: string; headers: Record<string, string> }[] = []
|
||||
const c = client(async (input, init) => {
|
||||
seen.push({ url: String(input), headers: init?.headers as Record<string, string> })
|
||||
return Response.json({ ok: true })
|
||||
})
|
||||
await c.store.request("/store/products", ok, { query: { limit: 5, q: undefined } })
|
||||
await c.internal.request("/internal/x", ok, { body: { a: 1 } })
|
||||
|
||||
expect(seen[0]!.url).toBe("http://medusa.test/store/products?limit=5")
|
||||
expect(seen[0]!.headers["x-publishable-api-key"]).toBe("pk_x")
|
||||
expect(seen[0]!.headers["x-internal-secret"]).toBeUndefined()
|
||||
expect(seen[1]!.headers["x-internal-secret"]).toBe("s".repeat(32))
|
||||
expect(seen[1]!.headers["x-publishable-api-key"]).toBeUndefined()
|
||||
expect(seen[1]!.headers["content-type"]).toBe("application/json")
|
||||
})
|
||||
|
||||
it("errori uniformi: rete, timeout, 5xx → unavailable; 4xx → rejected; formato errato → invalid_response", async () => {
|
||||
const fails = async (fetch: FetchFn, timeoutMs?: number) => {
|
||||
const err = await client(fetch, timeoutMs).store.request("/store/x", ok).catch((e: unknown) => e)
|
||||
expect(err).toBeInstanceOf(MedusaError)
|
||||
return err as MedusaError
|
||||
}
|
||||
|
||||
expect((await fails(async () => { throw new TypeError("fetch failed") })).kind).toBe("unavailable")
|
||||
|
||||
const hanging: FetchFn = (_input, init) =>
|
||||
new Promise((_resolve, reject) => init?.signal?.addEventListener("abort", () => reject(init.signal!.reason)))
|
||||
const timeout = await fails(hanging, 50)
|
||||
expect(timeout.kind).toBe("unavailable")
|
||||
expect(timeout.message).toContain("timeout")
|
||||
|
||||
const e500 = await fails(async () => Response.json({ type: "unknown_error", message: "boom" }, { status: 500 }))
|
||||
expect([e500.kind, e500.status]).toEqual(["unavailable", 500])
|
||||
|
||||
const e404 = await fails(async () => Response.json({ type: "not_found", message: "Customer non trovato" }, { status: 404 }))
|
||||
expect([e404.kind, e404.status, e404.type]).toEqual(["rejected", 404, "not_found"])
|
||||
|
||||
expect((await fails(async () => Response.json({ altro: 1 }))).kind).toBe("invalid_response")
|
||||
})
|
||||
|
||||
it("un MedusaError non gestito dalla route diventa 503 senza dettagli interni", async () => {
|
||||
const res = await t.app.inject({ method: "GET", url: "/__test/medusa-down" })
|
||||
expect(res.statusCode).toBe(503)
|
||||
expect(res.json().error.code).toBe("SHOP_UNAVAILABLE")
|
||||
expect(res.body).not.toContain("segreto")
|
||||
})
|
||||
})
|
||||
|
||||
describe("sincronizzazione account", () => {
|
||||
it("la registrazione crea il customer Medusa e ne salva l'id", async () => {
|
||||
const res = await register({ email: "Mario@Example.com", accountType: "company", companyName: "Rossi Srl", vatNumber: "IT01234567890" })
|
||||
expect(res.statusCode).toBe(202)
|
||||
await t.app.flushSyncs()
|
||||
|
||||
const customer = t.medusa.accountByEmail("mario@example.com")
|
||||
expect(customer).toMatchObject({ first_name: "Mario", last_name: "Rossi", company_name: "Rossi Srl" })
|
||||
expect((await userByEmail("mario@example.com")).medusaCustomerId).toBe(customer!.id)
|
||||
expect(t.medusa.calls[0]!.headers["x-internal-secret"]).toBe(t.app.config.INTERNAL_API_SECRET)
|
||||
})
|
||||
|
||||
it("con Medusa spento la registrazione riesce e il customer viene recuperato al login", async () => {
|
||||
t.medusa.down = true
|
||||
expect((await register({ email: "spento@example.com" })).statusCode).toBe(202)
|
||||
await t.app.flushSyncs()
|
||||
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBeNull()
|
||||
expect(t.logs.text).toContain("sincronizzazione Medusa non riuscita")
|
||||
|
||||
// Ancora spento: il login non fallisce
|
||||
expect((await login("spento@example.com")).statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBeNull()
|
||||
|
||||
t.medusa.down = false
|
||||
expect((await login("spento@example.com")).statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
const customer = t.medusa.accountByEmail("spento@example.com")
|
||||
expect((await userByEmail("spento@example.com")).medusaCustomerId).toBe(customer!.id)
|
||||
})
|
||||
|
||||
it("ensureMedusaCustomer è idempotente e non richiama Medusa se l'id c'è già", async () => {
|
||||
await register({ email: "idem@example.com" })
|
||||
await t.app.flushSyncs()
|
||||
const user = await userByEmail("idem@example.com")
|
||||
const calls = t.medusa.calls.length
|
||||
|
||||
expect(await ensureMedusaCustomer(t.app, user)).toBe(user.medusaCustomerId)
|
||||
expect(t.medusa.calls.length).toBe(calls)
|
||||
|
||||
// Id perso in DB: l'upsert per email restituisce lo stesso customer
|
||||
await db().update(users).set({ medusaCustomerId: null }).where(eq(users.id, user.id))
|
||||
expect(await ensureMedusaCustomer(t.app, { ...user, medusaCustomerId: null })).toBe(user.medusaCustomerId)
|
||||
expect(t.medusa.customers.size).toBe(1)
|
||||
})
|
||||
|
||||
it("ensureMedusaCustomer propaga l'errore se Medusa non risponde (per le operazioni shop)", async () => {
|
||||
await register({ email: "shop@example.com" })
|
||||
await t.app.flushSyncs()
|
||||
const user = { ...(await userByEmail("shop@example.com")), medusaCustomerId: null }
|
||||
t.medusa.down = true
|
||||
await expect(ensureMedusaCustomer(t.app, user)).rejects.toMatchObject({ kind: "unavailable" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("ordini guest", () => {
|
||||
it("un ordine guest precedente viene collegato solo dopo la verifica dell'email", async () => {
|
||||
const orderId = t.medusa.addGuestOrder("cliente@example.com")
|
||||
const other = t.medusa.addGuestOrder("altro@example.com")
|
||||
|
||||
await register({ email: "Cliente@Example.com" })
|
||||
await t.app.flushSyncs()
|
||||
const customerId = (await userByEmail("cliente@example.com")).medusaCustomerId!
|
||||
|
||||
// Email non verificata: il login non fa il claim
|
||||
await login("cliente@example.com")
|
||||
await t.app.flushSyncs()
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([])
|
||||
expect(t.medusa.calledPaths()).not.toContain("POST /internal/orders/claim")
|
||||
|
||||
await verifyEmailOf("cliente@example.com")
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
|
||||
expect(t.medusa.ordersOf(customerId)).not.toContain(other)
|
||||
const claim = t.medusa.calls.find((c) => c.path === "/internal/orders/claim")
|
||||
expect(claim?.body).toEqual({ customer_id: customerId, email: "cliente@example.com" })
|
||||
})
|
||||
|
||||
it("se Medusa è spento alla verifica, il claim viene recuperato al login successivo", async () => {
|
||||
const orderId = t.medusa.addGuestOrder("tardi@example.com")
|
||||
t.medusa.down = true
|
||||
await register({ email: "tardi@example.com" })
|
||||
await verifyEmailOf("tardi@example.com")
|
||||
expect((await userByEmail("tardi@example.com")).medusaCustomerId).toBeNull()
|
||||
|
||||
t.medusa.down = false
|
||||
await login("tardi@example.com")
|
||||
await t.app.flushSyncs()
|
||||
const customerId = (await userByEmail("tardi@example.com")).medusaCustomerId!
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
|
||||
})
|
||||
|
||||
it("il reset password (che verifica l'email) collega gli ordini guest", async () => {
|
||||
const orderId = t.medusa.addGuestOrder("reset@example.com")
|
||||
await register({ email: "reset@example.com" })
|
||||
await t.app.inject({ method: "POST", url: "/auth/forgot-password", payload: { email: "reset@example.com" } })
|
||||
const res = await t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/reset-password",
|
||||
payload: { token: tokenFromEmail(t.email.last("reset-password")?.text), password: "Nuova-password-1" },
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
const customerId = (await userByEmail("reset@example.com")).medusaCustomerId!
|
||||
expect(t.medusa.ordersOf(customerId)).toEqual([orderId])
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncInstallerGroup", () => {
|
||||
async function installer(status: "pending" | "approved" | "rejected", extra: Partial<typeof users.$inferInsert> = {}) {
|
||||
const [u] = await db()
|
||||
.insert(users)
|
||||
.values({ email: "inst@example.com", passwordHash: await hashPassword(PASSWORD), role: "installer", firstName: "I", lastName: "N", ...extra })
|
||||
.returning()
|
||||
await db().insert(installerProfiles).values({ userId: u!.id, businessName: "Impianti Srl", status })
|
||||
return u!
|
||||
}
|
||||
|
||||
it("approvato → aggiunto al gruppo (creando il customer se manca); poi rifiutato → rimosso", async () => {
|
||||
const user = await installer("approved")
|
||||
expect(await syncInstallerGroup(t.app, user)).toEqual({ member: true })
|
||||
const customerId = (await userByEmail("inst@example.com")).medusaCustomerId!
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(true)
|
||||
|
||||
await db().update(installerProfiles).set({ status: "rejected" }).where(eq(installerProfiles.userId, user.id))
|
||||
expect(await syncInstallerGroup(t.app, await userByEmail("inst@example.com"))).toEqual({ member: false })
|
||||
expect(t.medusa.installerGroup.has(customerId)).toBe(false)
|
||||
})
|
||||
|
||||
it("in attesa o disabilitato → non membro", async () => {
|
||||
const user = await installer("pending")
|
||||
expect(await syncInstallerGroup(t.app, user)).toEqual({ member: false })
|
||||
|
||||
await db().update(installerProfiles).set({ status: "approved" }).where(eq(installerProfiles.userId, user.id))
|
||||
await db().update(users).set({ disabledAt: new Date() }).where(eq(users.id, user.id))
|
||||
expect(await syncInstallerGroup(t.app, await userByEmail("inst@example.com"))).toEqual({ member: false })
|
||||
expect(t.medusa.installerGroup.size).toBe(0)
|
||||
})
|
||||
|
||||
it("propaga l'errore se Medusa non risponde", async () => {
|
||||
const user = await installer("approved")
|
||||
t.medusa.failStatus = 500
|
||||
await expect(syncInstallerGroup(t.app, user)).rejects.toBeInstanceOf(MedusaError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,239 @@
|
||||
import { rm } from "node:fs/promises"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { documents, quoteRequests, quotes } from "../src/db/schema/index.js"
|
||||
import { todayRome } from "../src/services/quotes.js"
|
||||
import { buildTestApp, createUserAndLogin, multipartBody, TEST_UPLOAD_DIR, type TestApp, tokenFromEmail, truncateAll } from "./helpers.js"
|
||||
|
||||
const PDF = Buffer.from("%PDF-1.7\n1 0 obj << >> endobj\n%%EOF\n")
|
||||
const YEAR = todayRome().slice(0, 4)
|
||||
|
||||
let t: TestApp
|
||||
let admin: Record<string, string>
|
||||
let mario: { user: { id: string; email: string }; headers: Record<string, string> }
|
||||
let luigi: { user: { id: string; email: string }; headers: Record<string, string> }
|
||||
|
||||
const call = (method: "GET" | "POST" | "PATCH", url: string, headers: Record<string, string>, payload?: object) =>
|
||||
t.app.inject({ method, url, headers, ...(payload ? { payload } : {}) })
|
||||
|
||||
async function newQuote(userId: string, extra: Record<string, unknown> = {}) {
|
||||
const res = await call("POST", "/admin/quotes", admin, { userId, title: "Impianto FV 6 kW", amountCents: 990000, validUntil: null, ...extra })
|
||||
expect(res.statusCode, res.body).toBe(201)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function sentQuote(userId: string, extra: Record<string, unknown> = {}) {
|
||||
const q = await newQuote(userId, extra)
|
||||
const sent = await call("POST", `/admin/quotes/${q.id}/send`, admin)
|
||||
expect(sent.statusCode, sent.body).toBe(200)
|
||||
return sent.json()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(async () => {
|
||||
await t.app.close()
|
||||
await rm(TEST_UPLOAD_DIR, { recursive: true, force: true })
|
||||
})
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.email.clear()
|
||||
admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
|
||||
mario = await createUserAndLogin(t.app, "mario@example.com")
|
||||
luigi = await createUserAndLogin(t.app, "luigi@example.com")
|
||||
})
|
||||
|
||||
describe("numerazione", () => {
|
||||
it("progressiva per anno: 2026-0001, 2026-0002…", async () => {
|
||||
const a = await newQuote(mario.user.id)
|
||||
const b = await newQuote(luigi.user.id)
|
||||
expect([a.number, b.number]).toEqual([`${YEAR}-0001`, `${YEAR}-0002`])
|
||||
expect(a.status).toBe("draft")
|
||||
})
|
||||
|
||||
it("nessun duplicato con 15 creazioni concorrenti", async () => {
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 15 }, (_, i) =>
|
||||
call("POST", "/admin/quotes", admin, { userId: i % 2 ? mario.user.id : luigi.user.id, title: `Preventivo ${i}`, amountCents: 1000 * i, validUntil: null }),
|
||||
),
|
||||
)
|
||||
expect(results.map((r) => r.statusCode)).toEqual(Array(15).fill(201))
|
||||
const numbers = results.map((r) => r.json().number).sort()
|
||||
expect(new Set(numbers).size).toBe(15)
|
||||
expect(numbers).toEqual(Array.from({ length: 15 }, (_, i) => `${YEAR}-${String(i + 1).padStart(4, "0")}`))
|
||||
})
|
||||
})
|
||||
|
||||
describe("ownership", () => {
|
||||
it("un cliente non può leggere né rispondere ai preventivi di un altro (404); le bozze non sono visibili", async () => {
|
||||
const draft = await newQuote(mario.user.id)
|
||||
const sent = await sentQuote(mario.user.id)
|
||||
|
||||
const own = await call("GET", "/me/quotes", mario.headers)
|
||||
expect(own.json().items.map((q: { id: string }) => q.id)).toEqual([sent.id])
|
||||
expect((await call("GET", `/me/quotes/${draft.id}`, mario.headers)).statusCode).toBe(404)
|
||||
expect((await call("GET", `/me/quotes/${sent.id}`, mario.headers)).statusCode).toBe(200)
|
||||
|
||||
expect((await call("GET", "/me/quotes", luigi.headers)).json().total).toBe(0)
|
||||
for (const url of [`/me/quotes/${sent.id}`, `/me/quotes/${sent.id}/pdf`]) {
|
||||
expect((await call("GET", url, luigi.headers)).statusCode, url).toBe(404)
|
||||
}
|
||||
for (const action of ["accept", "reject"]) {
|
||||
expect((await call("POST", `/me/quotes/${sent.id}/${action}`, luigi.headers)).statusCode).toBe(404)
|
||||
}
|
||||
expect((await call("GET", `/me/quotes/${sent.id}`, {})).statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("le route admin non sono accessibili ai clienti", async () => {
|
||||
expect((await call("GET", "/admin/quotes", mario.headers)).statusCode).toBe(403)
|
||||
expect((await call("GET", "/admin/quote-requests", mario.headers)).statusCode).toBe(403)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ciclo di vita", () => {
|
||||
it("invio con email, accettazione una sola volta, notifica allo staff", async () => {
|
||||
const q = await sentQuote(mario.user.id, { validUntil: "2099-12-31" })
|
||||
expect(q.status).toBe("sent")
|
||||
expect(q.sentAt).not.toBeNull()
|
||||
const mail = t.email.last("quote-sent")
|
||||
expect(mail?.to).toBe("mario@example.com")
|
||||
expect(mail?.text).toContain(q.number)
|
||||
expect(mail?.text).toMatch(/9\.900,00\s€/)
|
||||
expect(mail?.text).toContain(`/preventivi/${q.id}`)
|
||||
|
||||
const accepted = await call("POST", `/me/quotes/${q.id}/accept`, mario.headers)
|
||||
expect(accepted.json().status).toBe("accepted")
|
||||
expect(t.email.last("staff-notification")?.subject).toContain("accettato")
|
||||
expect((await call("POST", `/me/quotes/${q.id}/reject`, mario.headers)).json().error.code).toBe("QUOTE_NOT_PENDING")
|
||||
expect((await call("POST", `/admin/quotes/${q.id}/send`, admin)).statusCode).toBe(409)
|
||||
})
|
||||
|
||||
it("preventivo scaduto: mostrato come expired, non accettabile", async () => {
|
||||
const q = await sentQuote(mario.user.id)
|
||||
await t.app.db.update(quotes).set({ validUntil: "2020-01-01" }).where(eq(quotes.id, q.id))
|
||||
expect((await call("GET", `/me/quotes/${q.id}`, mario.headers)).json().status).toBe("expired")
|
||||
const res = await call("POST", `/me/quotes/${q.id}/accept`, mario.headers)
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json().error.code).toBe("QUOTE_EXPIRED")
|
||||
const [row] = await t.app.db.select().from(quotes).where(eq(quotes.id, q.id))
|
||||
expect(row!.status).toBe("expired")
|
||||
})
|
||||
|
||||
it("modificabile solo in bozza; PATCH parziale non tocca gli altri campi", async () => {
|
||||
const q = await newQuote(mario.user.id, { validUntil: "2099-01-31" })
|
||||
const patched = await call("PATCH", `/admin/quotes/${q.id}`, admin, { amountCents: 1000000 })
|
||||
expect(patched.json()).toMatchObject({ amountCents: 1000000, title: "Impianto FV 6 kW", validUntil: "2099-01-31" })
|
||||
await call("POST", `/admin/quotes/${q.id}/send`, admin)
|
||||
expect((await call("PATCH", `/admin/quotes/${q.id}`, admin, { title: "Nuovo" })).json().error.code).toBe("QUOTE_NOT_DRAFT")
|
||||
})
|
||||
|
||||
it("PDF: allegato come documento privato admin, scaricabile solo dal proprietario", async () => {
|
||||
const q = await newQuote(mario.user.id)
|
||||
const upload = (content: Buffer) => {
|
||||
const body = multipartBody({}, [{ filename: "preventivo.pdf", contentType: "application/pdf", content }])
|
||||
return t.app.inject({ method: "POST", url: `/admin/quotes/${q.id}/pdf`, payload: body.payload, headers: { ...body.headers, ...admin } })
|
||||
}
|
||||
const first = await upload(PDF)
|
||||
expect(first.statusCode, first.body).toBe(200)
|
||||
expect(first.json().hasPdf).toBe(true)
|
||||
const firstDoc = first.json().pdfDocumentId
|
||||
|
||||
// Sostituzione: il documento precedente viene eliminato
|
||||
const second = await upload(Buffer.concat([PDF, Buffer.from("% v2\n")]))
|
||||
expect(second.json().pdfDocumentId).not.toBe(firstDoc)
|
||||
const docs = await t.app.db.select().from(documents)
|
||||
expect(docs).toHaveLength(1)
|
||||
expect(docs[0]).toMatchObject({ visibility: "admin", category: "preventivi", title: `Preventivo ${q.number}` })
|
||||
|
||||
await call("POST", `/admin/quotes/${q.id}/send`, admin)
|
||||
const dl = await call("GET", `/me/quotes/${q.id}/pdf`, mario.headers)
|
||||
expect(dl.statusCode).toBe(200)
|
||||
expect(dl.headers["content-disposition"]).toContain(`Preventivo ${q.number}.pdf`)
|
||||
expect(dl.headers["cache-control"]).toBe("private, no-store")
|
||||
// Il documento non è raggiungibile dal download generico per un cliente
|
||||
expect((await call("GET", `/documents/${docs[0]!.id}/download`, mario.headers)).statusCode).toBe(403)
|
||||
expect((await call("GET", `/me/quotes/${q.id}/pdf`, luigi.headers)).statusCode).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe("richieste di preventivo", () => {
|
||||
const answers = { percorso: "acqua", acqua_persone: 3, acqua_durezza_nota: "no", acqua_abitazione: "casa" }
|
||||
const submit = (email: string) =>
|
||||
t.app.inject({
|
||||
method: "POST",
|
||||
url: "/configurator/submit",
|
||||
payload: { answers, contact: { name: "Anonimo", email }, acceptPrivacy: true },
|
||||
})
|
||||
|
||||
it("elenco, filtro, cambio stato; preventivo da richiesta di un account esistente", async () => {
|
||||
const req = (await submit("mario@example.com")).json()
|
||||
const list = await call("GET", "/admin/quote-requests?status=new&q=mario", admin)
|
||||
expect(list.json().items.map((r: { id: string }) => r.id)).toEqual([req.id])
|
||||
|
||||
const patched = await call("PATCH", `/admin/quote-requests/${req.id}`, admin, { status: "contacted" })
|
||||
expect(patched.json().status).toBe("contacted")
|
||||
|
||||
const q = await newQuote(undefined as never, { userId: undefined, quoteRequestId: req.id })
|
||||
expect(q.userId).toBe(mario.user.id) // trovato per email; la richiesta viene collegata
|
||||
await call("POST", `/admin/quotes/${q.id}/send`, admin)
|
||||
const detail = (await call("GET", `/admin/quote-requests/${req.id}`, admin)).json()
|
||||
expect(detail).toMatchObject({ userId: mario.user.id, status: "converted" })
|
||||
expect(detail.quotes.map((x: { id: string }) => x.id)).toEqual([q.id])
|
||||
})
|
||||
|
||||
it("richiesta anonima senza account → 409 CUSTOMER_ACCOUNT_REQUIRED", async () => {
|
||||
const req = (await submit("nessuno@example.com")).json()
|
||||
const res = await call("POST", "/admin/quotes", admin, { quoteRequestId: req.id, title: "Addolcitore", amountCents: 150000, validUntil: null })
|
||||
expect(res.statusCode).toBe(409)
|
||||
expect(res.json().error.code).toBe("CUSTOMER_ACCOUNT_REQUIRED")
|
||||
})
|
||||
|
||||
it("le richieste anonime vengono collegate all'account alla verifica dell'email, non alla sola registrazione", async () => {
|
||||
await submit("nuovo@example.com")
|
||||
await t.app.inject({
|
||||
method: "POST",
|
||||
url: "/auth/register",
|
||||
payload: { email: "nuovo@example.com", password: "Password-di-test-1", firstName: "N", lastName: "U", acceptPrivacy: true },
|
||||
})
|
||||
const [before] = await t.app.db.select().from(quoteRequests)
|
||||
expect(before!.userId).toBeNull()
|
||||
|
||||
const token = tokenFromEmail(t.email.last("verify-email")?.text)
|
||||
await t.app.inject({ method: "POST", url: "/auth/verify-email", payload: { token } })
|
||||
const [after] = await t.app.db.select().from(quoteRequests)
|
||||
expect(after!.userId).not.toBeNull()
|
||||
await t.app.flushSyncs()
|
||||
})
|
||||
})
|
||||
|
||||
describe("ordini", () => {
|
||||
it("GET /me/orders legge gli ordini del proprio customer Medusa (centesimi)", async () => {
|
||||
// Il customer Medusa viene creato al primo accesso agli ordini
|
||||
await t.app.inject({ method: "GET", url: "/me/orders", headers: mario.headers })
|
||||
const customerId = t.medusa.accountByEmail("mario@example.com")!.id
|
||||
t.medusa.customerOrderList.set(customerId, [
|
||||
{
|
||||
id: "order_1",
|
||||
display_id: 12,
|
||||
status: "pending",
|
||||
created_at: "2026-10-01T10:00:00.000Z",
|
||||
payment_status: "captured",
|
||||
fulfillment_status: "not_fulfilled",
|
||||
item_total: 1190,
|
||||
shipping_total: 15,
|
||||
tax_total: 217.29,
|
||||
total: 1205,
|
||||
items: [{ title: "Addolcitore", variant_sku: "ADD-20", thumbnail: null, quantity: 1, unit_price: 1190, total: 1190 }],
|
||||
},
|
||||
])
|
||||
const res = await t.app.inject({ method: "GET", url: "/me/orders", headers: mario.headers })
|
||||
expect(res.json()).toMatchObject({
|
||||
total: 1,
|
||||
items: [{ number: 12, paymentStatus: "captured", totals: { total: 120500, tax: 21729 }, items: [{ sku: "ADD-20", unitPrice: 119000 }] }],
|
||||
})
|
||||
expect(res.body).not.toContain("order_1")
|
||||
// Luigi non vede gli ordini di Mario
|
||||
expect((await t.app.inject({ method: "GET", url: "/me/orders", headers: luigi.headers })).json().total).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { consents, installerProfiles, users } from "../src/db/schema/index.js"
|
||||
import { CART_COOKIE } from "../src/services/shop.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
|
||||
import { MOCK_PRODUCT, MOCK_SHIPPING, MOCK_VARIANT } from "./medusa-mock.js"
|
||||
|
||||
const PASSWORD = "Password-di-test-1"
|
||||
const PRICE = MOCK_VARIANT.price * 100
|
||||
const INSTALLER_PRICE = MOCK_VARIANT.installerPrice * 100
|
||||
|
||||
let t: TestApp
|
||||
|
||||
/** Client di test con cookie jar e access token facoltativo. */
|
||||
class Browser {
|
||||
jar: Record<string, string> = {}
|
||||
token: string | undefined
|
||||
|
||||
async call(method: "GET" | "POST" | "PATCH" | "DELETE", url: string, payload?: Record<string, unknown>) {
|
||||
const res = await t.app.inject({
|
||||
method,
|
||||
url,
|
||||
cookies: this.jar,
|
||||
headers: this.token ? { authorization: `Bearer ${this.token}` } : {},
|
||||
...(payload ? { payload } : {}),
|
||||
})
|
||||
for (const c of res.cookies as { name: string; value: string; maxAge?: number; expires?: Date }[]) {
|
||||
const expired = c.maxAge === 0 || (c.expires !== undefined && c.expires.getTime() <= Date.now()) || c.value === ""
|
||||
if (expired) delete this.jar[c.name]
|
||||
else this.jar[c.name] = c.value
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async login(email: string) {
|
||||
const res = await this.call("POST", "/auth/login", { email, password: PASSWORD })
|
||||
expect(res.statusCode).toBe(200)
|
||||
this.token = res.json().accessToken
|
||||
return res
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(email: string, role: "customer" | "installer" = "customer", installerStatus?: "pending" | "approved") {
|
||||
const [u] = await t.app.db
|
||||
.insert(users)
|
||||
.values({ email, passwordHash: await hashPassword(PASSWORD), role, firstName: "Ina", lastName: "Stall", emailVerifiedAt: new Date() })
|
||||
.returning()
|
||||
if (installerStatus) {
|
||||
await t.app.db.insert(installerProfiles).values({ userId: u!.id, businessName: "Impianti Srl", status: installerStatus })
|
||||
}
|
||||
return u!
|
||||
}
|
||||
|
||||
/** Installatore approvato con customer Medusa già nel gruppo installatori. */
|
||||
async function approvedInstaller(email = "inst@example.com") {
|
||||
const u = await createUser(email, "installer", "approved")
|
||||
const res = await t.medusa.fetch(`${t.app.config.MEDUSA_URL}/internal/customers`, {
|
||||
method: "POST",
|
||||
headers: { "x-internal-secret": t.app.config.INTERNAL_API_SECRET },
|
||||
body: JSON.stringify({ email, first_name: "Ina", last_name: "Stall" }),
|
||||
})
|
||||
const customerId = ((await res.json()) as { customer: { id: string } }).customer.id
|
||||
await t.app.db.update(users).set({ medusaCustomerId: customerId }).where(eq(users.id, u.id))
|
||||
t.medusa.installerGroup.add(customerId)
|
||||
return { user: u, customerId }
|
||||
}
|
||||
|
||||
const address = { firstName: "Mario", lastName: "Rossi", address1: "Via Roma 1", city: "Milano", postalCode: "20100", province: "MI" }
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.medusa.reset()
|
||||
t.app.shop.clearCache()
|
||||
})
|
||||
|
||||
describe("catalogo", () => {
|
||||
it("lista e dettaglio con prezzi standard in centesimi, senza id prodotto Medusa", async () => {
|
||||
const b = new Browser()
|
||||
const list = await b.call("GET", "/shop/products?limit=5")
|
||||
expect(list.statusCode).toBe(200)
|
||||
expect(list.json()).toMatchObject({ total: 1, limit: 5, offset: 0 })
|
||||
expect(list.json().items[0]).toEqual({
|
||||
handle: MOCK_PRODUCT.handle,
|
||||
title: MOCK_PRODUCT.title,
|
||||
subtitle: null,
|
||||
thumbnail: null,
|
||||
fromPrice: PRICE,
|
||||
fromOriginalPrice: PRICE,
|
||||
priceType: "standard",
|
||||
categories: [{ handle: "energia", name: "Energia" }],
|
||||
})
|
||||
expect(list.body).not.toContain("prod_1")
|
||||
|
||||
const detail = await b.call("GET", `/shop/products/${MOCK_PRODUCT.handle}`)
|
||||
expect(detail.json().variants[0]).toMatchObject({ id: MOCK_VARIANT.id, price: PRICE, originalPrice: PRICE, inStock: true })
|
||||
|
||||
expect((await b.call("GET", "/shop/products/inesistente")).json().error.code).toBe("PRODUCT_NOT_FOUND")
|
||||
})
|
||||
|
||||
it("filtra per categoria (handle) e restituisce le categorie", async () => {
|
||||
const b = new Browser()
|
||||
expect((await b.call("GET", "/shop/products?category=energia")).json().total).toBe(1)
|
||||
expect((await b.call("GET", "/shop/products?category=altro")).json()).toMatchObject({ items: [], total: 0 })
|
||||
expect((await b.call("GET", "/shop/categories")).json()).toEqual([
|
||||
{ handle: "energia", name: "Energia", description: null, parentHandle: null },
|
||||
])
|
||||
})
|
||||
|
||||
it("le liste sono in cache (una sola chiamata a Medusa per pagina)", async () => {
|
||||
const b = new Browser()
|
||||
await b.call("GET", "/shop/products?limit=5")
|
||||
await b.call("GET", "/shop/products?limit=5")
|
||||
expect(t.medusa.calledPaths().filter((p) => p === "GET /store/products")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("installatore approvato vede il listino riservato; cliente e installatore in attesa no", async () => {
|
||||
await approvedInstaller()
|
||||
const inst = new Browser()
|
||||
await inst.login("inst@example.com")
|
||||
const detail = (await inst.call("GET", `/shop/products/${MOCK_PRODUCT.handle}`)).json()
|
||||
expect(detail.priceType).toBe("installer")
|
||||
expect(detail.variants[0]).toMatchObject({ price: INSTALLER_PRICE, originalPrice: PRICE })
|
||||
expect((await inst.call("GET", "/shop/products")).json().items[0]).toMatchObject({
|
||||
fromPrice: INSTALLER_PRICE,
|
||||
fromOriginalPrice: PRICE,
|
||||
priceType: "installer",
|
||||
})
|
||||
|
||||
await createUser("cliente@example.com")
|
||||
await createUser("attesa@example.com", "installer", "pending")
|
||||
for (const email of ["cliente@example.com", "attesa@example.com"]) {
|
||||
const b = new Browser()
|
||||
await b.login(email)
|
||||
expect((await b.call("GET", `/shop/products/${MOCK_PRODUCT.handle}`)).json().variants[0].price).toBe(PRICE)
|
||||
}
|
||||
// Anonimo dopo l'installatore: la cache del catalogo non contiene prezzi riservati
|
||||
expect((await new Browser().call("GET", "/shop/products")).json().items[0].fromPrice).toBe(PRICE)
|
||||
})
|
||||
|
||||
it("token non valido → 401 (niente prezzi da anonimo in silenzio)", async () => {
|
||||
const b = new Browser()
|
||||
b.token = "non.valido.token"
|
||||
expect((await b.call("GET", "/shop/products")).statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe("carrello guest e checkout", () => {
|
||||
it("flusso completo: righe, indirizzo con email, spedizione, pagamento, ordine", async () => {
|
||||
const b = new Browser()
|
||||
expect((await b.call("GET", "/shop/cart")).statusCode).toBe(404)
|
||||
|
||||
const created = await b.call("POST", "/shop/cart")
|
||||
expect(created.statusCode).toBe(201)
|
||||
expect(b.jar[CART_COOKIE]).toBeDefined()
|
||||
expect(created.headers["set-cookie"]).toMatch(/HttpOnly/i)
|
||||
expect((await b.call("POST", "/shop/cart")).statusCode).toBe(200) // stesso carrello
|
||||
|
||||
let cart = (await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 2 })).json()
|
||||
expect(cart).toMatchObject({ itemCount: 2, readyForPayment: false })
|
||||
expect(cart.items[0]).toMatchObject({ unitPrice: PRICE, total: 2 * PRICE, productHandle: MOCK_PRODUCT.handle })
|
||||
expect(JSON.stringify(cart)).not.toMatch(/cart_\d|cus_|reg_it/)
|
||||
|
||||
cart = (await b.call("PATCH", `/shop/cart/items/${cart.items[0].id}`, { quantity: 1 })).json()
|
||||
expect(cart.itemCount).toBe(1)
|
||||
|
||||
expect((await b.call("POST", "/shop/cart/payment")).json().error.code).toBe("CART_NOT_READY")
|
||||
const noEmail = await b.call("POST", "/shop/cart/address", { shippingAddress: address, acceptPrivacy: true })
|
||||
expect(noEmail.json().error.code).toBe("EMAIL_REQUIRED")
|
||||
expect((await b.call("POST", "/shop/cart/address", { email: "x@example.com", shippingAddress: { ...address, postalCode: "abc" }, acceptPrivacy: true })).statusCode).toBe(400)
|
||||
|
||||
cart = (await b.call("POST", "/shop/cart/address", { email: " Guest@Example.com ", shippingAddress: address, acceptPrivacy: true })).json()
|
||||
expect(cart.email).toBe("guest@example.com")
|
||||
expect(cart.shippingAddress).toMatchObject({ address1: "Via Roma 1", countryCode: "it" })
|
||||
expect(cart.billingAddress).toMatchObject({ address1: "Via Roma 1" })
|
||||
expect(t.medusa.calledPaths()).toContain(`POST /internal/carts/${[...t.medusa.carts.keys()][0]}/guest`)
|
||||
|
||||
const options = (await b.call("GET", "/shop/cart/shipping-options")).json()
|
||||
expect(options).toEqual([{ id: MOCK_SHIPPING.id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount * 100 }])
|
||||
cart = (await b.call("POST", "/shop/cart/shipping", { optionId: MOCK_SHIPPING.id })).json()
|
||||
expect(cart).toMatchObject({ readyForPayment: true, totals: { total: PRICE + 1500, shipping: 1500 } })
|
||||
|
||||
const pay = await b.call("POST", "/shop/cart/payment")
|
||||
expect(pay.json()).toEqual({ provider: "stripe", clientSecret: expect.stringMatching(/^pi_secret_/), amount: PRICE + 1500 })
|
||||
expect(t.medusa.calls.find((c) => c.path.endsWith("/payment-sessions"))?.body).toEqual({ provider_id: "pp_stripe_stripe" })
|
||||
|
||||
const order = await b.call("POST", "/shop/cart/complete")
|
||||
expect(order.statusCode).toBe(200)
|
||||
expect(order.json()).toMatchObject({ number: 42, email: "guest@example.com", totals: { total: PRICE + 1500 } })
|
||||
expect(b.jar[CART_COOKIE]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("pagamento non autorizzato → 402 e il carrello resta", async () => {
|
||||
const b = new Browser()
|
||||
await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
|
||||
await b.call("POST", "/shop/cart/address", { email: "g@example.com", shippingAddress: address, acceptPrivacy: true })
|
||||
await b.call("POST", "/shop/cart/shipping", { optionId: MOCK_SHIPPING.id })
|
||||
await b.call("POST", "/shop/cart/payment")
|
||||
t.medusa.paymentAuthorized = false
|
||||
const res = await b.call("POST", "/shop/cart/complete")
|
||||
expect(res.statusCode).toBe(402)
|
||||
expect(res.json().error.code).toBe("PAYMENT_NOT_COMPLETED")
|
||||
expect(b.jar[CART_COOKIE]).toBeDefined()
|
||||
})
|
||||
|
||||
it("errori Medusa tradotti: giacenza, riga inesistente, Medusa giù", async () => {
|
||||
const b = new Browser()
|
||||
const stock = await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 99 })
|
||||
expect(stock.statusCode).toBe(409)
|
||||
expect(stock.json().error.code).toBe("OUT_OF_STOCK")
|
||||
expect(stock.body).not.toContain("variant")
|
||||
|
||||
expect((await b.call("DELETE", "/shop/cart/items/cali_inesistente")).json().error.code).toBe("CART_ITEM_NOT_FOUND")
|
||||
|
||||
t.medusa.down = true
|
||||
expect((await b.call("GET", "/shop/cart")).json().error.code).toBe("SHOP_UNAVAILABLE")
|
||||
})
|
||||
|
||||
it("cookie manomesso o carrello inesistente → nessun carrello e cookie cancellato", async () => {
|
||||
const b = new Browser()
|
||||
b.jar[CART_COOKIE] = "cart_falso"
|
||||
expect((await b.call("GET", "/shop/cart")).statusCode).toBe(404)
|
||||
expect(t.medusa.calledPaths().some((p) => p.startsWith("GET /store/carts/"))).toBe(false)
|
||||
expect(b.jar[CART_COOKIE]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("checkout: consenso privacy obbligatorio, dati per la fattura nei metadata, consensi senza duplicati", async () => {
|
||||
const b = new Browser()
|
||||
await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
|
||||
const body = { email: "azienda@example.com", shippingAddress: address }
|
||||
|
||||
const noPrivacy = await b.call("POST", "/shop/cart/address", body)
|
||||
expect(noPrivacy.statusCode).toBe(400)
|
||||
expect(noPrivacy.json().error.details[0].path).toBe("body.acceptPrivacy")
|
||||
const badVat = await b.call("POST", "/shop/cart/address", {
|
||||
...body,
|
||||
acceptPrivacy: true,
|
||||
invoice: { companyName: "Impianti Srl", vatNumber: "01234567890" },
|
||||
})
|
||||
expect(badVat.json().error.details[0].path).toBe("body.invoice.vatNumber")
|
||||
|
||||
const invoice = { companyName: "Impianti Srl", vatNumber: "IT 00743110157", sdiCode: "abc1234", pec: " Fatture@PEC.it " }
|
||||
let cart = (await b.call("POST", "/shop/cart/address", { ...body, acceptPrivacy: true, acceptMarketing: true, invoice })).json()
|
||||
expect(cart.invoice).toEqual({ companyName: "Impianti Srl", vatNumber: "00743110157", fiscalCode: null, sdiCode: "ABC1234", pec: "fatture@pec.it" })
|
||||
const cartId = [...t.medusa.carts.keys()][0]!
|
||||
expect(t.medusa.carts.get(cartId)?.metadata).toMatchObject({ invoice: { vatNumber: "00743110157" } })
|
||||
|
||||
// Reinvio senza fattura: i dati vengono tolti, i consensi non si duplicano
|
||||
cart = (await b.call("POST", "/shop/cart/address", { ...body, acceptPrivacy: true, acceptMarketing: true })).json()
|
||||
expect(cart.invoice).toBeNull()
|
||||
const rows = await t.app.db.select().from(consents).where(eq(consents.email, "azienda@example.com"))
|
||||
expect(rows.map((r) => `${r.type}:${r.source}`).sort()).toEqual(["marketing:checkout", "privacy:checkout"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("carrello e account", () => {
|
||||
it("al login il carrello guest viene associato e riprezzato col listino installatori", async () => {
|
||||
const { customerId } = await approvedInstaller()
|
||||
const b = new Browser()
|
||||
const guest = (await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })).json()
|
||||
expect(guest.items[0].unitPrice).toBe(PRICE)
|
||||
|
||||
await b.login("inst@example.com")
|
||||
const cartId = [...t.medusa.carts.keys()][0]!
|
||||
expect(t.medusa.carts.get(cartId)?.customer_id).toBe(customerId)
|
||||
|
||||
const cart = (await b.call("GET", "/shop/cart")).json()
|
||||
expect(cart).toMatchObject({ email: "inst@example.com" })
|
||||
expect(cart.items[0].unitPrice).toBe(INSTALLER_PRICE)
|
||||
})
|
||||
|
||||
it("utente autenticato: nuovo carrello già suo, email dell'account, niente email nel body", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const b = new Browser()
|
||||
await b.login("cliente@example.com")
|
||||
await b.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
|
||||
const cart = (await b.call("POST", "/shop/cart/address", { shippingAddress: address, acceptPrivacy: true })).json()
|
||||
expect(cart.email).toBe("cliente@example.com")
|
||||
expect(t.medusa.calledPaths().some((p) => p.endsWith("/guest"))).toBe(false)
|
||||
})
|
||||
|
||||
it("il carrello di un account non è accessibile da anonimo né da un altro utente", async () => {
|
||||
await createUser("a@example.com")
|
||||
await createUser("b@example.com")
|
||||
const a = new Browser()
|
||||
await a.login("a@example.com")
|
||||
await a.call("POST", "/shop/cart/items", { variantId: MOCK_VARIANT.id, quantity: 1 })
|
||||
const stolen = a.jar[CART_COOKIE]!
|
||||
|
||||
const anon = new Browser()
|
||||
anon.jar[CART_COOKIE] = stolen
|
||||
expect((await anon.call("GET", "/shop/cart")).statusCode).toBe(404)
|
||||
|
||||
const other = new Browser()
|
||||
await other.login("b@example.com")
|
||||
other.jar[CART_COOKIE] = stolen
|
||||
expect((await other.call("GET", "/shop/cart")).statusCode).toBe(404)
|
||||
// B non ha "preso" il carrello di A
|
||||
expect((await a.call("GET", "/shop/cart")).json().itemCount).toBe(1)
|
||||
})
|
||||
|
||||
it("il logout cancella il cookie del carrello", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const b = new Browser()
|
||||
await b.login("cliente@example.com")
|
||||
await b.call("POST", "/shop/cart")
|
||||
expect(b.jar[CART_COOKIE]).toBeDefined()
|
||||
await b.call("POST", "/auth/logout")
|
||||
expect(b.jar[CART_COOKIE]).toBeUndefined()
|
||||
})
|
||||
|
||||
it("login con Medusa giù: il login riesce comunque", async () => {
|
||||
await createUser("cliente@example.com")
|
||||
const b = new Browser()
|
||||
await b.call("POST", "/shop/cart")
|
||||
t.medusa.down = true
|
||||
expect((await b.call("POST", "/auth/login", { email: "cliente@example.com", password: PASSWORD })).statusCode).toBe(200)
|
||||
await t.app.flushSyncs()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,190 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { consents, showroomBookings } from "../src/db/schema/index.js"
|
||||
import { createFormToken } from "../src/services/form-guard.js"
|
||||
import { SHOWROOM, type ShowroomConfig } from "../src/showroom/config.js"
|
||||
import { bookableSlots, easterSunday, findBookableSlot, slotsOfDay, zonedTime } from "../src/showroom/slots.js"
|
||||
import { buildTestApp, createUserAndLogin, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
let t: TestApp
|
||||
let ipSeq = 0
|
||||
|
||||
/** Token emesso 10 secondi fa (supera il tempo minimo di compilazione). */
|
||||
const formToken = () => createFormToken("c".repeat(32), new Date(Date.now() - 10_000)).token
|
||||
|
||||
const book = (payload: Record<string, unknown>, headers: Record<string, string> = {}) =>
|
||||
t.app.inject({
|
||||
method: "POST",
|
||||
url: "/showroom/bookings",
|
||||
headers,
|
||||
// IP diverso a ogni richiesta: il rate limit della route (5 ogni 15 minuti) non interferisce
|
||||
remoteAddress: `10.0.0.${++ipSeq % 250}`,
|
||||
payload: { name: "Mario Rossi", email: "Mario@Example.com", acceptPrivacy: true, formToken: formToken(), people: 1, ...payload },
|
||||
})
|
||||
|
||||
/** Primo slot prenotabile secondo GET /showroom/slots. */
|
||||
async function firstSlot() {
|
||||
const res = await t.app.inject({ method: "GET", url: "/showroom/slots?days=31" })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const slot = res.json().days[0]?.slots[0]
|
||||
expect(slot).toBeDefined()
|
||||
return slot as { start: string; end: string; available: number }
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.email.clear()
|
||||
})
|
||||
|
||||
describe("slot dello showroom (funzioni pure)", () => {
|
||||
const cfg: ShowroomConfig = { ...SHOWROOM, closedDates: ["2026-10-09"] }
|
||||
|
||||
it("orari di un giorno feriale, del sabato e della domenica (ora legale e solare)", () => {
|
||||
// lunedì 5 ottobre 2026 (ora legale, UTC+2): 9-12 e 14-18 → 3 + 4 slot da un'ora
|
||||
const monday = slotsOfDay(cfg, "2026-10-05")
|
||||
expect(monday).toHaveLength(7)
|
||||
expect(monday[0]!.start.toISOString()).toBe("2026-10-05T07:00:00.000Z")
|
||||
expect(monday[3]!.start.toISOString()).toBe("2026-10-05T12:00:00.000Z")
|
||||
expect(monday[6]!.end.toISOString()).toBe("2026-10-05T16:00:00.000Z")
|
||||
// sabato 7 novembre 2026 (ora solare, UTC+1): solo mattina
|
||||
const saturday = slotsOfDay(cfg, "2026-11-07")
|
||||
expect(saturday.map((s) => s.start.toISOString())).toEqual([
|
||||
"2026-11-07T08:00:00.000Z",
|
||||
"2026-11-07T09:00:00.000Z",
|
||||
"2026-11-07T10:00:00.000Z",
|
||||
])
|
||||
expect(slotsOfDay(cfg, "2026-10-04")).toEqual([]) // domenica
|
||||
})
|
||||
|
||||
it("cambio d'ora: lunedì 26 ottobre 2026 è già in ora solare", () => {
|
||||
expect(zonedTime("2026-10-24", "09:00", "Europe/Rome").toISOString()).toBe("2026-10-24T07:00:00.000Z")
|
||||
expect(zonedTime("2026-10-26", "09:00", "Europe/Rome").toISOString()).toBe("2026-10-26T08:00:00.000Z")
|
||||
expect(zonedTime("2026-03-30", "09:00", "Europe/Rome").toISOString()).toBe("2026-03-30T07:00:00.000Z")
|
||||
})
|
||||
|
||||
it("chiusure: festività fisse, Pasquetta, date straordinarie", () => {
|
||||
expect(easterSunday(2026)).toBe("2026-04-05")
|
||||
expect(easterSunday(2027)).toBe("2027-03-28")
|
||||
expect(slotsOfDay(cfg, "2026-04-06")).toEqual([]) // Pasquetta
|
||||
expect(slotsOfDay(cfg, "2026-12-08")).toEqual([]) // Immacolata (martedì)
|
||||
expect(slotsOfDay(cfg, "2026-10-09")).toEqual([]) // chiusura straordinaria (venerdì)
|
||||
expect(slotsOfDay(cfg, "2026-10-08")).toHaveLength(7)
|
||||
})
|
||||
|
||||
it("preavviso minimo e orizzonte di prenotazione", () => {
|
||||
const now = new Date("2026-10-05T10:30:00Z") // lunedì 12:30 a Roma
|
||||
const days = bookableSlots(cfg, now, "2026-10-05", 3)
|
||||
// lunedì escluso (preavviso 24 h); martedì dalle 14:00 (12:30 + 24 h → primo slot alle 14)
|
||||
expect(days.map((d) => d.date)).toEqual(["2026-10-06", "2026-10-07"])
|
||||
expect(days[0]!.slots[0]!.start.toISOString()).toBe("2026-10-06T12:00:00.000Z")
|
||||
expect(findBookableSlot(cfg, now, new Date("2026-10-06T12:00:00Z"))).not.toBeNull()
|
||||
expect(findBookableSlot(cfg, now, new Date("2026-10-06T09:00:00Z"))).toBeNull() // entro il preavviso
|
||||
expect(findBookableSlot(cfg, now, new Date("2026-10-06T12:30:00Z"))).toBeNull() // non è un inizio di slot
|
||||
expect(findBookableSlot(cfg, now, new Date("2026-10-06T17:00:00Z"))).toBeNull() // dopo la chiusura
|
||||
expect(findBookableSlot(cfg, now, new Date("2026-12-07T08:00:00Z"))).toBeNull() // oltre 60 giorni
|
||||
})
|
||||
})
|
||||
|
||||
describe("prenotazioni showroom", () => {
|
||||
it("prenotazione confermata con email, consensi e notifica allo staff; posti scalati", async () => {
|
||||
await createUserAndLogin(t.app, "admin@iti.local", "admin")
|
||||
t.email.clear()
|
||||
const slot = await firstSlot()
|
||||
expect(slot.available).toBe(SHOWROOM.capacity)
|
||||
|
||||
const res = await book({ requestedAt: slot.start, people: 2, acceptMarketing: true, notes: "Interessati alle pompe di calore" })
|
||||
expect(res.statusCode, res.body).toBe(201)
|
||||
expect(res.json()).toMatchObject({ email: "mario@example.com", people: 2, status: "confirmed", requestedAt: slot.start })
|
||||
|
||||
expect(t.email.last("showroom-booking-confirmed")?.to).toBe("mario@example.com")
|
||||
expect(t.email.last("staff-notification")?.to).toBe("admin@iti.local")
|
||||
const saved = await t.app.db.select().from(consents)
|
||||
expect(saved.map((c) => [c.type, c.source, c.email]).sort()).toEqual([
|
||||
["marketing", "showroom", "mario@example.com"],
|
||||
["privacy", "showroom", "mario@example.com"],
|
||||
])
|
||||
expect((await firstSlot()).available).toBe(SHOWROOM.capacity - 2)
|
||||
})
|
||||
|
||||
it("oltre la capienza la seconda prenotazione è rifiutata (409 SLOT_FULL)", async () => {
|
||||
const slot = await firstSlot()
|
||||
expect((await book({ requestedAt: slot.start, people: 4 })).statusCode).toBe(201)
|
||||
const second = await book({ requestedAt: slot.start, people: 3 })
|
||||
expect(second.statusCode).toBe(409)
|
||||
expect(second.json().error.code).toBe("SLOT_FULL")
|
||||
expect((await book({ requestedAt: slot.start, people: 2 })).statusCode).toBe(201)
|
||||
expect((await book({ requestedAt: slot.start, people: 1 })).statusCode).toBe(409)
|
||||
expect((await firstSlot()).start).toBe(slot.start) // lo slot pieno resta elencato…
|
||||
const res = await t.app.inject({ method: "GET", url: "/showroom/slots?days=31" })
|
||||
expect(res.json().days[0].slots[0].available).toBe(0) // …con 0 posti
|
||||
})
|
||||
|
||||
it("prenotazioni concorrenti sullo stesso slot non superano la capienza", async () => {
|
||||
const slot = await firstSlot()
|
||||
const results = await Promise.all(Array.from({ length: 5 }, () => book({ requestedAt: slot.start, people: 4 })))
|
||||
expect(results.map((r) => r.statusCode).sort()).toEqual([201, 409, 409, 409, 409])
|
||||
expect(await t.app.db.select().from(showroomBookings)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("slot non valido, persone oltre il massimo, consenso privacy mancante → 400", async () => {
|
||||
const slot = await firstSlot()
|
||||
const offSlot = new Date(new Date(slot.start).getTime() + 30 * 60_000).toISOString()
|
||||
const bad = await book({ requestedAt: offSlot })
|
||||
expect(bad.statusCode).toBe(400)
|
||||
expect(bad.json().error.code).toBe("INVALID_SLOT")
|
||||
expect((await book({ requestedAt: new Date(Date.now() + 3_600_000).toISOString() })).json().error.code).toBe("INVALID_SLOT")
|
||||
expect((await book({ requestedAt: slot.start, people: SHOWROOM.maxPeoplePerBooking + 1 })).statusCode).toBe(400)
|
||||
const noPrivacy = await book({ requestedAt: slot.start, acceptPrivacy: false })
|
||||
expect(noPrivacy.statusCode).toBe(400)
|
||||
expect(noPrivacy.json().error.code).toBe("VALIDATION_ERROR")
|
||||
expect((await book({ requestedAt: slot.start, acceptPrivacy: undefined })).statusCode).toBe(400)
|
||||
expect(await t.app.db.select().from(showroomBookings)).toHaveLength(0)
|
||||
expect(await t.app.db.select().from(consents)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("anti-spam: honeypot e invio troppo rapido scartati in silenzio, token falso → 400", async () => {
|
||||
const slot = await firstSlot()
|
||||
const honeypot = await book({ requestedAt: slot.start, website: "http://spam.example" })
|
||||
expect(honeypot.statusCode).toBe(200)
|
||||
const fast = await book({ requestedAt: slot.start, formToken: createFormToken("c".repeat(32)).token })
|
||||
expect(fast.statusCode).toBe(200)
|
||||
expect(await t.app.db.select().from(showroomBookings)).toHaveLength(0)
|
||||
expect(t.email.sent).toHaveLength(0)
|
||||
|
||||
const forged = await book({ requestedAt: slot.start, formToken: createFormToken("x".repeat(32), new Date(Date.now() - 10_000)).token })
|
||||
expect(forged.statusCode).toBe(400)
|
||||
expect(forged.json().error.code).toBe("FORM_EXPIRED")
|
||||
})
|
||||
|
||||
it("admin: elenco, annullamento libera i posti, riattivazione ricontrolla la capienza e conferma via email", async () => {
|
||||
const admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
|
||||
const user = await createUserAndLogin(t.app, "luigi@example.com")
|
||||
const slot = await firstSlot()
|
||||
const first = (await book({ requestedAt: slot.start, people: 4 }, user.headers)).json()
|
||||
expect(first.email).toBe("mario@example.com")
|
||||
|
||||
const list = await t.app.inject({ method: "GET", url: "/admin/showroom/bookings?status=confirmed", headers: admin })
|
||||
expect(list.json()).toMatchObject({ total: 1, items: [{ id: first.id, userId: user.user.id }] })
|
||||
expect((await t.app.inject({ method: "GET", url: "/admin/showroom/bookings", headers: user.headers })).statusCode).toBe(403)
|
||||
|
||||
const patch = (id: string, status: string) =>
|
||||
t.app.inject({ method: "PATCH", url: `/admin/showroom/bookings/${id}`, headers: admin, payload: { status } })
|
||||
expect((await patch(first.id, "cancelled")).json().status).toBe("cancelled")
|
||||
expect((await firstSlot()).available).toBe(SHOWROOM.capacity)
|
||||
|
||||
expect((await book({ requestedAt: slot.start, people: 3 })).statusCode).toBe(201)
|
||||
const reactivate = await patch(first.id, "confirmed")
|
||||
expect(reactivate.statusCode).toBe(409)
|
||||
expect(reactivate.json().error.code).toBe("SLOT_FULL")
|
||||
|
||||
t.email.clear()
|
||||
const second = (await t.app.inject({ method: "GET", url: "/admin/showroom/bookings?status=confirmed", headers: admin })).json().items[0]
|
||||
await patch(second.id, "cancelled")
|
||||
expect((await patch(first.id, "confirmed")).json().status).toBe("confirmed")
|
||||
expect(t.email.last("showroom-booking-confirmed")?.to).toBe("mario@example.com")
|
||||
expect((await patch(first.id, "completed")).json().status).toBe("completed")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,81 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { buildTestApp, createUserAndLogin, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
let t: TestApp
|
||||
let admin: Record<string, string>
|
||||
let mario: Record<string, string>
|
||||
let luigi: Record<string, string>
|
||||
|
||||
const call = (method: "GET" | "POST" | "PATCH", url: string, headers: Record<string, string>, payload?: object) =>
|
||||
t.app.inject({ method, url, headers, ...(payload ? { payload } : {}) })
|
||||
|
||||
const openTicket = async (headers = mario) => {
|
||||
const res = await call("POST", "/me/tickets", headers, { subject: "Addolcitore in allarme", body: "Lampeggia la spia rossa" })
|
||||
expect(res.statusCode, res.body).toBe(201)
|
||||
return res.json()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.email.clear()
|
||||
admin = (await createUserAndLogin(t.app, "admin@iti.local", "admin")).headers
|
||||
mario = (await createUserAndLogin(t.app, "mario@example.com")).headers
|
||||
luigi = (await createUserAndLogin(t.app, "luigi@example.com")).headers
|
||||
})
|
||||
|
||||
describe("ticket", () => {
|
||||
it("apertura con primo messaggio e notifica allo staff", async () => {
|
||||
const tk = await openTicket()
|
||||
expect(tk).toMatchObject({ subject: "Addolcitore in allarme", status: "open", priority: "normal" })
|
||||
expect(tk.messages).toHaveLength(1)
|
||||
expect(tk.messages[0]).toMatchObject({ body: "Lampeggia la spia rossa", fromStaff: false })
|
||||
expect(t.email.last("staff-notification")?.to).toBe("admin@iti.local")
|
||||
// priorità urgent riservata all'assistenza
|
||||
expect((await call("POST", "/me/tickets", mario, { subject: "Urgente!", body: "x", priority: "urgent" })).statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it("un cliente non può leggere né scrivere nei ticket di un altro (404)", async () => {
|
||||
const tk = await openTicket()
|
||||
expect((await call("GET", `/me/tickets/${tk.id}`, luigi)).statusCode).toBe(404)
|
||||
expect((await call("POST", `/me/tickets/${tk.id}/messages`, luigi, { body: "intrusione" })).statusCode).toBe(404)
|
||||
expect((await call("GET", "/me/tickets", luigi)).json().total).toBe(0)
|
||||
expect((await call("GET", "/me/tickets", mario)).json().total).toBe(1)
|
||||
expect((await call("GET", "/admin/tickets", mario)).statusCode).toBe(403)
|
||||
})
|
||||
|
||||
it("risposta admin: email al cliente, stato in attesa del cliente; la risposta del cliente riapre", async () => {
|
||||
const tk = await openTicket()
|
||||
t.email.clear()
|
||||
const reply = await call("POST", `/admin/tickets/${tk.id}/messages`, admin, { body: "Il tecnico passa giovedì" })
|
||||
expect(reply.statusCode).toBe(201)
|
||||
expect(reply.json()).toMatchObject({ status: "waiting_customer", customer: { email: "mario@example.com" } })
|
||||
expect(reply.json().messages.map((m: { fromStaff: boolean }) => m.fromStaff)).toEqual([false, true])
|
||||
|
||||
const mail = t.email.last("ticket-reply")
|
||||
expect(mail?.to).toBe("mario@example.com")
|
||||
expect(mail?.text).toContain("Il tecnico passa giovedì")
|
||||
expect(mail?.text).toContain(`/assistenza/${tk.id}`)
|
||||
|
||||
const back = await call("POST", `/me/tickets/${tk.id}/messages`, mario, { body: "Va bene, grazie" })
|
||||
expect(back.json().status).toBe("open")
|
||||
expect(back.json().messages).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("admin: elenco con filtri, cambio stato e priorità; chiuso e riaperto dal cliente", async () => {
|
||||
const tk = await openTicket()
|
||||
await openTicket(luigi)
|
||||
expect((await call("GET", "/admin/tickets?q=luigi", admin)).json().total).toBe(1)
|
||||
|
||||
const patched = await call("PATCH", `/admin/tickets/${tk.id}`, admin, { priority: "urgent" })
|
||||
expect(patched.json()).toMatchObject({ priority: "urgent", status: "open" })
|
||||
await call("PATCH", `/admin/tickets/${tk.id}`, admin, { status: "closed" })
|
||||
expect((await call("GET", "/admin/tickets?status=closed", admin)).json().items.map((x: { id: string }) => x.id)).toEqual([tk.id])
|
||||
|
||||
const reopened = await call("POST", `/me/tickets/${tk.id}/messages`, mario, { body: "Il problema si è ripresentato" })
|
||||
expect(reopened.json().status).toBe("open")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,208 @@
|
||||
import { createHmac, randomUUID } from "node:crypto"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
|
||||
import { users, webhookEvents } from "../src/db/schema/index.js"
|
||||
import { hashPassword } from "../src/services/password.js"
|
||||
import { buildTestApp, type TestApp, truncateAll } from "./helpers.js"
|
||||
|
||||
const SECRET = "w".repeat(32) // WEBHOOK_SECRET di testConfig
|
||||
|
||||
let t: TestApp
|
||||
|
||||
const sign = (body: string, secret = SECRET) => createHmac("sha256", secret).update(body).digest("hex")
|
||||
|
||||
function payload(event: string, data: Record<string, unknown>, id: string = randomUUID()) {
|
||||
return JSON.stringify({ id, event, occurred_at: new Date().toISOString(), data })
|
||||
}
|
||||
|
||||
const deliver = (body: string, signature: string | undefined = sign(body), app = t.app) =>
|
||||
app.inject({
|
||||
method: "POST",
|
||||
url: "/webhooks/medusa",
|
||||
headers: { "content-type": "application/json", ...(signature ? { "x-signature": signature } : {}) },
|
||||
payload: body,
|
||||
})
|
||||
|
||||
const ORDER = {
|
||||
id: "order_1",
|
||||
display_id: 7,
|
||||
status: "pending",
|
||||
email: "cliente@example.com",
|
||||
customer_id: null,
|
||||
currency_code: "eur",
|
||||
created_at: "2026-10-02T10:00:00.000Z",
|
||||
item_total: 1190,
|
||||
shipping_total: 15,
|
||||
tax_total: 217.29,
|
||||
discount_total: 0,
|
||||
total: 1205,
|
||||
items: [{ title: "Standard", product_title: "Addolcitore", variant_title: "Standard", quantity: 1, unit_price: 1190, total: 1190 }],
|
||||
shipping_address: { first_name: "Mario", last_name: "Rossi", address_1: "Via Roma 1", city: "Milano", postal_code: "20100", province: "MI" },
|
||||
shipping_methods: [{ name: "Spedizione standard" }],
|
||||
fulfillments: [{ id: "ful_1", labels: [{ tracking_number: "TRK123", tracking_url: "https://track.example/TRK123" }] }],
|
||||
}
|
||||
|
||||
const eventRow = async (id: string) => (await t.app.db.select().from(webhookEvents).where(eq(webhookEvents.id, id)))[0]
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await buildTestApp()
|
||||
})
|
||||
afterAll(() => t.app.close())
|
||||
beforeEach(async () => {
|
||||
await truncateAll()
|
||||
t.medusa.reset()
|
||||
t.email.clear()
|
||||
t.medusa.orderDetails.set(ORDER.id, ORDER)
|
||||
await t.app.db
|
||||
.insert(users)
|
||||
.values({ email: "admin@iti.local", passwordHash: await hashPassword("x".repeat(12)), role: "admin", firstName: "A", lastName: "D" })
|
||||
})
|
||||
|
||||
describe("firma", () => {
|
||||
it("firma valida → elaborato, evento salvato", async () => {
|
||||
const id = randomUUID()
|
||||
const res = await deliver(payload("order.placed", { id: ORDER.id }, id))
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ status: "processed" })
|
||||
const row = await eventRow(id)
|
||||
expect(row).toMatchObject({ event: "order.placed", error: null })
|
||||
expect(row?.processedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it("firma errata, assente, con altra chiave o body alterato → 401 e niente in DB", async () => {
|
||||
const body = payload("order.placed", { id: ORDER.id })
|
||||
for (const res of [
|
||||
await deliver(body, "0".repeat(64)),
|
||||
await deliver(body, ""), // header assente
|
||||
await deliver(body, "non-esadecimale"),
|
||||
await deliver(body, sign(body, "z".repeat(32))),
|
||||
await deliver(body.replace("order_1", "order_2"), sign(body)),
|
||||
]) {
|
||||
expect(res.statusCode).toBe(401)
|
||||
expect(res.json().error.code).toBe("INVALID_SIGNATURE")
|
||||
}
|
||||
expect(await t.app.db.select().from(webhookEvents)).toHaveLength(0)
|
||||
expect(t.email.sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("firma valida su JSON non valido → 400; payload senza id uuid → 400", async () => {
|
||||
expect((await deliver("{non json")).statusCode).toBe(400)
|
||||
const noId = JSON.stringify({ id: "x", event: "order.placed", occurred_at: "", data: {} })
|
||||
expect((await deliver(noId)).json().error.code).toBe("VALIDATION_ERROR")
|
||||
})
|
||||
})
|
||||
|
||||
describe("idempotenza", () => {
|
||||
it("stesso evento due volte → elaborato una sola volta", async () => {
|
||||
const body = payload("order.placed", { id: ORDER.id })
|
||||
expect((await deliver(body)).json().status).toBe("processed")
|
||||
expect((await deliver(body)).json().status).toBe("duplicate")
|
||||
expect(t.email.sent.filter((m) => m.tag === "order-confirmation")).toHaveLength(1)
|
||||
expect(await t.app.db.select().from(webhookEvents)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("consegne concorrenti dello stesso evento → una sola elaborazione", async () => {
|
||||
const body = payload("order.placed", { id: ORDER.id })
|
||||
const results = await Promise.all([deliver(body), deliver(body), deliver(body)])
|
||||
expect(results.map((r) => r.statusCode)).toEqual([200, 200, 200])
|
||||
expect(results.map((r) => r.json().status).sort()).toEqual(["duplicate", "duplicate", "processed"])
|
||||
expect(t.email.sent.filter((m) => m.tag === "order-confirmation")).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("errore di elaborazione → 500, errore salvato; il retry di Medusa lo rielabora", async () => {
|
||||
const id = randomUUID()
|
||||
const body = payload("order.placed", { id: ORDER.id }, id)
|
||||
t.medusa.down = true
|
||||
const failed = await deliver(body)
|
||||
expect(failed.statusCode).toBe(500)
|
||||
expect(failed.json().error.code).toBe("WEBHOOK_PROCESSING_FAILED")
|
||||
const row = await eventRow(id)
|
||||
expect(row?.processedAt).toBeNull()
|
||||
expect(row?.error).toContain("Medusa non raggiungibile")
|
||||
|
||||
t.medusa.down = false
|
||||
expect((await deliver(body)).json().status).toBe("processed")
|
||||
expect(await eventRow(id)).toMatchObject({ error: null })
|
||||
expect(t.email.sent.filter((m) => m.tag === "order-confirmation")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("dispatcher", () => {
|
||||
it("order.placed → conferma al cliente con righe e totali, notifica agli admin", async () => {
|
||||
await deliver(payload("order.placed", { id: ORDER.id }))
|
||||
const mail = t.email.last("order-confirmation")
|
||||
expect(mail?.to).toBe("cliente@example.com")
|
||||
expect(mail?.subject).toContain("n. 7")
|
||||
expect(mail?.text).toContain("1 x Addolcitore - Standard")
|
||||
expect(mail?.text).toMatch(/Totale \(di cui IVA 217,29\s€\): 1\.205,00\s€/)
|
||||
expect(mail?.html).toContain("1.205,00")
|
||||
expect(mail?.text).toContain("Via Roma 1")
|
||||
expect(t.email.last("order-placed-admin")?.to).toBe("admin@iti.local")
|
||||
})
|
||||
|
||||
it("ADMIN_NOTIFY_EMAILS sostituisce gli admin del DB", async () => {
|
||||
const other = await buildTestApp({ ADMIN_NOTIFY_EMAILS: "Ordini@ITI.it, magazzino@iti.it" })
|
||||
other.medusa.orderDetails.set(ORDER.id, ORDER)
|
||||
try {
|
||||
const body = payload("order.placed", { id: ORDER.id })
|
||||
expect((await deliver(body, sign(body), other.app)).statusCode).toBe(200)
|
||||
} finally {
|
||||
await other.app.close()
|
||||
}
|
||||
expect(other.email.sent.filter((m) => m.tag === "order-placed-admin").map((m) => m.to)).toEqual([
|
||||
"ordini@iti.it",
|
||||
"magazzino@iti.it",
|
||||
])
|
||||
})
|
||||
|
||||
it("order.canceled → email di annullamento", async () => {
|
||||
await deliver(payload("order.canceled", { id: ORDER.id }))
|
||||
expect(t.email.last("order-canceled")).toMatchObject({ to: "cliente@example.com" })
|
||||
})
|
||||
|
||||
it("order.fulfillment_created → email di spedizione con tracking; no_notification → nessuna email", async () => {
|
||||
await deliver(payload("order.fulfillment_created", { order_id: ORDER.id, fulfillment_id: "ful_1", no_notification: false }))
|
||||
expect(t.email.last("order-shipped")?.text).toContain("TRK123")
|
||||
|
||||
t.email.clear()
|
||||
const res = await deliver(payload("order.fulfillment_created", { order_id: ORDER.id, fulfillment_id: "ful_1", no_notification: true }))
|
||||
expect(res.json().status).toBe("processed")
|
||||
expect(t.email.sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("evento non gestito → ignored, ma registrato come elaborato", async () => {
|
||||
const id = randomUUID()
|
||||
expect((await deliver(payload("order.updated", { id: ORDER.id }, id))).json().status).toBe("ignored")
|
||||
expect((await eventRow(id))?.processedAt).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it("ordine inesistente in Medusa → 500 (Medusa ritenta)", async () => {
|
||||
expect((await deliver(payload("order.placed", { id: "order_x" }))).statusCode).toBe(500)
|
||||
})
|
||||
})
|
||||
|
||||
describe("esclusioni", () => {
|
||||
it("niente rate limit globale né header CORS sull'endpoint", async () => {
|
||||
const limited = await buildTestApp({ RATE_LIMIT_MAX: "2" })
|
||||
limited.medusa.orderDetails.set(ORDER.id, ORDER)
|
||||
try {
|
||||
const body = payload("order.placed", { id: ORDER.id })
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const res = await limited.app.inject({
|
||||
method: "POST",
|
||||
url: "/webhooks/medusa",
|
||||
headers: { "content-type": "application/json", "x-signature": sign(body), origin: "http://localhost:4200" },
|
||||
payload: body,
|
||||
})
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers["access-control-allow-origin"]).toBeUndefined()
|
||||
}
|
||||
// Controllo: le altre route sono limitate
|
||||
const statuses = []
|
||||
for (let i = 0; i < 3; i++) statuses.push((await limited.app.inject({ method: "GET", url: "/shop/categories" })).statusCode)
|
||||
expect(statuses).toContain(429)
|
||||
} finally {
|
||||
await limited.app.close()
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user