import type { FetchFn } from "../src/services/medusa-client.js" type Customer = { id: string; email: string; first_name: string; last_name: string; company_name: string | null; has_account: boolean } type Order = { id: string; email: string; customer_id: string } type Cart = { id: string email: string | null customer_id: string | null items: { id: string; variant_id: string; quantity: number }[] shipping_address: Record | null billing_address: Record | null shipping_option_id: string | null completed_at: string | null payment_session: boolean metadata: Record | null } /** Catalogo finto: un prodotto con una variante. Importi in euro IVA inclusa, come Medusa. */ export const MOCK_PRODUCT = { handle: "pompa-di-calore", title: "Pompa di calore" } export const MOCK_VARIANT = { id: "variant_1", price: 5490, installerPrice: 4650, stock: 5 } export const MOCK_SHIPPING = { id: "so_1", name: "Spedizione standard", amount: 15 } const REGION = "reg_it" /** * Finto Medusa per i test, con stato in memoria: route /internal/* usate da apps/api e la parte di * Store API usata dal proxy shop (regione, catalogo, carrello, spedizione, pagamento, completamento). * `down = true` simula Medusa spento (errore di rete, come fetch su una porta chiusa). */ export class MedusaMock { down = false /** Se impostato, ogni richiesta risponde con questo status (es. 500). */ failStatus: number | undefined /** Se false, il completamento risponde type "cart" (pagamento non autorizzato). */ paymentAuthorized = true readonly customers = new Map() readonly orders: Order[] = [] readonly installerGroup = new Set() readonly carts = new Map() /** Ordini restituiti da GET /internal/customers/:id/orders, per customer. */ readonly customerOrderList = new Map[]>() /** Dettagli ordine restituiti da GET /internal/orders/:id (per i webhook). */ readonly orderDetails = new Map>() readonly calls: { method: string; path: string; headers: Record; body: unknown }[] = [] private seq = 0 constructor(readonly internalSecret: string) {} reset() { this.down = false this.failStatus = undefined this.paymentAuthorized = true this.customers.clear() this.orders.length = 0 this.installerGroup.clear() this.carts.clear() this.orderDetails.clear() this.customerOrderList.clear() this.calls.length = 0 } /** Ordine guest (fatto senza account) con l'email indicata. */ addGuestOrder(email: string): string { const guest: Customer = { id: `cus_guest_${++this.seq}`, email, first_name: "G", last_name: "G", company_name: null, has_account: false } this.customers.set(guest.id, guest) const order = { id: `order_${++this.seq}`, email, customer_id: guest.id } this.orders.push(order) return order.id } ordersOf(customerId: string) { return this.orders.filter((o) => o.customer_id === customerId).map((o) => o.id) } accountByEmail(email: string) { return [...this.customers.values()].find((c) => c.has_account && c.email === email) } calledPaths() { return this.calls.map((c) => `${c.method} ${c.path}`) } readonly fetch: FetchFn = async (input, init) => { const url = new URL(input instanceof Request ? input.url : String(input)) const method = init?.method ?? "GET" const headers = Object.fromEntries(Object.entries((init?.headers ?? {}) as Record)) const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined this.calls.push({ method, path: url.pathname, headers, body }) if (this.down) throw new TypeError("fetch failed") if (this.failStatus) return json(this.failStatus, { type: "unexpected_state", message: "errore simulato" }) if (url.pathname.startsWith("/internal")) { if (headers["x-internal-secret"] !== this.internalSecret) return json(401, { type: "unauthorized" }) return this.internal(method, url.pathname, body, url.searchParams) } if (url.pathname.startsWith("/store")) { if (!headers["x-publishable-api-key"]) return json(400, { type: "not_allowed", message: "publishable key mancante" }) return this.store(method, url, body) } return json(404, { type: "not_found", message: "route non simulata" }) } // --- /internal/* --- private internal(method: string, path: string, body: any, query: URLSearchParams = new URLSearchParams()): Response { const customerOrders = path.match(/^\/internal\/customers\/([^/]+)\/orders$/) if (method === "GET" && customerOrders?.[1]) { let all = this.customerOrderList.get(decodeURIComponent(customerOrders[1])) ?? [] const displayId = query.get("display_id") if (displayId) all = all.filter((o) => String(o["display_id"]) === displayId) return json(200, { orders: all, count: all.length, offset: 0, limit: 20 }) } const orderDetail = path.match(/^\/internal\/orders\/([^/]+)$/) if (method === "GET" && orderDetail?.[1]) { const order = this.orderDetails.get(decodeURIComponent(orderDetail[1])) return order ? json(200, { order }) : json(404, { type: "not_found", message: "Order not found" }) } if (method === "POST" && path === "/internal/customers") { const existing = this.accountByEmail(body.email) if (existing) return json(200, { customer: existing, created: false }) const customer: Customer = { id: `cus_${++this.seq}`, has_account: true, company_name: null, ...body } this.customers.set(customer.id, customer) return json(201, { customer, created: true }) } const group = path.match(/^\/internal\/customers\/([^/]+)\/groups$/) if (method === "POST" && group?.[1]) { const id = decodeURIComponent(group[1]) if (!this.customers.has(id)) return json(404, { type: "not_found", message: "customer non trovato" }) if (body.action === "add") this.installerGroup.add(id) else this.installerGroup.delete(id) return json(200, { customer_id: id, group: { id: "cusgroup_1", name: "installatori" }, member: body.action === "add" }) } const cartCustomer = path.match(/^\/internal\/carts\/([^/]+)\/customer$/) if (method === "POST" && cartCustomer?.[1]) { const cart = this.carts.get(cartCustomer[1]) const customer = this.customers.get(body.customer_id) if (!cart || !customer) return json(404, { type: "not_found", message: "non trovato" }) cart.customer_id = customer.id cart.email = customer.email return json(200, { cart: { id: cart.id } }) } const cartGuest = path.match(/^\/internal\/carts\/([^/]+)\/guest$/) if (method === "POST" && cartGuest?.[1]) { const cart = this.carts.get(cartGuest[1]) if (!cart) return json(404, { type: "not_found", message: "non trovato" }) if (cart.customer_id && this.customers.get(cart.customer_id)?.has_account) { return json(400, { type: "not_allowed", message: "carrello di un account" }) } let guest = [...this.customers.values()].find((c) => !c.has_account && c.email === body.email) if (!guest) { guest = { id: `cus_guest_${++this.seq}`, email: body.email, first_name: "", last_name: "", company_name: null, has_account: false } this.customers.set(guest.id, guest) } cart.customer_id = guest.id cart.email = guest.email return json(200, { cart_id: cart.id, email: guest.email }) } if (method === "POST" && path === "/internal/prices") { const installer = this.installerGroup.has(body.customer_id) const prices: Record = {} for (const id of body.variant_ids as string[]) { if (id !== MOCK_VARIANT.id) continue // Come Medusa con un listino "override": original_amount = prezzo del listino prices[id] = installer ? { amount: MOCK_VARIANT.installerPrice, original_amount: MOCK_VARIANT.installerPrice, price_list_type: "override" } : { amount: MOCK_VARIANT.price, original_amount: MOCK_VARIANT.price, price_list_type: null } } return json(200, { prices }) } if (method === "POST" && path === "/internal/orders/claim") { const customer = this.customers.get(body.customer_id) if (!customer) return json(404, { type: "not_found", message: "customer non trovato" }) if (!customer.has_account || customer.email !== body.email) return json(400, { type: "invalid_data", message: "email diversa" }) const claimed: string[] = [] for (const order of this.orders) { const owner = this.customers.get(order.customer_id) if (order.email === body.email && !owner?.has_account) { order.customer_id = customer.id claimed.push(order.id) } } return json(200, { claimed, failed: [] }) } return json(404, { type: "not_found", message: "route non simulata" }) } // --- Store API --- private unitPrice(cart: Cart) { return cart.customer_id && this.installerGroup.has(cart.customer_id) ? MOCK_VARIANT.installerPrice : MOCK_VARIANT.price } private cartBody(cart: Cart) { const price = this.unitPrice(cart) const items = cart.items.map((i) => ({ id: i.id, variant_id: i.variant_id, product_handle: MOCK_PRODUCT.handle, product_title: MOCK_PRODUCT.title, title: "Standard", variant_title: "Standard", variant_sku: "SKU-1", thumbnail: null, quantity: i.quantity, unit_price: price, total: price * i.quantity, })) const itemTotal = items.reduce((n, i) => n + i.total, 0) const shipping = cart.shipping_option_id ? MOCK_SHIPPING.amount : 0 const total = itemTotal + shipping return { id: cart.id, email: cart.email, customer_id: cart.customer_id, completed_at: cart.completed_at, region_id: REGION, item_total: itemTotal, shipping_total: shipping, discount_total: 0, tax_total: Math.round(((total * 22) / 122) * 100) / 100, total, items, shipping_address: cart.shipping_address, billing_address: cart.billing_address, metadata: cart.metadata, shipping_methods: cart.shipping_option_id ? [{ shipping_option_id: cart.shipping_option_id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount }] : [], } } private store(method: string, url: URL, body: any): Response { const path = url.pathname if (method === "GET" && path === "/store/regions") { return json(200, { regions: [{ id: REGION, countries: [{ iso_2: "it" }] }] }) } if (method === "GET" && path === "/store/product-categories") { return json(200, { product_categories: [{ id: "pcat_1", handle: "energia", name: "Energia", description: "", parent_category_id: null }], }) } if (method === "GET" && path === "/store/products") { const handle = url.searchParams.get("handle") const category = url.searchParams.get("category_id[]") const product = { id: "prod_1", ...MOCK_PRODUCT, subtitle: null, description: "Descrizione", thumbnail: null, images: [], categories: [{ handle: "energia", name: "Energia" }], variants: [ { id: MOCK_VARIANT.id, title: "Standard", sku: "SKU-1", manage_inventory: true, allow_backorder: false, inventory_quantity: MOCK_VARIANT.stock, calculated_price: { calculated_amount: MOCK_VARIANT.price, original_amount: MOCK_VARIANT.price }, options: [{ value: "Standard", option: { title: "Modello" } }], }, ], } const match = (!handle || handle === MOCK_PRODUCT.handle) && (!category || category === "pcat_1") const products = match ? [product] : [] return json(200, { products, count: products.length, offset: 0, limit: 12 }) } if (method === "POST" && path === "/store/carts") { const cart: Cart = { id: `cart_${++this.seq}`, email: null, customer_id: null, items: [], shipping_address: null, billing_address: null, shipping_option_id: null, completed_at: null, payment_session: false, metadata: null, } this.carts.set(cart.id, cart) return json(200, { cart: this.cartBody(cart) }) } const m = path.match(/^\/store\/carts\/([^/]+)(\/.*)?$/) if (m?.[1]) { const cart = this.carts.get(m[1]) if (!cart) return json(404, { type: "not_found", message: `Cart with id '${m[1]}' not found` }) return this.cartRoute(cart, method, m[2] ?? "", body) } if (method === "GET" && path === "/store/shipping-options") { return json(200, { shipping_options: [{ id: MOCK_SHIPPING.id, name: MOCK_SHIPPING.name, amount: MOCK_SHIPPING.amount, insufficient_inventory: false }], }) } if (method === "POST" && path === "/store/payment-collections") { const cart = this.carts.get(body.cart_id) if (!cart) return json(404, { type: "not_found", message: "cart" }) return json(200, { payment_collection: { id: `paycol_${cart.id}`, amount: this.cartBody(cart).total, payment_sessions: [] } }) } const ps = path.match(/^\/store\/payment-collections\/paycol_([^/]+)\/payment-sessions$/) if (method === "POST" && ps?.[1]) { const cart = this.carts.get(ps[1]) if (!cart) return json(404, { type: "not_found", message: "collection" }) cart.payment_session = true return json(200, { payment_collection: { id: `paycol_${cart.id}`, amount: this.cartBody(cart).total, payment_sessions: [{ provider_id: body.provider_id, data: { client_secret: `pi_secret_${cart.id}` } }], }, }) } return json(404, { type: "not_found", message: "route non simulata" }) } private cartRoute(cart: Cart, method: string, rest: string, body: any): Response { if (method === "GET" && rest === "") return json(200, { cart: this.cartBody(cart) }) if (method === "POST" && rest === "") { if (body.shipping_address) cart.shipping_address = body.shipping_address if (body.billing_address) cart.billing_address = body.billing_address // Come Medusa: i metadata inviati si fondono con quelli esistenti if (body.metadata) cart.metadata = { ...cart.metadata, ...body.metadata } return json(200, { cart: this.cartBody(cart) }) } if (method === "POST" && rest === "/line-items") { if (body.variant_id !== MOCK_VARIANT.id) return json(404, { type: "not_found", message: "Variant not found" }) const existing = cart.items.find((i) => i.variant_id === body.variant_id) const quantity = (existing?.quantity ?? 0) + body.quantity if (quantity > MOCK_VARIANT.stock) { return json(400, { type: "not_allowed", message: "Some variant does not have the required inventory" }) } if (existing) existing.quantity = quantity else cart.items.push({ id: `cali_${++this.seq}`, variant_id: body.variant_id, quantity: body.quantity }) return json(200, { cart: this.cartBody(cart) }) } const li = rest.match(/^\/line-items\/([^/]+)$/) if (li?.[1]) { const item = cart.items.find((i) => i.id === li[1]) if (!item) return json(404, { type: "not_found", message: "Line item not found" }) if (method === "DELETE") { cart.items = cart.items.filter((i) => i !== item) return json(200, { id: item.id, deleted: true, parent: this.cartBody(cart) }) } item.quantity = body.quantity return json(200, { cart: this.cartBody(cart) }) } if (method === "POST" && rest === "/shipping-methods") { if (body.option_id !== MOCK_SHIPPING.id) return json(400, { type: "invalid_data", message: "Shipping option not valid" }) cart.shipping_option_id = body.option_id return json(200, { cart: this.cartBody(cart) }) } if (method === "POST" && rest === "/complete") { if (!cart.payment_session) return json(400, { type: "invalid_data", message: "Payment sessions are required to complete cart" }) if (!this.paymentAuthorized) { return json(200, { type: "cart", cart: this.cartBody(cart), error: { message: "Payment authorization failed", type: "payment_authorization_error" }, }) } cart.completed_at = new Date().toISOString() const b = this.cartBody(cart) return json(200, { type: "order", order: { id: `order_${++this.seq}`, display_id: 42, email: cart.email, created_at: cart.completed_at, item_total: b.item_total, shipping_total: b.shipping_total, tax_total: b.tax_total, total: b.total, items: b.items, metadata: cart.metadata, }, }) } return json(404, { type: "not_found", message: "route non simulata" }) } } const json = (status: number, data: unknown) => new Response(JSON.stringify(data), { status, headers: { "content-type": "application/json" } })