/** * Mock Medusa Store API. * * Enough of the read endpoints — plus an in-memory cart — for the storefront * to run end to end without a Medusa instance. Checkout completion and * payments are deliberately not emulated; run the real backend for those. * * node mock-backend/server.js (or: yarn mock-backend) */ const http = require("node:http") const { randomUUID } = require("node:crypto") const { parse } = require("node:url") const qs = require("qs") const { REGION, CATEGORIES, COLLECTIONS, PRODUCTS } = require("./fixtures") const PORT = Number(process.env.MOCK_PORT || 9000) // --- helpers ---------------------------------------------------------------- function sendJson(res, status, data) { res.writeHead(status, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Headers": "*", "Access-Control-Allow-Methods": "GET,POST,PUT,PATCH,DELETE,OPTIONS", }) res.end(JSON.stringify(data)) } function toArray(value) { if (value === undefined || value === null) return undefined return Array.isArray(value) ? value : [value] } function readBody(req) { return new Promise((resolve) => { let raw = "" req.on("data", (chunk) => { raw += chunk }) req.on("end", () => { if (!raw) return resolve({}) try { resolve(JSON.parse(raw)) } catch { resolve({}) } }) }) } function cheapestAmount(product) { const amounts = (product.variants ?? []) .map((v) => v.calculated_price?.calculated_amount) .filter((a) => typeof a === "number") return amounts.length ? Math.min(...amounts) : 0 } function sortProducts(products, order) { if (!order) return products const desc = order.startsWith("-") const field = desc ? order.slice(1) : order const sorted = [...products] if (field === "price" || field === "variants.calculated_price") { sorted.sort((a, b) => cheapestAmount(a) - cheapestAmount(b)) } else if (field === "title") { sorted.sort((a, b) => a.title.localeCompare(b.title)) } else if (field === "created_at") { sorted.sort((a, b) => String(a.created_at).localeCompare(String(b.created_at))) } else { return products } return desc ? sorted.reverse() : sorted } function paginate(items, query) { const limit = Number(query.limit) || 100 const offset = Number(query.offset) || 0 return { items: items.slice(offset, offset + limit), count: items.length } } /** Descendant ids so filtering by a parent category also returns its children. */ function categoryIdsWithDescendants(ids) { const result = new Set(ids) let added = true while (added) { added = false CATEGORIES.forEach((cat) => { const parentId = cat.parent_category?.id if (parentId && result.has(parentId) && !result.has(cat.id)) { result.add(cat.id) added = true } }) } return result } // --- in-memory carts -------------------------------------------------------- const carts = new Map() function findVariant(variantId) { for (const product of PRODUCTS) { const variant = product.variants.find((v) => v.id === variantId) if (variant) return { product, variant } } return null } function recalculate(cart) { cart.items.forEach((item) => { item.subtotal = item.unit_price * item.quantity item.total = item.subtotal item.original_total = item.subtotal }) const subtotal = cart.items.reduce((acc, item) => acc + item.total, 0) cart.subtotal = subtotal cart.discount_total = 0 cart.shipping_total = 0 cart.tax_total = 0 cart.item_total = subtotal cart.original_total = subtotal cart.total = subtotal return cart } function createCart() { const cart = { id: `cart_${randomUUID()}`, region_id: REGION.id, region: REGION, currency_code: REGION.currency_code, email: null, customer_id: null, shipping_address: null, billing_address: null, shipping_methods: [], payment_collection: null, promotions: [], items: [], metadata: {}, } carts.set(cart.id, recalculate(cart)) return cart } function addLineItem(cart, { variant_id, quantity = 1, metadata }) { const found = findVariant(variant_id) if (!found) return null const { product, variant } = found const existing = cart.items.find((i) => i.variant_id === variant_id) if (existing) { existing.quantity += quantity } else { cart.items.push({ id: `item_${randomUUID()}`, cart_id: cart.id, title: product.title, product_id: product.id, product_title: product.title, product_handle: product.handle, thumbnail: product.thumbnail, variant_id: variant.id, variant: { ...variant, product }, quantity, unit_price: variant.calculated_price.calculated_amount, created_at: new Date().toISOString(), metadata: metadata ?? {}, }) } return recalculate(cart) } // --- routing ---------------------------------------------------------------- const server = http.createServer(async (req, res) => { if (req.method === "OPTIONS") { return sendJson(res, 200, {}) } const { pathname, query: rawQuery } = parse(req.url) const query = qs.parse(rawQuery) const body = req.method === "GET" ? {} : await readBody(req) console.log(`[mock-backend] ${req.method} ${req.url}`) // Regions if (pathname === "/store/regions") { return sendJson(res, 200, { regions: [REGION], count: 1 }) } if (pathname.startsWith("/store/regions/")) { const id = pathname.split("/").pop() if (id !== REGION.id) { return sendJson(res, 404, { message: "Region not found" }) } return sendJson(res, 200, { region: REGION }) } // Collections if (pathname === "/store/collections") { let collections = COLLECTIONS if (query.handle) { collections = collections.filter((c) => c.handle === query.handle) } const withProducts = collections.map((c) => ({ ...c, products: PRODUCTS.filter((p) => p.collection_id === c.id), })) return sendJson(res, 200, { collections: withProducts, count: withProducts.length, }) } if (pathname.startsWith("/store/collections/")) { const id = pathname.split("/").pop() const collection = COLLECTIONS.find((c) => c.id === id) if (!collection) { return sendJson(res, 404, { message: "Collection not found" }) } return sendJson(res, 200, { collection }) } // Categories if (pathname === "/store/product-categories") { let categories = CATEGORIES if (query.handle) { categories = categories.filter((c) => c.handle === query.handle) } const withProducts = categories.map((c) => ({ ...c, products: PRODUCTS.filter((p) => p.categories.some((pc) => pc.id === c.id) ), })) return sendJson(res, 200, { product_categories: withProducts, count: withProducts.length, }) } // Products if (pathname === "/store/products") { let products = PRODUCTS if (query.handle) { products = products.filter((p) => p.handle === query.handle) } if (query.q) { const term = String(query.q).toLowerCase() products = products.filter( (p) => p.title.toLowerCase().includes(term) || (p.description ?? "").toLowerCase().includes(term) || (p.material ?? "").toLowerCase().includes(term) ) } const collectionIds = toArray(query.collection_id) if (collectionIds) { products = products.filter((p) => collectionIds.includes(p.collection_id)) } const categoryIds = toArray(query.category_id) if (categoryIds) { const allowed = categoryIdsWithDescendants(categoryIds) products = products.filter((p) => p.categories.some((c) => allowed.has(c.id)) ) } const ids = toArray(query.id) if (ids) { products = products.filter((p) => ids.includes(p.id)) } products = sortProducts(products, query.order) const { items, count } = paginate(products, query) return sendJson(res, 200, { products: items, count }) } if (pathname.startsWith("/store/product-variants/")) { const id = pathname.split("/").pop() const found = findVariant(id) if (!found) { return sendJson(res, 404, { message: "Variant not found" }) } return sendJson(res, 200, { variant: found.variant }) } // Carts if (pathname === "/store/carts" && req.method === "POST") { return sendJson(res, 200, { cart: createCart() }) } if (pathname.startsWith("/store/carts/")) { const segments = pathname.split("/").filter(Boolean) // store, carts, :id, ... const cartId = segments[2] const cart = carts.get(cartId) if (!cart) { return sendJson(res, 404, { message: "Cart not found" }) } // /store/carts/:id if (segments.length === 3) { if (req.method === "POST") { Object.assign(cart, body) return sendJson(res, 200, { cart: recalculate(cart) }) } return sendJson(res, 200, { cart: recalculate(cart) }) } // /store/carts/:id/line-items[/:lineId] if (segments[3] === "line-items") { const lineId = segments[4] if (!lineId && req.method === "POST") { const updated = addLineItem(cart, body) if (!updated) { return sendJson(res, 400, { message: "Variant not found" }) } return sendJson(res, 200, { cart: updated }) } const item = cart.items.find((i) => i.id === lineId) if (!item) { return sendJson(res, 404, { message: "Line item not found" }) } if (req.method === "DELETE") { cart.items = cart.items.filter((i) => i.id !== lineId) return sendJson(res, 200, { id: lineId, object: "line-item", deleted: true, parent: recalculate(cart), }) } if (req.method === "POST") { if (typeof body.quantity === "number") { item.quantity = body.quantity } return sendJson(res, 200, { cart: recalculate(cart) }) } } return sendJson(res, 404, { message: `Mock backend: no cart handler for ${req.method} ${pathname}`, }) } // Checkout-adjacent endpoints: answered as "nothing configured" so the // storefront degrades gracefully instead of erroring. if (pathname === "/store/shipping-options") { return sendJson(res, 200, { shipping_options: [] }) } if (pathname === "/store/payment-providers") { return sendJson(res, 200, { payment_providers: [] }) } if (pathname === "/store/customers/me") { return sendJson(res, 401, { message: "Unauthorized" }) } if (pathname === "/store/locales") { return sendJson(res, 404, { message: "Not Found" }) } return sendJson(res, 404, { message: `Mock backend: no handler for ${pathname}`, }) }) server.listen(PORT, () => { console.log(`[mock-backend] listening on http://localhost:${PORT}`) })