/** * End-to-end harness: a fake theme cart page + Ajax Cart API, wired to the REAL * storefront service (engine + Postgres reservations). Only Shopify's Admin API is mocked. * * DATABASE_URL=postgresql://… npx tsx tests/e2e/server.ts → http://localhost:4545/cart */ import { createServer } from "node:http"; import { readFileSync } from "node:fs"; import { join } from "node:path"; import prisma from "../../app/db.server"; import { getAvailability, holdSelection, joinWaitlist, releaseSelection } from "../../app/services/storefront.server"; import { withDefaults } from "../../app/services/settings"; const SHOP = "e2e-demo.myshopify.com"; const PORT = Number(process.env.PORT ?? 4545); const ext = join(import.meta.dirname, "../../extensions/datewise-widget"); // ---------- mock catalog + Admin API ---------- const CATALOG: Record = { "101": { title: "Fresh Marigold Garland", price: 1999, type: "Fresh Flowers", tags: ["perishable"], vendor: "C2C Agricorp" }, "102": { title: "பாக்கு (Pākkŭ) 200 g", price: 699, type: "Puja", tags: [], vendor: "Clicks to Cart" }, }; const feeVariants: { id: string; price: string }[] = []; const admin = { async graphql(q: string, o?: { variables?: Record }) { const v = o?.variables ?? {}; let data: unknown = {}; if (q.includes("DatewiseCart")) data = { nodes: (v.ids as string[]).map((gid) => { const id = gid.split("/").pop()!; const fee = feeVariants.find((f) => f.id.endsWith(`/${id}`)); if (fee) return { id: gid, title: fee.price, price: fee.price, product: { id: "gid://shopify/Product/900", title: "Delivery & handling", vendor: "", productType: "Service", tags: ["datewise-fee"], collections: { nodes: [] } }, inventoryItem: null }; const p = CATALOG[id]; if (!p) return null; return { id: gid, title: "Default Title", price: (p.price / 100).toFixed(2), product: { id: `gid://shopify/Product/${id}`, title: p.title, vendor: p.vendor, productType: p.type, tags: p.tags, collections: { nodes: [] } }, inventoryItem: { tracked: false, inventoryLevels: { nodes: [] } }, }; }), }; else if (q.includes("productCreate")) data = { productCreate: { product: { id: "gid://shopify/Product/900" }, userErrors: [] } }; else if (q.includes("variants(first: 250)")) data = { product: { variants: { nodes: feeVariants } } }; else if (q.includes("product(id")) data = { product: { id: "gid://shopify/Product/900" } }; else if (q.includes("productVariantsBulkCreate")) { const price = ((v.variants as { price: string }[])[0]).price; const n = { id: `gid://shopify/ProductVariant/${9000 + feeVariants.length}`, price }; feeVariants.push(n); data = { productVariantsBulkCreate: { productVariants: [n], userErrors: [] } }; } else if (q.includes("publications")) data = { publications: { nodes: [{ id: "gid://shopify/Publication/1", catalog: { title: "Online Store" } }] } }; else data = { metafieldsSet: { userErrors: [] }, publishablePublish: { userErrors: [] } }; return new Response(JSON.stringify({ data })); }, }; // ---------- seed ---------- async function seed() { await prisma.shop.deleteMany({ where: { domain: SHOP } }); const h = (a: string, b: string) => [{ start: a, end: b }]; const all = (a: string, b: string) => Object.fromEntries([0, 1, 2, 3, 4, 5, 6].map((d) => [d, h(a, b)])); const slots = [{ start: "10:00", end: "13:00" }, { start: "13:00", end: "16:00" }, { start: "16:00", end: "19:00", surcharge: 300 }]; await prisma.shop.create({ data: { domain: SHOP, timezone: "America/Toronto", plan: "Pro", settings: withDefaults({ holdMinutes: 10 }) as object, locations: { create: [{ name: "Kitchener — Guelph St", address: "900 Guelph St, Kitchener, ON", timezone: "America/Toronto", lat: 43.4643, lng: -80.4844, enabled: true, priority: 1, methods: { delivery: { enabled: true, weeklyHours: all("10:00", "19:00"), slotMode: "explicit", slots: Object.fromEntries([0, 1, 2, 3, 4, 5, 6].map((d) => [d, slots])), prepMinutes: 120, prepMode: "elapsed", sameDayCutoff: "12:00", horizonDays: 21, slotCapacity: 2, dailyCapacity: 6 }, pickup: { enabled: true, weeklyHours: all("10:00", "18:00"), slotMode: "generated", slotMinutes: 120, prepMinutes: 60, prepMode: "elapsed", sameDayCutoff: "14:00", horizonDays: 21, slotCapacity: 3 }, shipping: { enabled: true, weeklyHours: all("10:00", "17:00"), slotMode: "dateOnly", prepMinutes: 0, prepMode: "operating", sameDayCutoff: "13:00", horizonDays: 14 }, }, zones: [{ id: "kw", name: "Kitchener–Waterloo", postalPatterns: ["N2H*", "N2G*"], fee: 799, freeOver: 7500, minOrder: 1000 }], }], }, rules: { create: [ { name: "Fresh flowers can't ship", target: { type: "productType", values: ["Fresh Flowers"] }, action: { type: "prohibitMethods", methods: ["shipping"] }, message: "Fresh flowers are pickup or local delivery only", priority: 1 }, { name: "Garland stringing", target: { type: "tag", values: ["perishable"] }, action: { type: "addPrep", minutes: 240 }, priority: 2 }, ], }, }, }); } // ---------- fake theme + Ajax Cart API ---------- let cart = { token: "c1", items: [] as { key: string; variant_id: number; quantity: number; final_price: number; product_title: string; properties: Record }[], attributes: {} as Record }; const addLine = (variant: number, qty: number, properties: Record = {}) => { const p = CATALOG[String(variant)]; const fee = feeVariants.find((f) => f.id.endsWith(`/${variant}`)); cart.items.push({ key: `${variant}:${Date.now()}${Math.random()}`, variant_id: variant, quantity: qty, final_price: p ? p.price : Math.round(Number(fee?.price ?? 0) * 100), product_title: p?.title ?? "Delivery & handling", properties }); }; const cartJson = () => ({ ...cart, item_count: cart.items.reduce((s, i) => s + i.quantity, 0), total_price: cart.items.reduce((s, i) => s + i.final_price * i.quantity, 0) }); const page = () => ` Demo cart

