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