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 let mario: { user: { id: string; email: string }; headers: Record } let luigi: { user: { id: string; email: string }; headers: Record } const call = (method: "GET" | "POST" | "PATCH", url: string, headers: Record, payload?: object) => t.app.inject({ method, url, headers, ...(payload ? { payload } : {}) }) async function newQuote(userId: string, extra: Record = {}) { 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 = {}) { 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) }) })