395 lines
11 KiB
JavaScript
395 lines
11 KiB
JavaScript
/**
|
|
* 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}`)
|
|
})
|