Public: anyone can read this; the crew can change it.
Security first: `bun test` is red on main — one test leaks database state into another #36
Check · Review · Land · Live done — Landed and live in production.
ledger/bazaar:ledger/25-security-first-bun-test-is-red-on-main-o → main 1 commit fork 1 thread · 1 unresolved
10 files
changed +236
−55
AGENTS.md +6 −1
| @@ -10,7 +10,12 @@ preview at its own address, deployed from your branch as you push. | ||
| 10 | 10 | a person merges your pull once it is reviewed. |
| 11 | 11 | - Keep state under `FORGE_APP_DATA`. Never write elsewhere at runtime. |
| 12 | 12 | - Run `bun test` before you push. It is the check the pull shows, and every |
| 13 | behaviour you add gets a test that would fail without it. | |
| 13 | behaviour you add gets a test that would fail without it. A new test file | |
| 14 | starts with `await freshData("its-name")` from `src/fresh.ts` before it | |
| 15 | imports anything that queries: the files share one process and one module | |
| 16 | registry, so that call is what gives the file a database of its own. No | |
| 17 | test may depend on another file's rows, and the suite has to pass in any | |
| 18 | order (`bun test --randomize` is the quick way to find out). | |
| 14 | 19 | - `src/tty.ts` is the app's terminal interface. The exact command that |
| 15 | 20 | reaches it is `FORGE_APP_SSH` at runtime; print that verbatim wherever the |
| 16 | 21 | app tells people how to connect. Keep it working and keep it useful: what |
README.md +20 −2
| @@ -88,7 +88,12 @@ takes off. | ||
| 88 | 88 | discount, never on shipping and never on the sale of a gift card, which is |
| 89 | 89 | money moving rather than a sale. The rate and its name are copied onto the |
| 90 | 90 | order at the moment of sale, so changing the table never rewrites what |
| 91 | somebody was charged. | |
| 91 | somebody was charged. Before an address is typed the cart and the checkout | |
| 92 | page estimate with the home country's row — `tax.HOME_COUNTRY`, the country | |
| 93 | the checkout form itself offers — so the rate a shopper is shown and the | |
| 94 | rate the order charges are one lookup in one table, and the "anywhere else" | |
| 95 | default set at `/admin/tax` is what it says it is: the rate for the places | |
| 96 | the table does not name. | |
| 92 | 97 | |
| 93 | 98 | The arithmetic runs in one fixed order, every step an integer number of |
| 94 | 99 | cents: goods, less the discount, plus shipping, plus tax on what is left of |
| @@ -251,7 +256,8 @@ till: someone reading their card over and over must still be able to spend it. | ||
| 251 | 256 | | --- | --- | |
| 252 | 257 | | `src/server.ts` | The front door: security headers on every response, same-origin and CSRF checks, the request's context | |
| 253 | 258 | | `src/routes.ts` | The table of paths | |
| 254 | | `src/db.ts` | The schema, opened once | | |
| 259 | | `src/db.ts` | The schema, and `open(dir)`: a directory in, a database out | | |
| 260 | | `src/fresh.ts` | A database of a test file's own, opened before the file imports anything that queries | | |
| 255 | 261 | | `src/catalog.ts` `src/inventory.ts` `src/orders.ts` | Products, the stock ledger, orders and refunds | |
| 256 | 262 | | `src/cart.ts` `src/codes.ts` `src/mine.ts` `src/money.ts` `src/sign.ts` | The signed cart, the codes cookie, the orders this browser placed, integer cents, HMACs | |
| 257 | 263 | | `src/discounts.ts` `src/giftcards.ts` `src/tax.ts` | What a code is worth, what a card holds, what the rate is | |
| @@ -263,6 +269,18 @@ till: someone reading their card over and over must still be able to spend it. | ||
| 263 | 269 | | `src/pages/*.ts` `src/html.ts` `src/style.ts` | Rendering; the stylesheet is served under a name made from its own contents | |
| 264 | 270 | | `src/units.test.ts` `src/server.test.ts` `src/stripe.test.ts` `src/proxy.test.ts` | The rules on their own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy | |
| 265 | 271 | |
| 272 | ### Tests | |
| 273 | ||
| 274 | `bun test`. `bun test` runs every file in one process with one module | |
| 275 | registry, so `src/db.ts` is evaluated once: a test file that only set | |
| 276 | `FORGE_APP_DATA` would still be querying whichever database the first file to | |
| 277 | import it opened. Each file therefore starts with `await freshData(name)` | |
| 278 | (`src/fresh.ts`), which makes a temporary directory and opens a database there | |
| 279 | before anything that queries is imported. No file depends on another file's | |
| 280 | rows: every file passes alone with the same count as it does in the suite, and | |
| 281 | the suite passes in any order — `bun test`, `bun test --isolate`, | |
| 282 | `bun test --parallel=4` and `bun test --randomize --seed N` are all green. | |
| 283 | ||
| 266 | 284 | ## The terminal |
| 267 | 285 | |
| 268 | 286 | The same shop over the same data. The exact command is in `FORGE_APP_SSH` |
src/db.ts +62 −24
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** |
| 2 | * The database: one SQLite file under FORGE_APP_DATA, opened once. | |
| 2 | * The database: one SQLite file in a directory, opened by a factory. | |
| 3 | 3 | * |
| 4 | 4 | * The shape of the store, in one place. Two rules hold everywhere: |
| 5 | 5 | * money is an integer number of cents, and anything that changes stock, a |
| @@ -7,21 +7,17 @@ | ||
| 7 | 7 | * gift_card_entry, discount_redemption, order_event). A product's stock and a |
| 8 | 8 | * gift card's balance are the running totals of their ledgers, never a bare |
| 9 | 9 | * edit. |
| 10 | * | |
| 11 | * `open(dir)` is the whole of it: a directory in, a usable database out. The | |
| 12 | * app keeps one of those — `db`, under FORGE_APP_DATA — and a test keeps its | |
| 13 | * own, which is why the handle is a live binding and not a frozen singleton. | |
| 10 | 14 | */ |
| 11 | 15 | |
| 12 | 16 | import { Database } from "bun:sqlite"; |
| 13 | 17 | import { mkdirSync } from "node:fs"; |
| 14 | 18 | import { join } from "node:path"; |
| 15 | 19 | |
| 16 | export const dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 17 | mkdirSync(dataDir, { recursive: true }); | |
| 18 | ||
| 19 | export const db = new Database(join(dataDir, "app.db")); | |
| 20 | db.exec("PRAGMA journal_mode = WAL"); | |
| 21 | db.exec("PRAGMA foreign_keys = ON"); | |
| 22 | db.exec("PRAGMA busy_timeout = 5000"); | |
| 23 | ||
| 24 | db.exec(` | |
| 20 | const SCHEMA = ` | |
| 25 | 21 | CREATE TABLE IF NOT EXISTS category ( |
| 26 | 22 | id INTEGER PRIMARY KEY, |
| 27 | 23 | slug TEXT NOT NULL UNIQUE, |
| @@ -217,29 +213,71 @@ CREATE TABLE IF NOT EXISTS tax_rate ( | ||
| 217 | 213 | active INTEGER NOT NULL DEFAULT 1 |
| 218 | 214 | ); |
| 219 | 215 | CREATE UNIQUE INDEX IF NOT EXISTS tax_rate_place ON tax_rate(country, region); |
| 220 | `); | |
| 216 | `; | |
| 221 | 217 | |
| 222 | 218 | /** |
| 223 | 219 | * Columns added after the first release. A database that already holds orders |
| 224 | 220 | * is grown, never rebuilt: each column is added once, with a default that |
| 225 | 221 | * makes the rows already there say the truth about themselves. |
| 226 | 222 | */ |
| 227 | const columnsOf = (table: string): Set<string> => | |
| 228 | new Set(db.query<{ name: string }, []>(`PRAGMA table_info("${table}")`).all().map((column) => column.name)); | |
| 223 | const columnsOf = (handle: Database, table: string): Set<string> => | |
| 224 | new Set(handle.query<{ name: string }, []>(`PRAGMA table_info("${table}")`).all().map((column) => column.name)); | |
| 229 | 225 | |
| 230 | function addColumn(table: string, name: string, declaration: string, backfill?: string): void { | |
| 231 | if (columnsOf(table).has(name)) return; | |
| 232 | db.exec(`ALTER TABLE "${table}" ADD COLUMN ${name} ${declaration}`); | |
| 233 | if (backfill) db.exec(backfill); | |
| 226 | function addColumn(handle: Database, table: string, name: string, declaration: string, backfill?: string): void { | |
| 227 | if (columnsOf(handle, table).has(name)) return; | |
| 228 | handle.exec(`ALTER TABLE "${table}" ADD COLUMN ${name} ${declaration}`); | |
| 229 | if (backfill) handle.exec(backfill); | |
| 234 | 230 | } |
| 235 | 231 | |
| 236 | addColumn("order", "discount_code", "TEXT NOT NULL DEFAULT ''"); | |
| 237 | addColumn("order", "discount_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 238 | addColumn("order", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 239 | addColumn("order", "tax_rate_bp", "INTEGER NOT NULL DEFAULT 0"); | |
| 240 | addColumn("order", "tax_label", "TEXT NOT NULL DEFAULT ''"); | |
| 241 | addColumn("variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 242 | addColumn("order_item", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 232 | function migrate(handle: Database): void { | |
| 233 | addColumn(handle, "order", "discount_code", "TEXT NOT NULL DEFAULT ''"); | |
| 234 | addColumn(handle, "order", "discount_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 235 | addColumn(handle, "order", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 236 | addColumn(handle, "order", "tax_rate_bp", "INTEGER NOT NULL DEFAULT 0"); | |
| 237 | addColumn(handle, "order", "tax_label", "TEXT NOT NULL DEFAULT ''"); | |
| 238 | addColumn(handle, "variant", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 239 | addColumn(handle, "order_item", "gift_card_cents", "INTEGER NOT NULL DEFAULT 0"); | |
| 240 | } | |
| 241 | ||
| 242 | /** | |
| 243 | * Open the database kept in a directory: the directory is made if it is not | |
| 244 | * there, the file is opened, the pragmas are set, the schema is created and | |
| 245 | * the migrations are run. Call it twice with two directories and you have two | |
| 246 | * databases that know nothing of each other. | |
| 247 | */ | |
| 248 | export function open(dir: string): Database { | |
| 249 | mkdirSync(dir, { recursive: true }); | |
| 250 | const handle = new Database(join(dir, "app.db")); | |
| 251 | handle.exec("PRAGMA journal_mode = WAL"); | |
| 252 | handle.exec("PRAGMA foreign_keys = ON"); | |
| 253 | handle.exec("PRAGMA busy_timeout = 5000"); | |
| 254 | handle.exec(SCHEMA); | |
| 255 | migrate(handle); | |
| 256 | return handle; | |
| 257 | } | |
| 258 | ||
| 259 | /** The database this process is working in, and where it lives. */ | |
| 260 | export let dataDir = process.env.FORGE_APP_DATA ?? "./data"; | |
| 261 | export let db = open(dataDir); | |
| 262 | ||
| 263 | /** | |
| 264 | * Work in another database from here on. | |
| 265 | * | |
| 266 | * `bun test` runs every file in one process with one module registry, so this | |
| 267 | * module is evaluated once and the first file to import it would otherwise | |
| 268 | * choose the database for all of them — each file's own `mkdtempSync` would | |
| 269 | * isolate nothing. A test file calls this (see `src/fresh.ts`) before it | |
| 270 | * imports anything that queries. Nothing in the app calls it: an import of | |
| 271 | * `db` is a live binding, so every module follows the swap. | |
| 272 | */ | |
| 273 | export function useDatabase(dir: string): Database { | |
| 274 | const previous = db; | |
| 275 | dataDir = dir; | |
| 276 | process.env.FORGE_APP_DATA = dir; | |
| 277 | db = open(dir); | |
| 278 | previous.close(false); | |
| 279 | return db; | |
| 280 | } | |
| 243 | 281 | |
| 244 | 282 | export type Category = { id: number; slug: string; name: string; blurb: string }; |
| 245 | 283 | export type Product = { |
src/fresh.ts added +26
| @@ -0,0 +1,26 @@ | ||
| 1 | /** | |
| 2 | * A database of this test file's own. | |
| 3 | * | |
| 4 | * `bun test` runs every file in one process with one module registry, so | |
| 5 | * `db.ts` is evaluated exactly once: the first file to import it would | |
| 6 | * otherwise pick the database every other file queries, and a file's own | |
| 7 | * `mkdtempSync` would isolate nothing. Each test file calls this first, before | |
| 8 | * it imports anything that queries, and gets a directory and a database that | |
| 9 | * belong to it alone — in any order the files happen to run in. | |
| 10 | * | |
| 11 | * Only tests call it. The app opens its database once, from FORGE_APP_DATA. | |
| 12 | */ | |
| 13 | ||
| 14 | import { mkdtempSync } from "node:fs"; | |
| 15 | import { tmpdir } from "node:os"; | |
| 16 | import { join } from "node:path"; | |
| 17 | ||
| 18 | export async function freshData(name: string): Promise<string> { | |
| 19 | const dir = mkdtempSync(join(tmpdir(), `bazaar-${name}-`)); | |
| 20 | process.env.FORGE_APP_DATA = dir; | |
| 21 | // Imported here, not at the top: this may be the import that evaluates | |
| 22 | // db.ts, and by now FORGE_APP_DATA says where its database belongs. | |
| 23 | const { useDatabase } = await import("./db.ts"); | |
| 24 | useDatabase(dir); | |
| 25 | return dir; | |
| 26 | } |
src/inventory.ts +13 −6
| @@ -17,16 +17,23 @@ export type StockEntry = { | ||
| 17 | 17 | sku: string; |
| 18 | 18 | }; |
| 19 | 19 | |
| 20 | const record = db.query( | |
| 21 | "INSERT INTO stock_entry (variant_id, delta, reason, order_id) VALUES (?1, ?2, ?3, ?4)", | |
| 22 | ); | |
| 20 | /** The row that says why stock moved. Prepared on the handle in use, never | |
| 21 | * held across one: `db.query` caches the statement on the database itself. */ | |
| 22 | const record = (variantId: number, delta: number, reason: string, orderId?: number): void => { | |
| 23 | db.query("INSERT INTO stock_entry (variant_id, delta, reason, order_id) VALUES (?1, ?2, ?3, ?4)").run( | |
| 24 | variantId, | |
| 25 | delta, | |
| 26 | reason, | |
| 27 | orderId ?? null, | |
| 28 | ); | |
| 29 | }; | |
| 23 | 30 | |
| 24 | 31 | /** Add stock (a delivery, a correction, a restock after a refund). */ |
| 25 | 32 | export function addStock(variantId: number, quantity: number, reason: string, orderId?: number): void { |
| 26 | 33 | if (quantity <= 0) return; |
| 27 | 34 | db.transaction(() => { |
| 28 | 35 | db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, quantity); |
| 29 | record.run(variantId, quantity, reason, orderId ?? null); | |
| 36 | record(variantId, quantity, reason, orderId); | |
| 30 | 37 | })(); |
| 31 | 38 | } |
| 32 | 39 | |
| @@ -41,7 +48,7 @@ export function takeStock(variantId: number, quantity: number, reason: string, o | ||
| 41 | 48 | .query("UPDATE variant SET stock = stock - ?2 WHERE id = ?1 AND stock >= ?2") |
| 42 | 49 | .run(variantId, quantity).changes; |
| 43 | 50 | if (changes !== 1) return false; |
| 44 | record.run(variantId, -quantity, reason, orderId ?? null); | |
| 51 | record(variantId, -quantity, reason, orderId); | |
| 45 | 52 | return true; |
| 46 | 53 | })(); |
| 47 | 54 | } |
| @@ -56,7 +63,7 @@ export function setStock(variantId: number, target: number, reason: string): voi | ||
| 56 | 63 | else |
| 57 | 64 | db.transaction(() => { |
| 58 | 65 | db.query("UPDATE variant SET stock = stock + ?2 WHERE id = ?1").run(variantId, delta); |
| 59 | record.run(variantId, delta, reason, null); | |
| 66 | record(variantId, delta, reason); | |
| 60 | 67 | })(); |
| 61 | 68 | } |
| 62 | 69 |
src/pages/checkout.ts +2 −1
| @@ -5,6 +5,7 @@ | ||
| 5 | 5 | * on the page. The mock is labelled as a mock everywhere it appears. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { HOME_COUNTRY } from "../tax.ts"; | |
| 8 | 9 | import { csrfField, escape } from "../html.ts"; |
| 9 | 10 | import { dueCents, formatCents } from "../money.ts"; |
| 10 | 11 | import { codeBox, totals } from "./cart.ts"; |
| @@ -62,7 +63,7 @@ ${quote.problems.concat(quote.code_problems).map((problem) => `<p class="notice | ||
| 62 | 63 | ${field("city", "City", values.city ?? "", { autocomplete: "address-level2" })} |
| 63 | 64 | ${field("region", "State or region", values.region ?? "", { required: false, autocomplete: "address-level1" })} |
| 64 | 65 | ${field("postal", "Postcode", values.postal ?? "", { autocomplete: "postal-code", maxlength: 20 })} |
| 65 | ${field("country", "Country", values.country ?? "United States", { autocomplete: "country-name" })} | |
| 66 | ${field("country", "Country", values.country ?? HOME_COUNTRY, { autocomplete: "country-name" })} | |
| 66 | 67 | </div> |
| 67 | 68 | <p class="muted" style="font-size:.88rem; margin-top:1rem">Payment is taken next, by ${escape(gateway.label)}.</p> |
| 68 | 69 | <button class="btn" style="margin-top:.5rem">Continue to payment</button> |
src/server.test.ts +45 −4
| @@ -1,9 +1,10 @@ | ||
| 1 | 1 | import { expect, test } from "bun:test"; |
| 2 | import { mkdtempSync } from "node:fs"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | 2 | import { join } from "node:path"; |
| 3 | import { freshData } from "./fresh.ts"; | |
| 5 | 4 | |
| 6 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-")); | |
| 5 | // A database of this file's own, opened before anything that queries is | |
| 6 | // imported — the module registry is shared with every other test file. | |
| 7 | const dataDir = await freshData("server"); | |
| 7 | 8 | process.env.FORGE_APP_IDENTITY_KEY = "the-key-the-forge-signs-with"; |
| 8 | 9 | process.env.PORT = "0"; |
| 9 | 10 | delete process.env.STRIPE_SECRET_KEY; |
| @@ -14,6 +15,7 @@ const { db } = await import("./db.ts"); | ||
| 14 | 15 | const limit = await import("./ratelimit.ts"); |
| 15 | 16 | const giftcards = await import("./giftcards.ts"); |
| 16 | 17 | const discounts = await import("./discounts.ts"); |
| 18 | const tax = await import("./tax.ts"); | |
| 17 | 19 | const base = `http://${server.hostname}:${server.port}`; |
| 18 | 20 | |
| 19 | 21 | type Person = { id: string; name: string; role: "user" | "admin"; expiresMs?: number }; |
| @@ -265,6 +267,8 @@ test("a cart cookie from somewhere else is ignored", async () => { | ||
| 265 | 267 | }); |
| 266 | 268 | |
| 267 | 269 | test("checkout refuses an address it cannot use", async () => { |
| 270 | const orders = (): number => db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM "order"').get()!.n; | |
| 271 | const before = orders(); | |
| 268 | 272 | const shopper = new Shopper(); |
| 269 | 273 | const product = await shopper.page("/p/house-espresso"); |
| 270 | 274 | await shopper.post( |
| @@ -288,7 +292,7 @@ test("checkout refuses an address it cannot use", async () => { | ||
| 288 | 292 | ); |
| 289 | 293 | expect(response.status).toBe(200); |
| 290 | 294 | expect(await response.text()).toContain("does not look right"); |
| 291 | expect(db.query<{ n: number }, []>('SELECT COUNT(*) AS n FROM "order"').get()!.n).toBe(0); | |
| 295 | expect(orders()).toBe(before); // nothing was written, whatever was there already | |
| 292 | 296 | }); |
| 293 | 297 | |
| 294 | 298 | test("a purchase takes stock, records a charge and writes a receipt", async () => { |
| @@ -1093,3 +1097,40 @@ test("a category longer than one page is paged, and every page is reachable", as | ||
| 1093 | 1097 | expect(found).toContain("Page 1 of 2"); |
| 1094 | 1098 | expect(await fetch(base + "/search?q=Bulk+Item&page=2").then((r) => r.text())).toContain("Bulk Item 26"); |
| 1095 | 1099 | }); |
| 1100 | ||
| 1101 | test("the rate the cart estimates is the rate the order charges, from one table", async () => { | |
| 1102 | limit.forget(); | |
| 1103 | // The setting behind the table is set to something else on purpose: what | |
| 1104 | // the cart shows has to come from the row the order will be priced from, | |
| 1105 | // not from a second source that can drift away from it. | |
| 1106 | tax.setDefaultRateBp(2000); | |
| 1107 | tax.upsertRate({ country: tax.HOME_COUNTRY, region: "", rate_bp: 1000, label: "US sales tax", active: true }); | |
| 1108 | try { | |
| 1109 | const shopper = new Shopper(); | |
| 1110 | await addToCart(shopper, "COF-GUJI-250", 2); // $38.00 of goods | |
| 1111 | ||
| 1112 | const cart = await shopper.page("/cart"); | |
| 1113 | expect(cart).toContain("10.00%, estimated"); | |
| 1114 | expect(cart).toContain("$3.80"); // 10% of $38, and not 20% of it | |
| 1115 | expect(cart).not.toContain("20.00%"); | |
| 1116 | expect(await shopper.page("/checkout")).toContain("10.00%"); | |
| 1117 | ||
| 1118 | const placed = await submitCheckout(shopper, { email: "[email protected]" }); | |
| 1119 | expect(placed.status).toBe(303); | |
| 1120 | const order = orderNamed(orderAt(placed.headers.get("location")!).number); | |
| 1121 | expect(order.tax_rate_bp).toBe(1000); | |
| 1122 | expect(order.tax_cents).toBe(380); | |
| 1123 | expect(order.total_cents).toBe(4780); // $38.00 + $6.00 shipping + $3.80 tax | |
| 1124 | } finally { | |
| 1125 | tax.setDefaultRateBp(tax.DEFAULT_RATE_BP); | |
| 1126 | tax.upsertRate({ country: tax.HOME_COUNTRY, region: "", rate_bp: 850, label: "US sales tax", active: true }); | |
| 1127 | } | |
| 1128 | }); | |
| 1129 | ||
| 1130 | test("this file's rows are this file's own", async () => { | |
| 1131 | // The isolation every test file depends on: the database the app modules | |
| 1132 | // are querying is the one this file opened, not one another file chose. | |
| 1133 | const current = await import("./db.ts"); | |
| 1134 | expect(current.dataDir).toBe(dataDir); | |
| 1135 | expect(db.filename).toBe(join(dataDir, "app.db")); | |
| 1136 | }); |
src/shop.ts +2 −1
| @@ -18,6 +18,7 @@ import * as orders from "./orders.ts"; | ||
| 18 | 18 | import * as giftCardPages from "./pages/giftcard.ts"; |
| 19 | 19 | import * as pages from "./pages/checkout.ts"; |
| 20 | 20 | import * as storefront from "./pages/storefront.ts"; |
| 21 | import * as tax from "./tax.ts"; | |
| 21 | 22 | import { |
| 22 | 23 | categories, |
| 23 | 24 | categoryBySlug, |
| @@ -244,7 +245,7 @@ export function checkoutForm(ctx: Ctx, error?: string, values: Record<string, st | ||
| 244 | 245 | return render( |
| 245 | 246 | ctx, |
| 246 | 247 | { title: "Checkout" }, |
| 247 | pages.checkout(quote, ctx.session, chosen(), idempotencyFor(ctx), { country: "United States", ...values }, error), | |
| 248 | pages.checkout(quote, ctx.session, chosen(), idempotencyFor(ctx), { country: tax.HOME_COUNTRY, ...values }, error), | |
| 248 | 249 | ); |
| 249 | 250 | } |
| 250 | 251 |
src/tax.ts +24 −13
| @@ -17,6 +17,15 @@ import { db, getSetting, setSetting, type TaxRate } from "./db.ts"; | ||
| 17 | 17 | export const DEFAULT_RATE_BP = 850; |
| 18 | 18 | export const DEFAULT_LABEL = "Tax"; |
| 19 | 19 | |
| 20 | /** | |
| 21 | * Where a parcel goes until somebody says otherwise: the shop's own country. | |
| 22 | * The checkout form offers it and the cart estimates with it, so the rate on | |
| 23 | * the cart page and the rate the order charges are the same row of the same | |
| 24 | * table — an estimate that is a different number from the bill is a bug. | |
| 25 | */ | |
| 26 | export const HOME_COUNTRY = "United States"; | |
| 27 | export const home = (): Destination => ({ country: HOME_COUNTRY, region: "" }); | |
| 28 | ||
| 20 | 29 | export type Destination = { country: string; region: string }; |
| 21 | 30 | export type Rate = { rate_bp: number; label: string }; |
| 22 | 31 | |
| @@ -37,19 +46,21 @@ export const rates = (): TaxRate[] => | ||
| 37 | 46 | export const rateById = (id: number): TaxRate | null => |
| 38 | 47 | db.query<TaxRate, [number]>("SELECT * FROM tax_rate WHERE id = ?1").get(id); |
| 39 | 48 | |
| 40 | /** The rate for an address, or the default when the table does not know it. */ | |
| 41 | export function rateFor(destination?: Destination): Rate { | |
| 42 | if (destination) { | |
| 43 | const country = normalize(destination.country); | |
| 44 | const region = normalize(destination.region); | |
| 45 | const found = db | |
| 46 | .query<TaxRate, [string, string]>( | |
| 47 | `SELECT * FROM tax_rate WHERE active = 1 AND country = ?1 AND region IN (?2, '') | |
| 48 | ORDER BY region = ?2 DESC LIMIT 1`, | |
| 49 | ) | |
| 50 | .get(country, region); | |
| 51 | if (found) return { rate_bp: found.rate_bp, label: found.label }; | |
| 52 | } | |
| 49 | /** | |
| 50 | * The rate for an address, or the default when the table does not know it. | |
| 51 | * With no address it is the home country's rate — the one the checkout form | |
| 52 | * is about to offer — so an estimate is this same lookup, never a second one. | |
| 53 | */ | |
| 54 | export function rateFor(destination: Destination = home()): Rate { | |
| 55 | const country = normalize(destination.country); | |
| 56 | const region = normalize(destination.region); | |
| 57 | const found = db | |
| 58 | .query<TaxRate, [string, string]>( | |
| 59 | `SELECT * FROM tax_rate WHERE active = 1 AND country = ?1 AND region IN (?2, '') | |
| 60 | ORDER BY region = ?2 DESC LIMIT 1`, | |
| 61 | ) | |
| 62 | .get(country, region); | |
| 63 | if (found) return { rate_bp: found.rate_bp, label: found.label }; | |
| 53 | 64 | return { rate_bp: defaultRateBp(), label: DEFAULT_LABEL }; |
| 54 | 65 | } |
| 55 | 66 |
src/units.test.ts +36 −3
| @@ -2,9 +2,11 @@ import { expect, test } from "bun:test"; | ||
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { join } from "node:path"; |
| 5 | import { freshData } from "./fresh.ts"; | |
| 5 | 6 | |
| 6 | // A database of its own: these tests write rows, and never into a real shop. | |
| 7 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "bazaar-units-")); | |
| 7 | // A database of its own: these tests write rows, and never into a real shop | |
| 8 | // or into another test file's rows, whichever order the files run in. | |
| 9 | const dataDir = await freshData("units"); | |
| 8 | 10 | |
| 9 | 11 | const { dueCents, formatCents, parseCents, parseQuantity, sumLines } = await import("./money.ts"); |
| 10 | 12 | const { encode, decode, put, add, quote } = await import("./cart.ts"); |
| @@ -19,7 +21,8 @@ const desk = await import("./desk.ts"); | ||
| 19 | 21 | const discounts = await import("./discounts.ts"); |
| 20 | 22 | const giftcards = await import("./giftcards.ts"); |
| 21 | 23 | const tax = await import("./tax.ts"); |
| 22 | const { db, forgetPasswordSettings, getSetting, setSetting } = await import("./db.ts"); | |
| 24 | const dbModule = await import("./db.ts"); | |
| 25 | const { db, forgetPasswordSettings, getSetting, setSetting } = dbModule; | |
| 23 | 26 | |
| 24 | 27 | catalog.seed(); |
| 25 | 28 | discounts.seed(); |
| @@ -586,3 +589,33 @@ test("a card's holder is told what happened, not what the shop wrote to itself", | ||
| 586 | 589 | // The shop's own view keeps every word of it. |
| 587 | 590 | expect(giftcards.entriesOf(card.id)[0]!.reason).toBe("chargeback from the buyer, do not honour"); |
| 588 | 591 | }); |
| 592 | ||
| 593 | test("a test file's database is its own, from the factory", () => { | |
| 594 | // bun test shares one process and one module registry across files, so this | |
| 595 | // is the assertion that keeps every other one honest: the database the app | |
| 596 | // modules query is the one this file opened. | |
| 597 | expect(dbModule.dataDir).toBe(dataDir); | |
| 598 | expect(db.filename).toBe(join(dataDir, "app.db")); | |
| 599 | // Every order in here is one this file wrote by hand; an order that went | |
| 600 | // through checkout belongs to the server tests and to their database. | |
| 601 | expect(db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM "order" WHERE access_token <> 'token'`).get()!.n).toBe( | |
| 602 | 0, | |
| 603 | ); | |
| 604 | // Which is what makes the settings this file moves — tax_default_bp among | |
| 605 | // them — safe: they move nothing in any other file's database. | |
| 606 | }); | |
| 607 | ||
| 608 | test("two databases from the factory know nothing of each other", () => { | |
| 609 | const elsewhere = dbModule.open(mkdtempSync(join(tmpdir(), "bazaar-elsewhere-"))); | |
| 610 | try { | |
| 611 | elsewhere.query("INSERT INTO category (slug, name) VALUES ('only-there', 'Only there')").run(); | |
| 612 | const counted = (handle: typeof db): number => | |
| 613 | handle.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM category WHERE slug = 'only-there'").get()!.n; | |
| 614 | expect(counted(elsewhere)).toBe(1); | |
| 615 | expect(counted(db)).toBe(0); | |
| 616 | // And the schema is all there in the new one: it is the same factory. | |
| 617 | expect(elsewhere.query<{ n: number }, []>("SELECT COUNT(*) AS n FROM tax_rate").get()!.n).toBe(0); | |
| 618 | } finally { | |
| 619 | elsewhere.close(false); | |
| 620 | } | |
| 621 | }); |
x