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
399 lines
17 KiB
TypeScript
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" } })
|