datewise/tests/e2e/server.ts
metatroncubeswdev e56182e0e3 chore: initial Datewise app (rebranded from SlotSure prototype)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:06:49 -04:00

183 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, { title: string; price: number; type: string; tags: string[]; vendor: string }> = {
"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<string, unknown> }) {
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<string, string> }[], attributes: {} as Record<string, string> };
const addLine = (variant: number, qty: number, properties: Record<string, string> = {}) => {
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 = () => `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Demo cart</title><link rel="stylesheet" href="/assets/datewise.css"><style>body{font:16px/1.5 system-ui;max-width:640px;margin:24px auto;padding:0 16px}.line{display:flex;justify-content:space-between;gap:8px;padding:6px 0;border-bottom:1px solid #ddd}</style></head>
<body><h1>Your cart</h1><div id="lines"></div><p>Total: <b id="total"></b></p>
<div data-datewise-root></div>
<form action="/checkout" method="post" id="co"><button type="submit" name="checkout">Check out</button></form><p id="checkedout"></p>
<script type="application/json" data-datewise-config>${JSON.stringify({ checkoutGuard: true, locale: "en", currency: "CAD", text: {} })}</script>
<script>
async function draw(){const c=await (await fetch('/cart.js')).json();document.getElementById('lines').innerHTML=c.items.map(i=>'<div class="line"><span>'+i.product_title+' × '+i.quantity+'</span><span><button data-k="'+i.key+'" data-q="'+(i.quantity+1)+'">+</button><button data-k="'+i.key+'" data-q="'+(i.quantity-1)+'">−</button></span></div>').join('');document.getElementById('total').textContent='$'+(c.total_price/100).toFixed(2);}
document.addEventListener('click',async e=>{const b=e.target.closest('[data-k]');if(!b)return;await fetch('/cart/change.js',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:b.dataset.k,quantity:+b.dataset.q})});draw();});
document.getElementById('co').addEventListener('submit',e=>{e.preventDefault();fetch('/cart.js').then(r=>r.json()).then(c=>{document.getElementById('checkedout').textContent='CHECKOUT '+JSON.stringify(c.attributes);});});
draw();
</script><script src="/assets/datewise.js" defer></script></body></html>`;
const body = (req: import("node:http").IncomingMessage) =>
new Promise<unknown>((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<string, string> };
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<string, string> }[] };
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<string, unknown>;
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`);
});