Your cart

Total:

`; const body = (req: import("node:http").IncomingMessage) => new Promise((r) => { let s = ""; req.on("data", (c) => (s += c)); req.on("end", () => r(s ? JSON.parse(s) : {})); }); createServer(async (req, res) => { const url = new URL(req.url!, `http://localhost:${PORT}`); const json = (o: unknown, status = 200) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(o)); }; try { if (url.pathname === "/" || url.pathname === "/cart") { res.writeHead(200, { "Content-Type": "text/html" }); return res.end(page()); } if (url.pathname.startsWith("/assets/")) { const f = url.pathname.slice(8); res.writeHead(200, { "Content-Type": f.endsWith(".css") ? "text/css" : "text/javascript" }); return res.end(readFileSync(join(ext, "assets", f))); } if (url.pathname === "/cart.js") return json(cartJson()); if (url.pathname === "/cart/update.js") { const b = (await body(req)) as { attributes?: Record }; for (const [k, v] of Object.entries(b.attributes ?? {})) v === "" ? delete cart.attributes[k] : (cart.attributes[k] = v); return json(cartJson()); } if (url.pathname === "/cart/add.js") { const b = (await body(req)) as { items: { id: number; quantity: number; properties?: Record }[] }; b.items.forEach((i) => addLine(i.id, i.quantity, i.properties)); return json({ items: cart.items }); } if (url.pathname === "/cart/change.js") { const b = (await body(req)) as { id: string; quantity: number }; cart.items = cart.items.map((i) => (i.key === b.id ? { ...i, quantity: b.quantity } : i)).filter((i) => i.quantity > 0); return json(cartJson()); } if (url.pathname === "/__reset") { cart = { token: "c1", items: [], attributes: {} }; addLine(101, 2); addLine(102, 1); await prisma.hold.deleteMany({}); await prisma.capacityCounter.deleteMany({}); return json({ ok: true }); } if (url.pathname === "/__rival-hold") { // Another shopper grabs a place directly through the same service. const b = (await body(req)) as Record; return json(await holdSelection(SHOP, admin, { lines: [{ variantId: 101, quantity: 1 }, { variantId: 102, quantity: 1 }], ...b })); } const p = url.pathname.replace("/apps/datewise", ""); const b = req.method === "POST" ? await body(req) : Object.fromEntries(url.searchParams); if (p === "/availability") return json(await getAvailability(SHOP, admin, b)); if (p === "/hold") return json(await holdSelection(SHOP, admin, b)); if (p === "/release") return json(await releaseSelection(SHOP, (b as { token: string }).token)); if (p === "/waitlist") return json(await joinWaitlist(SHOP, b)); res.writeHead(404); res.end(); } catch (e) { console.error(e); json({ ok: false, message: String(e) }, 500); } }).listen(PORT, async () => { await seed(); console.log(`e2e harness on http://localhost:${PORT}/cart`); });