返回 CodeWhale
commerce.test.ts
根目录 / web / lib / merch / commerce.test.ts
1 import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2 import { DatabaseSync, type SQLInputValue } from "node:sqlite";
3 import { readFileSync } from "node:fs";
4 import Stripe from "stripe";
5 import { handleMerch } from "./server";
6 import { operatorAction, supplierRequest } from "./operator";
7 import { parseConfig, reviewedRoute, validateQuoteInput, priceQuote } from "./model";
8 import { destinationRate, yoycolHeaders, yoycolRequest } from "./yoycol";
9 import type { MerchConfig, MerchDatabase, MerchEnv, D1Statement, Quote } from "./types";
10
11 class Sqlite implements MerchDatabase {
12 db = new DatabaseSync(":memory:");
13 constructor() { this.db.exec(readFileSync(new URL("../../migrations/merch/0001_merch.sql", import.meta.url), "utf8")); }
14 prepare(sql: string): D1Statement {
15 let values: SQLInputValue[] = [];
16 const result: D1Statement = {
17 bind: (...args) => { values = args as SQLInputValue[]; return result; },
18 first: async <T>() => this.db.prepare(sql).get(...values) as T ?? null,
19 all: async <T>() => ({ results: this.db.prepare(sql).all(...values) as T[] }),
20 run: async () => ({ meta: { changes: Number(this.db.prepare(sql).run(...values).changes) } }),
21 };
22 return result;
23 }
24 async batch(statements: D1Statement[]) {
25 this.db.exec("BEGIN");
26 try { const result = []; for (const statement of statements) result.push(await statement.run()); this.db.exec("COMMIT"); return result; }
27 catch (error) { this.db.exec("ROLLBACK"); throw error; }
28 }
29 }
30 const input = { productId: "whale-bro", size: "XL", color: "White", quantity: 1, country: "US", address: { name: "Sample Tester", line1: "1 Test Street", line2: "", city: "Test City", region: "CA", postalCode: "94107", phone: "+15555550100" } };
31 function configuration(): MerchConfig {
32 return { mode: "test", checkoutEnabled: true, supplierSubmissionEnabled: false, siteOrigin: "http://localhost:3187", countries: { US: { regionCode: "US", shippingLevelCode: "FIXTURE", currency: "usd", usdExchangeRate: 1, feeRate: .054, flatFeeUsd: .30, taxReviewed: true, taxNote: "Fixture: import charges excluded.", supplierPricesUsdReviewed: true, addressPolicyReviewed: true, allowedPostalPrefixes: ["941"], blockedPostalPrefixes: ["94199"] } }, products: { "whale-bro": { US: { approved: true, designCode: "fixture-design", skuBySize: { XL: "fixture-sku" }, productionCostUsd: 5.12 } }, contributor: { US: { approved: true, designCode: "", contributorDesigns: { en: ["fixture-en-0", "fixture-en-1", "fixture-en-2"] }, skuBySize: { XL: "fixture-sku" }, productionCostUsd: 5.12 } } } };
33 }
34 let db: Sqlite, env: MerchEnv, config: MerchConfig;
35 let currentSession: Stripe.Checkout.Session;
36 let sent: { url: string; method: string; body: string; idempotency: string | null }[];
37 let supplierFailure = false;
38 beforeEach(() => {
39 db = new Sqlite(); config = configuration(); sent = []; supplierFailure = false;
40 env = { MERCH_DB: db, MERCH_RATE_LIMITER: { limit: async () => ({ success: true }) }, MERCH_CONFIG_JSON: JSON.stringify(config), MERCH_STRIPE_KEY: "sk_test_fixture", MERCH_STRIPE_WEBHOOK_SECRET: "whsec_fixture", MERCH_PAYMENT_CONFIGURATION: "pmc_fixture", YOYCOL_ACCESS_KEY: "fixture-key", YOYCOL_SECRET_KEY: "fixture-secret" };
41 currentSession = { object: "checkout.session", id: "cs_test_fixture", livemode: false, metadata: {}, client_reference_id: "", currency: "usd", amount_total: 1725, payment_status: "unpaid", status: "complete", url: "https://checkout.stripe.com/c/pay/fixture" } as Stripe.Checkout.Session;
42 vi.stubGlobal("fetch", vi.fn(async (url: URL | string, init?: RequestInit) => {
43 const u = new URL(String(url)), method = init?.method ?? "GET", body = String(init?.body ?? "");
44 sent.push({ url: u.href, method, body, idempotency: new Headers(init?.headers).get("idempotency-key") });
45 if (u.hostname === "www.yoycol.com") {
46 if (method === "POST") {
47 if (supplierFailure) throw new Error("ambiguous connection loss");
48 const value = JSON.parse(body);
49 return Response.json({ code: "100000", data: { orderId: 42, storeOrderSn: value.storeOrderSn, currency: "USD", orderRealAmount: 7.37, canPay: true } });
50 }
51 return Response.json({ code: "100000", data: [{ regionCode: "US", levels: [{ levelCode: "FIXTURE", levelName: "Fixture service", firstPrice: 2.25, additionalPrice: .75 }] }] });
52 }
53 if (u.hostname === "api.stripe.com") {
54 if (method === "POST") {
55 const data = new URLSearchParams(body);
56 currentSession.client_reference_id = data.get("client_reference_id");
57 currentSession.metadata = { merch_order_id: data.get("metadata[merch_order_id]")!, merch_product_id: data.get("metadata[merch_product_id]")! };
58 currentSession.currency = data.get("line_items[0][price_data][currency]");
59 currentSession.amount_total = Number(data.get("line_items[0][price_data][unit_amount]")) + Number(data.get("line_items[1][price_data][unit_amount]"));
60 }
61 return Response.json(currentSession);
62 }
63 throw new Error("No unmocked network is permitted in commerce tests");
64 }));
65 });
66 afterEach(() => { vi.unstubAllGlobals(); db.db.close(); });
67 function request(action: string, data: unknown, origin = config.siteOrigin) {
68 return new Request("http://localhost:3187/api/merch/" + action, { method: "POST", headers: { "Content-Type": "application/json", Origin: origin }, body: JSON.stringify(data) });
69 }
70 async function quoted() {
71 const response = await handleMerch(request("quote", input), "quote", env);
72 expect(response.status).toBe(200);
73 return response.json() as Promise<Quote>;
74 }
75 async function checkout() {
76 const quote = await quoted();
77 const response = await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env);
78 expect(response.status).toBe(200); return quote;
79 }
80 async function event(type = "checkout.session.completed", eventId = "evt_fixture", valid = true, livemode = config.mode === "live") {
81 const payload = JSON.stringify({ id: eventId, object: "event", livemode, type, created: Math.floor(Date.now()/1000), data: { object: { id: currentSession.id, metadata: currentSession.metadata } } });
82 const signature = Stripe.webhooks.generateTestHeaderString({ payload, secret: "whsec_fixture" });
83 return handleMerch(new Request("http://localhost/api/merch/webhook", { method: "POST", body: payload, headers: { "stripe-signature": valid ? signature : "invalid" } }), "webhook", env);
84 }
85 function state(id: string) { return db.db.prepare("SELECT payment_state,fulfillment_state FROM merch_orders WHERE id=?").get(id); }
86
87 describe("commerce trust boundaries", () => {
88 it("fails closed without storage, keys or reviewed rates", async () => {
89 expect(await (await handleMerch(new Request("http://localhost/status"), "status", {})).json()).toEqual({ checkoutConfigured: false, readyProductIds: [] });
90 expect((await handleMerch(request("quote", input), "quote", {})).status).toBe(503);
91 config.countries.US.addressPolicyReviewed = false; env.MERCH_CONFIG_JSON = JSON.stringify(config);
92 expect((await handleMerch(request("quote", input), "quote", env)).status).toBe(409);
93 expect(sent).toHaveLength(0);
94 });
95 it("accepts only a real catalog variant and bounded quantity", () => {
96 for (const value of [{ ...input, productId: "deskmat" }, { ...input, productId: "field" }, { ...input, quantity: 0 }, { ...input, quantity: 6 }, { ...input, size: "10XL" }, { ...input, color: "Black" }, { ...input, country: "ZZ" }]) expect(() => validateQuoteInput(value)).toThrow();
97 expect(validateQuoteInput(input).size).toBe("XL");
98 });
99 it("rejects remote/unreviewed postcodes and unapproved contributor print languages", () => {
100 expect(() => reviewedRoute(config, validateQuoteInput({ ...input, address: { ...input.address, postalCode: "94199" } }))).toThrow();
101 const c = validateQuoteInput({ ...input, productId: "contributor", contributor: { github: "", contribution: "https://github.com/codewhale-hq/CodeWhale/pull/123", language: "zh", phraseIndex: 1 } });
102 expect(() => reviewedRoute(config, c)).toThrow();
103 c.contributor!.language = "en";
104 expect(reviewedRoute(config, c).designCode).toBe("fixture-en-1");
105 });
106 it("rejects non-boolean activation and insecure production origins", () => {
107 expect(parseConfig(JSON.stringify({ ...config, checkoutEnabled: "true" }))).toBeNull();
108 expect(parseConfig(JSON.stringify({ ...config, mode: "live" }))).toBeNull();
109 expect(parseConfig(JSON.stringify({ ...config, siteOrigin: "ftp://localhost" }))).toBeNull();
110 });
111 it("requires same origin and a shared rate limit before contacting a supplier", async () => {
112 expect((await handleMerch(request("quote", input, "https://other.example"), "quote", env)).status).toBe(403);
113 env.MERCH_RATE_LIMITER = { limit: async () => ({ success: false }) };
114 expect((await handleMerch(request("quote", input), "quote", env)).status).toBe(429);
115 expect(sent).toHaveLength(0);
116 });
117 it("bounds streamed requests and ignores client prices", async () => {
118 expect((await handleMerch(request("quote", { ...input, extra: "x".repeat(9000) }), "quote", env)).status).toBe(413);
119 const response = await handleMerch(request("quote", { ...input, total: 1 }), "quote", env);
120 expect((await response.json()).total).toBe(1725);
121 });
122 it("grosses contributor costs up without a profit reserve", () => {
123 const c = validateQuoteInput({ ...input, productId: "contributor", contributor: { github: "123456", contribution: "", language: "en", phraseIndex: 0 } });
124 const priced = priceQuote(c, config.countries.US, 5.12, 2.25);
125 expect(priced).toEqual({ merchandise: 512, shipping: 225, processing: 74, total: 811 });
126 expect(priceQuote(validateQuoteInput(input), config.countries.US, 5.12, 2.25).processing).toBe(0);
127 expect(() => priceQuote(validateQuoteInput(input), config.countries.US, 14, 2.25)).toThrow("price review");
128 });
129 it("selects the exact supplier region/service and additional-piece price", () => {
130 const rates = [{ regionCode: "US", levels: [{ levelCode: "EXP", firstPrice: 12, additionalPrice: 5 }, { levelCode: "STD", firstPrice: 3, additionalPrice: 1 }] }];
131 expect(destinationRate(rates, "US", "STD", 3).shippingUsd).toBe(5);
132 expect(() => destinationRate(rates, "CN", "STD", 1)).toThrow();
133 expect(() => destinationRate(rates, "US", "UNKNOWN", 1)).toThrow();
134 });
135 it("pins HTTPS and cannot POST to shipping or follow arbitrary endpoints", async () => {
136 await expect(yoycolRequest(env, "POST", "/api/2025/open/v4/shipping/levels")).rejects.toThrow();
137 await expect(yoycolRequest(env, "GET", "https://other.example/")).rejects.toThrow();
138 expect(sent).toHaveLength(0);
139 });
140 it("signs canonical raw sorted query values, independent of URL encoding", () => {
141 const headers = yoycolHeaders("GET", "/api/2025/open/v4/shipping/sku_quotes", { z: "two words", a: "x/y" }, "key", "secret", "1700000000000", "0123456789abcdef0123456789abcdef");
142 expect(headers["X-API-Signature"]).toBe("ojijq4/UJG5lcMwoYJXopBrxYJ8LTYICrzmKmiIE0jE=");
143 });
144 });
145
146 describe("checkout and durable payment receipts", () => {
147 it("creates a 15-minute quote and a stable idempotent session with separate shipping", async () => {
148 const quote = await checkout();
149 expect(quote.expiresAt-Date.now()).toBeGreaterThan(14*60*1000);
150 expect(quote.expiresAt-Date.now()).toBeLessThanOrEqual(15*60*1000);
151 const creation = sent.find(r => new URL(r.url).hostname === "api.stripe.com" && r.method === "POST")!;
152 expect(creation.idempotency).toBe("codewhale-merch:" + quote.quoteId);
153 const params = new URLSearchParams(creation.body);
154 expect(params.get("payment_method_configuration")).toBe("pmc_fixture");
155 expect(params.get("line_items[1][price_data][unit_amount]")).toBe("225");
156 expect(params.get("line_items[1][price_data][product_data][name]")).toBe("Delivery · Fixture service");
157 expect(params.has("shipping_options[0][shipping_rate_data][fixed_amount][amount]")).toBe(false);
158 expect(params.get("payment_intent_data[shipping][address][postal_code]")).toBe("94107");
159 expect(params.has("shipping_address_collection[allowed_countries][0]")).toBe(false);
160 expect(Number(params.get("expires_at"))*1000-quote.expiresAt).toBeGreaterThan(29*60*1000);
161 expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(200);
162 expect(sent.filter(r => new URL(r.url).hostname === "api.stripe.com" && r.method === "POST")).toHaveLength(1);
163 });
164 it("rejects an expired quote and changed saved artwork", async () => {
165 const quote = await quoted();
166 config.products["whale-bro"]!.US.designCode = "different-art"; env.MERCH_CONFIG_JSON = JSON.stringify(config);
167 expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(409);
168 db.db.prepare("UPDATE merch_orders SET expires_at=0").run();
169 expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(409);
170 });
171 it("revokes outstanding quotes when shipping, costs, fees or tax disclosure change", async () => {
172 const changes = [
173 () => { config.countries.US.shippingLevelCode = "REPLACEMENT"; },
174 () => { config.products["whale-bro"]!.US.productionCostUsd = 14; },
175 () => { config.countries.US.feeRate = .08; },
176 () => { config.countries.US.taxNote = "Updated delivery charge disclosure."; },
177 ];
178 for (const change of changes) {
179 config = configuration(); env.MERCH_CONFIG_JSON = JSON.stringify(config);
180 const quote = await quoted();
181 change(); env.MERCH_CONFIG_JSON = JSON.stringify(config);
182 const result = await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env);
183 expect(result.status).toBe(409);
184 expect((await result.json()).code).toBe("quote_expired");
185 }
186 expect(sent.filter(r => new URL(r.url).hostname === "api.stripe.com")).toHaveLength(0);
187 });
188 it("rejects bad signatures, test/live mismatch and unreconciled amounts", async () => {
189 const quote = await checkout();
190 expect((await event(undefined, undefined, false)).status).toBe(400);
191 expect((await event(undefined, undefined, true, true)).status).toBe(400);
192 currentSession.amount_total = 1; currentSession.payment_status = "paid";
193 expect((await event()).status).toBe(409);
194 expect(state(quote.quoteId)?.payment_state).toBe("quoted");
195 });
196 it("does not fulfill completed-but-unpaid events or a redirect", async () => {
197 const quote = await checkout();
198 expect(state(quote.quoteId)?.payment_state).toBe("quoted");
199 expect((await event()).status).toBe(200);
200 expect(state(quote.quoteId)?.fulfillment_state).toBe("not_ready");
201 expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(0);
202 });
203 it("accepts delayed payment after failure once, and duplicate events do not regress it", async () => {
204 const quote = await checkout(); await event("checkout.session.async_payment_failed", "evt_fail");
205 expect(state(quote.quoteId)?.payment_state).toBe("failed");
206 currentSession.payment_status = "paid";
207 await event("checkout.session.async_payment_succeeded", "evt_paid");
208 await event("checkout.session.async_payment_succeeded", "evt_paid");
209 currentSession.payment_status = "unpaid"; await event("checkout.session.expired", "evt_late");
210 expect(state(quote.quoteId)).toEqual(expect.objectContaining({ payment_state: "paid", fulfillment_state: "awaiting_supplier_review" }));
211 expect(db.db.prepare("SELECT count(*) AS n FROM merch_webhook_events WHERE id='evt_paid'").get()?.n).toBe(1);
212 });
213 it("keeps webhooks working when new sales are paused", async () => {
214 const quote = await checkout(); currentSession.payment_status = "paid";
215 config.checkoutEnabled = false; env.MERCH_CONFIG_JSON = JSON.stringify(config);
216 env.YOYCOL_ACCESS_KEY = undefined; env.MERCH_RATE_LIMITER = undefined;
217 expect((await event()).status).toBe(200);
218 expect(state(quote.quoteId)?.payment_state).toBe("paid");
219 });
220 });
221
222 describe("manual supplier handoff", () => {
223 async function paid(live = false) {
224 if (live) { config.mode = "live"; config.siteOrigin = "https://codewhale.net"; env.MERCH_CONFIG_JSON = JSON.stringify(config); env.MERCH_STRIPE_KEY = "sk_live_fixture"; currentSession.livemode = true; }
225 const quote = await checkout(); currentSession.payment_status = "paid"; await event(); return quote;
226 }
227 it("refuses unpaid and test-mode supplier creation", async () => {
228 const quote = await checkout();
229 await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId })).rejects.toThrow();
230 currentSession.payment_status = "paid"; await event();
231 await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" })).rejects.toThrow();
232 config.mode = "live"; config.siteOrigin = "https://codewhale.net"; config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config);
233 await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" })).rejects.toThrow();
234 });
235 it("previews frozen SKU/art/address without forwarding contributor identity", async () => {
236 const quote = await paid();
237 const stored = JSON.parse(db.db.prepare("SELECT quote_json FROM merch_orders WHERE id=?").get(quote.quoteId)?.quote_json as string) as Quote;
238 stored.input.contributor = { github: "private-identity", contribution: "https://github.com/codewhale-hq/CodeWhale/pull/123", language: "en", phraseIndex: 1 };
239 const body = JSON.stringify(supplierRequest(stored));
240 expect(body).not.toContain("private-identity"); expect(body).not.toContain("github.com");
241 expect(body).toContain("fixture-design"); expect(body).toContain("fixture-sku");
242 await operatorAction(env, { action: "preview", orderId: quote.quoteId });
243 expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(0);
244 });
245 it("atomically allows one submission and keeps supplier funding separate", async () => {
246 const quote = await paid(true); config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config);
247 const value = { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" };
248 const results = await Promise.allSettled([operatorAction(env, value), operatorAction(env, value)]);
249 expect(results.filter(r => r.status === "fulfilled")).toHaveLength(1);
250 expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(1);
251 expect(state(quote.quoteId)?.fulfillment_state).toBe("supplier_cost_review");
252 });
253 it("holds an ambiguous submission instead of automatically retrying", async () => {
254 const quote = await paid(true); config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config); supplierFailure = true;
255 const value = { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" };
256 await expect(operatorAction(env, value)).rejects.toThrow("Do not resubmit");
257 supplierFailure = false; await expect(operatorAction(env, value)).rejects.toThrow();
258 expect(state(quote.quoteId)?.fulfillment_state).toBe("reconciliation_hold");
259 expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(1);
260 });
261 });
262
262 lines TYPESCRIPT