183 lines
11 KiB
TypeScript
183 lines
11 KiB
TypeScript
/**
|
||
* 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`);
|
||
});
|