Files
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
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
2026-10-20 09:30:48 +02:00

399 lines
17 KiB
TypeScript

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<string, string> | null
billing_address: Record<string, string> | null
shipping_option_id: string | null
completed_at: string | null
payment_session: boolean
metadata: Record<string, unknown> | 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<string, Customer>()
readonly orders: Order[] = []
readonly installerGroup = new Set<string>()
readonly carts = new Map<string, Cart>()
/** Ordini restituiti da GET /internal/customers/:id/orders, per customer. */
readonly customerOrderList = new Map<string, Record<string, unknown>[]>()
/** Dettagli ordine restituiti da GET /internal/orders/:id (per i webhook). */
readonly orderDetails = new Map<string, Record<string, unknown>>()
readonly calls: { method: string; path: string; headers: Record<string, string>; 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<string, string>))
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<string, unknown> = {}
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" } })