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,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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user