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 = {} token: string | undefined async call(method: "GET" | "POST" | "PATCH" | "DELETE", url: string, payload?: Record) { 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() }) })