Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
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:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+324
View File
@@ -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()
})
})