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

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

240 lines
12 KiB
TypeScript

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)
})
})