HooStore-UI/mock-backend/server.js
2026-10-27 15:09:07 +07:00

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}`)
})