diff --git a/.dev.vars.example b/.dev.vars.example index f792211..05738ac 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,29 +1,18 @@ -# Template for local environment variables. Copy to .dev.vars and fill in real -# values — .dev.vars is gitignored. +# Copy to `.dev.vars` for `wrangler dev`. Never commit `.dev.vars`. # -# cp .dev.vars.example .dev.vars -# -# Local development and tests need no secrets at all: D1, R2 and KV are -# simulated by miniflare, migrations are applied through wrangler, and -# drizzle-kit only generates SQL without connecting to anything. -# -# The variables below are needed once you deploy. Uncomment and fill them in: - -# Stripe payments -# STRIPE_SECRET_KEY= -# STRIPE_WEBHOOK_SECRET= -# NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY= +# Local only: the deployed Worker reads these from Worker secrets, which +# `mallok create` sets with `wrangler secret put`. -# Admin session signing key (generate with: openssl rand -base64 32) -# SESSION_SECRET= +# Signs sessions and encrypts stored plugin keys. Any 32 random bytes: +# openssl rand -hex 32 +MALLOK_SECRET=change-me-to-32-random-bytes -# From address for transactional email -# MAIL_FROM_ADDRESS= +# The one-time key the first-run wizard at /_mallok/setup asks for. Without it +# a local site cannot be claimed. Any random value: +# openssl rand -hex 32 +MALLOK_SETUP_KEY=change-me-to-a-random-setup-key -# Theme, resolved at build time. Leave empty for the default. -# Ships with two: "default" (technical, hairline borders, catalogue-first) and -# "editorial" (serif, warm paper, soft shadows, application-notes-first). -# To add your own: copy a theme directory under a new name, rename its scope -# class in tokens.css and layout/Shell.tsx to match, register it in -# src/themes/registry.ts, then put that name here. -THEME= +# Optional. With a zone-scoped Cache Purge token, an edit is visible +# immediately; without one it is visible when the cached page expires. +# CF_API_TOKEN= +# CF_ZONE_ID= diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..86a63dc --- /dev/null +++ b/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 9f7a049..859bdb3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -15,6 +15,7 @@ labels: bug **Environment** - Local dev or deployed to Cloudflare? +- Mallok version (`package.json`): - Node version: - Browser (if relevant): diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 9faf687..dd19754 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -12,6 +12,8 @@ Describe the situation, not the solution. **What you have considered** **Scope check** -Nundar is a single-tenant storefront template. Multi-tenant SaaS features, -marketplace/multi-vendor flows and B2B quotation are deliberately out of scope — -see the "Out of scope" section of the design spec. +Nundar is a shop plugin and a commerce theme for Mallok, for a single shop on +its own Cloudflare account. Multi-tenant SaaS and marketplace or multi-vendor +flows are deliberately out of scope. Anything about pages, content, languages, +the admin or caching in general belongs to Mallok rather than here — see the +design spec in `docs/superpowers/specs/`. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 31dad2a..cb1920b 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -3,9 +3,10 @@ ## Why ## How it was verified -- [ ] `pnpm test` -- [ ] `pnpm typecheck` -- [ ] `pnpm lint` +- [ ] `npm run lint` +- [ ] `npm run typecheck` +- [ ] `npm test` +- [ ] `npm run smoke:shop` (when the change touches the plugin, the theme or the content) - [ ] Checked in the browser (describe what you clicked) ## Spec impact diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57cdef3..f209304 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,31 +16,29 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: pnpm/action-setup@v6 - - uses: actions/setup-node@v7 with: - node-version: 22 - cache: pnpm + node-version-file: .nvmrc + cache: npm + + # npm, not pnpm: Mallok's CLI installs and upgrades with npm only, and + # refuses a project that carries another manager's lockfile + - run: npm ci - - run: pnpm install --frozen-lockfile + - run: npm run lint - # Runs the exact command the README gives a new contributor: generate - # migrations, create the tables, load the seed data. Using it instead of - # the three underlying commands means CI also proves `pnpm setup` still - # works — the first command someone runs from the docs failing is the most - # discouraging kind of breakage. - # - # The build depends on this: generateStaticParams reads the local D1 for - # the product and use-case lists, so without the tables it fails with - # "no such table". - - run: pnpm setup + - run: npm run typecheck - - run: pnpm typecheck + # The project checks run in Node; the shop plugin's and the theme's tests + # run inside the real workerd runtime, not a Node mock + - run: npm test - - run: pnpm lint + # Stages the admin and theme assets, then a production build + - run: npm run build - # Tests run inside the real workerd runtime, not a Node mock - - run: pnpm test + # A real request to a real Worker: it boots, migrates and renders + - run: npm run smoke - - run: pnpm build + # The whole shop on a real Worker: settings, content published with the + # Mallok CLI, the seed, pages in two languages, the cart + - run: npm run smoke:shop diff --git a/.gitignore b/.gitignore index 5de0435..66402f2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,47 +1,13 @@ -# dependencies node_modules/ -.pnp/ -.pnp.js - -# builds -.next/ -out/ -build/ dist/ -.open-next/ - -# cloudflare .wrangler/ -.dev.vars -.dev.vars.* -!.dev.vars.example -# env +# Local secrets and the record of what was provisioned. Never commit either: +# the first holds credentials, the second names your Cloudflare resources. +.dev.vars .env .env.* -!.env.example - -# typescript -next-env.d.ts -*.tsbuildinfo +.mallok/ -# Generated by build-seed-sql -drizzle/seed.sql - -# test / coverage -coverage/ -playwright-report/ -test-results/ - -# logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* - -# editor / os -.vscode/ -.idea/ .DS_Store -*.swp +*.log diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 643577d..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,9 +0,0 @@ - - -# This is NOT the Next.js you know - -This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. - -This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. - - diff --git a/CLAUDE.md b/CLAUDE.md index 4ce4736..d8c852d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,97 +2,94 @@ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. -@AGENTS.md - ## What this repository is -Nundar is a single-tenant, self-deployed commerce engine for cross-border sellers: Next.js 16 (App Router) compiled for Cloudflare Workers by `@opennextjs/cloudflare`, using D1 (through Drizzle), R2, KV, a Cron trigger and the `send_email` binding. Its differentiator is long-tail SEO: every product carries per-language features and use cases, and a use case can be promoted (`has_own_page`) to its own landing page with a localised slug. +Nundar is a **shop plugin and a commerce theme for Mallok**, packaged as a Mallok site. It is not a standalone application: Mallok (the `mallok` npm package, pinned to an exact version, currently `0.1.0-rc.9`) provides routing, rendering, content, languages, hreflang, the sitemap, the admin, sign-in, media, email and the edge cache. Nundar adds only commerce. + +`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md` is the source of truth for the architecture, with the owner's decisions in its §11. The commerce rules in `docs/superpowers/specs/2026-09-03-nundar-design.md` (§4–§7) still hold; its stack and architecture are superseded. The previous standalone Next.js implementation is at the tag `nextjs-final` — read it for reference, never restore it. -`docs/superpowers/specs/2026-09-03-nundar-design.md` is the source of truth for *why* things are built the way they are. Its §13 records where the implementation deliberately diverged (Stripe hosted Checkout instead of Elements, PBKDF2 instead of Argon2id, no pre-generated image variants, the theme system). The phase plans in `docs/superpowers/plans/` record what each phase uncovered. A change that contradicts a recorded decision updates the spec in the same PR — the PR template asks. +Nundar is in development. Several features wait for extension points Mallok does not have yet (design §7). **Do not work around a missing Mallok capability** — no writing into Mallok's core tables, no `onRequest` hook to intercept pages, no client-side fetching of prices. The Mallok-side work is done by the owner in the Mallok repository; do not edit that repository from a session here. ## Commands ```bash -pnpm install -pnpm setup # copy .dev.vars from the example, generate migrations, migrate + seed the local D1 -pnpm dev # http://localhost:3000/en (also /de, /fr, /es); admin at /admin -pnpm test # full suite, inside workerd -pnpm vitest run tests/lib/money.test.ts # one file -pnpm vitest run tests/lib/orders/state.test.ts -t "oversold" # tests matching a name -pnpm typecheck -pnpm lint -pnpm build # next build; reads the LOCAL D1 (see "Database access") -THEME=editorial pnpm build # build with the second theme -pnpm preview # OpenNext build + local Workers preview +npm ci +npm run build # mallok prepare (stages admin + theme assets) then a deploy dry run +npm run dev # wrangler dev with local D1 and R2; needs .dev.vars +npm run lint # biome check . (npm run lint:fix to apply) +npm run typecheck +npm test # test:project, then test:shop +npm run test:project # node --test: site config and sample content (test/*.test.ts) +npm run test:shop # vitest inside workerd: plugin and theme (test/shop, test/theme) +npx vitest run test/shop/rates.test.ts # one file +npx vitest run test/shop/cart-route.test.ts -t "MOQ" # tests matching a name +npm run smoke # a real request to a real local Worker +npm run smoke:shop # the whole shop end to end on a throwaway local Worker ``` -CI (`.github/workflows/ci.yml`) runs `pnpm setup` → `typecheck` → `lint` → `test` → `build`; all must pass. +CI (`.github/workflows/ci.yml`) runs `npm ci` → lint → typecheck → test → build → smoke → smoke:shop. -- **Schema changes**: edit `src/db/schema/*.ts`, run `pnpm db:generate` (drizzle-kit only writes SQL into `drizzle/migrations/`), then `pnpm db:migrate:local`. Migrations are always applied with `wrangler d1 migrations apply`, never by drizzle-kit. The test database is built from the same migration files, so there is no separate test schema. -- **Bindings**: after changing `wrangler.jsonc`, run `pnpm cf-typegen`. `cloudflare-env.d.ts` is generated — never edit it by hand. -- **D1 commands**, local and remote, reference the binding `DB`, never the database name: a Deploy-button user's database is named after their own project, and wrangler resolves a name through `wrangler.jsonc` even with `--local`. -- **Admin accounts**: `pnpm admin:create you@example.com [--remote]` reads the password from stdin and refuses it as an argument. A deployment with no administrators exposes first-run owner setup at `/admin/setup` (`src/lib/admin/setup.ts`). -- **Deploy**: `pnpm deploy` = migrate local D1 → `opennextjs-cloudflare build` → migrate remote D1 → deploy. -- `vitest-pool-workers` and `next dev` listen on `127.0.0.1`, and `tsx` (every `src/scripts` command, including `pnpm setup` and `pnpm admin:create`) opens an IPC socket; in a sandbox that forbids local listening they fail with `listen EPERM`. +- **npm only.** Mallok's CLI refuses a project with a pnpm or yarn lockfile. Adding a dependency needs npm 11 (`npx npm@11 install …`): npm 10.9.7 crashes while adding some packages to this tree. `npm ci` works with either. +- **Upgrading Mallok** is `npx mallok upgrade --to `, never a hand edit of `package.json`. +- vitest, `wrangler dev`, the smoke scripts and npm itself need to listen on local ports or write outside the project; in a sandbox that forbids that they fail with `listen EPERM` / `EPERM`. ## Architecture -### Runtime shape - -- `src/worker.ts` is the Worker entry (`main` in `wrangler.jsonc`): OpenNext's generated fetch handler, a `scheduled` handler for the daily exchange-rate cron (06:00 UTC), and re-exports of OpenNext's three Durable Object classes. It imports the build artefact `.open-next/worker.js`, so it is excluded from `tsc`; `types/open-next-worker.d.ts` lets `pnpm typecheck` pass on a fresh clone. Keep it wiring only. -- ISR on Cloudflare is configured in `open-next.config.ts`: R2 incremental cache (`NEXT_INC_CACHE_R2_BUCKET`) behind a regional cache, a DO queue, a sharded DO tag cache, and cache purge. The Durable Objects exist only for this. -- `src/app` has three areas: `[locale]/…` (storefront: home, product list, product, use-case landing page, cart, checkout, order status), `admin/…` (`login` and `setup` outside the `(signed-in)` route group, everything else inside it sharing the sidebar layout), and `api/…` route handlers (cart, checkout, inventory, order status, Stripe webhook, image proxy, admin image upload). Plus `sitemap.ts`, `robots.ts` and `.well-known/security.txt`. +### The split -### Where logic lives: the testability split +- `src/worker/index.ts` is the whole site: `createMallok({ theme: nundarTheme, plugins: [inquiry, shop] })`. Theme and plugins are build-time choices. +- `src/plugins/shop/` — commerce logic and nothing about pages. `plugin.json` is the manifest (validated strictly by `definePlugin`: unknown fields are rejected, every declared hook and route needs an implementation and vice versa). `migrations/` holds SQL, `lib/` the logic, `routes/` the HTTP handlers, `index.ts` wires them. +- `src/theme/` — how pages look and nothing else: `theme.json` (kinds, fields, options), `layouts/`, `partials/`, `locales/.json`, `assets/`. It has no tables, no routes and no JavaScript. +- `content/` and `seed/` — the sample catalogue. `site.json` — the site's languages, kinds, navigation and theme options. -Tests run inside the real workerd runtime via `@cloudflare/vitest-pool-workers`, against a stub entry (`tests/worker-entry.ts`), and never go through Next routes — `next/headers`, `next/navigation` and `next/link` cannot load there. Therefore: +Mallok decides the contracts on both sides. Its documentation is the reference: `PLUGIN_API.md`, `THEME_FORMAT.md` and `CONTENT_FORMAT.md` in the Mallok repository (`node_modules/mallok/types/worker.d.ts` has the types). -- Business logic lives in `src/lib/**` as functions that take a `Db` (from `createDb(d1)`) or a binding (`KVNamespace`, R2 bucket) as a parameter. Tests call them with `createDb(env.DB)` from `cloudflare:test`; `tests/apply-migrations.ts` applies the migrations to each test worker's D1; `seedDatabase` from `src/scripts/seed.ts` supplies fixtures. -- Anything that touches Next APIs or Node APIs is a thin separate file over a pure one: `lib/auth/guard.ts` over `lib/auth/session.ts` / `admin.ts`, `lib/admin/locale.ts` over `lib/admin/i18n.ts`, `scripts/write-seed-sql.ts` (`node:fs`) over `scripts/build-seed-sql.ts`. Keep this split for new code; files in `src/app` should stay orchestration (auth guard, Zod parse, call `src/lib`, revalidate). +### The shop plugin -### Database access +- **Tables** are `p_shop_*`, language-independent, keyed by `product_group` — the product content's `translation_group` — so one set of variants serves every language. Timestamps are ISO text, as in Mallok. +- **Data access is raw D1 SQL, no ORM.** Read with one `db.batch`, write with one statement where possible: lists travel as a single JSON parameter through `json_each` (`lib/rates.ts`, `lib/cart-pricing.ts`). D1's Free plan allows 50 queries per invocation, and a tick of Mallok's cron shares its CPU budget across all plugins. +- **`lib/scheduled.ts`** does one small piece of work per minute tick: a repricing chunk in progress, else a rate fetch if due, else clearing expired carts. State between ticks is in `p_shop_state`. +- **Migrations**: additive and idempotent. Mallok's migrator drops whole-line `--` comments, then splits on `;` — a comment must have a line to itself. They run on the Worker's first request, not from a CLI. +- **The cart route** (`routes/cart.ts`, `POST /_mallok/p/shop/cart`) takes a plain form POST and answers 303. The cart cookie is scoped to `/_mallok/p/shop`: Mallok bypasses its edge cache for any public request that carries a cookie. +- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`. -- `getDb()` in dynamic routes, route handlers and server actions; `getDbAsync()` in statically generated pages (`generateStaticParams`, `generateMetadata`, SSG/ISR page bodies) — during static generation the Cloudflare context is only reachable asynchronously, and `getDb()` finds no bindings. -- **The build reads the local D1.** `generateStaticParams` runs on the build machine against miniflare's local database, so local migrations must be applied before `pnpm build` or it fails with `no such table`. Changing `database_id` in `wrangler.jsonc` points miniflare at a different, empty local database. The committed `wrangler.jsonc` carries `local-placeholder-replace-before-deploy` IDs. -- `next.config.ts` pins the build to one serial worker (`experimental.cpus: 1`, `workerThreads: false`) because concurrent build workers crash the local D1. The same file holds the security headers and CSP — edit it surgically, never rewrite it wholesale. +### The theme and content -### Storefront rendering, SEO and themes +- Templates are restricted Liquid. Output is escaped; only `content.html` and `page.head` are emitted verbatim. `page.head` carries hreflang and structured data from Mallok and must stay in `layouts/base.liquid`. +- Interface strings are in `locales/*.json` (flat maps, the default locale is the fallback). Site-specific copy is a theme option; per-language option values go under `themeOptions.$locales` in `site.json`. +- **References resolve by slug within the same language.** An `application` names its `product`; the product page lists them through `content.backrefs.application`. A `product` names its `collection`; the collection page lists `content.backrefs.product`. Mallok resolves `reference` only, not `reference[]`. +- Each language of a bundle is its own content item with its own `slug` (`index.md`, `index..md`); Mallok puts them in one `translation_group`. A bundle carries a `mallok.json` only when something outside the content must name it: the product's fixes the `translation_group` that `seed/shop-sample.sql` attaches variants to. `test/content.test.ts` checks the reference rule and keeps any identity file in step with its bundle. +- The default language (English) is unprefixed; others are `//…`. English pages default to USD, the rest to EUR (`lib/currency.ts`), never by IP. -- Home, list, product and use-case pages are statically generated with ISR. Admin server actions must `revalidatePath` every affected storefront path in every locale plus `/sitemap.xml` (see `revalidateProduct` in `app/admin/(signed-in)/products/[slug]/actions.ts`), otherwise edits never appear in production. -- Stock and prices baked into static HTML go stale, so `components/LiveStock.tsx` fetches `/api/inventory` after hydration and patches DOM nodes marked `data-variant-id` / `data-price` / `data-stock`. The visitor's currency (a cookie) is also applied client-side. -- **Route layer vs theme** (`src/themes/contract.ts`): routes fetch data, emit metadata, hreflang and canonicals (`lib/seo.ts`) and JSON-LD (`lib/seo/jsonld.ts` via `components/JsonLd`), build every URL (`lib/site-urls.ts`), and supply interface strings (`lib/storefront/i18n.ts`). A theme (`src/themes//`: `tokens.css`, `layout/`, `views/`) only renders, and owns its own voice (hero copy, headings). Never move SEO, URL building or commerce vocabulary into a theme. The contract types are a public interface: changing one breaks every theme. -- Theme rules TypeScript cannot enforce: product views must emit the `data-*` attributes above; theme CSS is scoped under `.theme-` and never `:root` (all registered themes share one bundle), with the Shell's root class matching. `tests/themes/contract.test.ts` checks this against the sources `vitest.config.mts` reads from disk. The theme is chosen at build time via `THEME` in `themes/registry.ts` (unknown names fall back to `default`); a new theme must be registered there. +### Known limits of mallok 0.1.0-rc.9 that shape the code -### Languages +- No render-time hook with database access, so prices and variants are not on pages yet. +- Plugin routes cannot render through the theme, so there is no cart page yet. +- Plugin admin panels are read-only tables; variants are seeded from `seed/shop-sample.sql`. +- `reference[]` fields are not resolved, so a product names one collection. -- Storefront locales (`en` default and `x-default`, `de`, `fr`, `es`) live in `src/config/locales.ts`. The URL prefix is the only source of language; never redirect or rewrite language or currency by IP — crawlers fetch mostly from US addresses and would see only one language. Currency defaults per locale and is otherwise the visitor's cookie choice. -- Translated content lives in translation tables or per-locale rows; never add `name_en` / `name_de` style columns. Features and use cases have one row per locale linked by `group_key`. Use-case slugs are localised, so hreflang resolves through `group_key`, and a language with no row is omitted rather than given another language's slug. -- The admin interface language (`en` authoritative, `zh`; `lib/admin/i18n.ts`, chosen by cookie) is a separate axis from storefront locales — don't share configuration between them. In both string catalogues the `en` entry defines the type, so every key must exist in every locale. -- Transactional email is rendered in the language stored on `orders.locale` (`lib/email/templates.ts`). +Each is a task in Mallok's plan for plugin API 2. When Mallok ships one, upgrade, remove the corresponding limitation here, and prove the new behaviour with a test or the smoke run. -### Commerce invariants (security controls — see SECURITY.md; do not "simplify" them away) +## Testing -- Money is always integer minor units (`*_minor`, `lib/money.ts`), never a float. -- The KV cart stores variant ids and quantities only. `priceCart` (`lib/cart/pricing.ts`) recomputes prices from D1 and re-validates availability, MOQ and stock at checkout; no request body carries an amount. -- Checkout (`api/checkout`) creates a `pending` order whose lines are snapshots, then a Stripe hosted Checkout session over plain `fetch` (`lib/stripe/client.ts`, no SDK). -- Only the Stripe webhook marks an order paid (`markOrderPaid` in `lib/orders/orders.ts`): verify the signature over the raw body, dedupe on `stripe_events`, decrement stock with `WHERE stock >= qty`, compensate manually on partial failure (there is no transaction spanning those statements), and mark the order `oversold` rather than let stock go negative. Return non-2xx on a genuine failure so Stripe redelivers, 200 for ignored or duplicate events; an email failure never changes the response. -- Order status changes only through the transitions in `lib/orders/state.ts`. -- MOQ is enforced on the product page, at add-to-cart, and server-side at checkout. -- Pricing: USD is the only hand-entered base price. EUR/GBP rows are `auto` (ECB rate × buffer, psychological rounding, recomputed only past a drift threshold) or `manual` (never overwritten). The cron path is `lib/pricing/cron.ts` → `ecb.ts` → `recalculate.ts`. -- Public APIs are rate-limited with KV fixed windows keyed on `cf-connecting-ip`, never `x-forwarded-for` (budgets in `lib/security/rate-limit.ts`). Every external input passes Zod. Logs never contain PII — log order ids, not emails or addresses. -- Admin auth: PBKDF2 via WebCrypto (`lib/auth/password.ts`; self-describing hash format with `needsRehash`), opaque session tokens in KV, roles `owner` / `staff` enforced by `requireAdmin` / `requireOwner`. Login is rate-limited and answers identically for an unknown account and a wrong password. -- Markdown is HTML-escaped before it is parsed (`lib/markdown.ts`). -- Security headers and the CSP are in `next.config.ts`; any new third-party origin (fonts, scripts, APIs) needs a CSP entry. R2 images are served through `api/images/[...key]` (private bucket, `products/` prefix, traversal guard); uploads are validated by magic bytes (`lib/media/images.ts`). -- Dependencies are deliberately few — Stripe and ECB over `fetch`, crypto via WebCrypto. Ask whether the platform already provides something before adding a package. +- Tests in `test/shop` and `test/theme` run inside workerd against the site's own Worker. `test/shop/helpers.ts` brings a site up through Mallok's HTTP API (first request → admin → token → settings → plugin enabled); tables are created by Mallok's migrator, never by hand. Create content with `createContent`, not by inserting rows. +- Mallok caches pages in `caches.default`. In a test, create all content before requesting any page. +- A test for a fix must be seen failing without the fix. For new guards, break the guard and confirm the test goes red. +- `npm run smoke:shop` is the only place theme, plugin, content and the Mallok CLI run together; run it when touching any of them. -### Admin UI +## Commerce invariants (do not "simplify" them away) -The admin is built on shadcn/ui (`components.json` style `base-nova`, whose primitives come from `@base-ui/react`, not Radix), with components in `src/components/ui`. The storefront does not use it; its look belongs to the theme. +- Money is integer minor units (`lib/money.ts`), never a float. +- A cart line is a variant and a quantity, never a price; `priceCart` recomputes from the database and reports every problem at once. +- MOQ and stock are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. +- `stock` carries `CHECK (stock >= 0)`. `test/shop/schema.test.ts` proves a failing decrement rolls back its whole D1 batch, and that `WHERE stock >= qty` does not — the payment write must rely on the constraint. +- A `manual` price is never overwritten; the base price is never rewritten; an `auto` price moves only past the drift threshold. +- Order status changes only through `lib/order-state.ts`. +- Every external input passes Zod; every SQL value is bound; redirects go only to same-site paths; logs carry no personal data. ## Conventions -- Everything in the repository is English: code comments, docs, commit messages, and source UI strings (`en` is the authoritative locale for storefront and admin). `README.md` is authoritative; `README.zh-CN.md` is a derived translation, so change the English first. -- Comments explain *why*, often naming the defect that motivated the code. Match that density and keep comments accurate when the code changes. +- Everything in the repository is English: code, comments, docs, commit messages. `README.md` is authoritative; `README.zh-CN.md` is a derived translation. +- Comments explain *why*, often naming the defect or the platform limit behind the code. Match that density. +- Biome formats and lints (single quotes, semicolons, 80 columns). Named exports; `import type`; no `any`. - Conventional Commits, one concern per commit. -- `.dev.vars` (gitignored, created from `.dev.vars.example`) holds `THEME`, the Stripe keys and `MAIL_FROM_ADDRESS`. `NEXT_PUBLIC_SITE_URL` drives every canonical and hreflang (`src/config/site.ts`). -- The `cloudflare.bindings` block in `package.json` supplies the Deploy to Cloudflare form descriptions; keep it in sync when adding a binding or secret. The R2 media binding must not be named `IMAGES` (reserved by Cloudflare Images). `compatibility_date` must not exceed what the local workerd supports, or the test pool refuses to start. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d1e152..260cac5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,31 +5,52 @@ Thanks for taking the time to contribute. ## Getting set up ```bash -pnpm install -pnpm setup # generates migrations, creates the local DB, loads sample data -pnpm dev +npm ci +npm run build # stages the admin and the theme's assets +npm run smoke:shop # the whole shop on a throwaway local Worker ``` -No Cloudflare account is needed for local development — D1, R2 and KV are all -simulated locally by miniflare. +No Cloudflare account is needed for local development: D1 and R2 are simulated +locally. The README has the steps for a local shop you can browse. + +**npm, not pnpm or yarn.** Nundar is a Mallok site, and Mallok's CLI installs +and upgrades with npm only; it refuses a project that carries another +manager's lockfile. + +**Adding a dependency needs npm 11.** npm 10.9.7, which ships with Node 22, +fails while adding some packages to this tree +(`Cannot read properties of null (reading 'edgesOut')`). Use +`npx npm@11 install `. `npm ci` works with either version. ## Before you open a pull request ```bash -pnpm test # runs in the real Workers runtime, not a Node mock -pnpm typecheck -pnpm lint +npm run lint +npm run typecheck +npm test # project checks, then tests inside the real Workers runtime +npm run smoke:shop # when you touched the plugin, the theme or the content ``` -All three must pass. New logic needs matching tests. +All must pass. New logic needs matching tests, and a test for a fix has to be +seen failing without the fix. + +## Where things go + +| Change | Place | +|---|---| +| Prices, stock, the cart, anything about buying | The shop plugin, `src/plugins/shop/` | +| How a page looks, interface strings | The commerce theme, `src/theme/` | +| Pages, content, languages, the admin, caching in general | Not here: that is [Mallok](https://github.com/fobstack/mallok) | + +If a change needs something Mallok does not offer, the answer is an extension +point in Mallok, not a workaround here. ## Design decisions live in `docs/` Before changing anything structural, read -`docs/superpowers/specs/2026-09-03-nundar-design.md`. It records not just what -the system does but **why** — the reasoning behind decisions like storing money -as integer minor units, decrementing stock only after payment, and never -redirecting by IP. +`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`. It records not +just what the system does but **why**. The commerce decisions in the original +spec, `2026-09-03-nundar-design.md`, still hold. If your change contradicts a decision recorded there, update the spec in the same pull request and explain the new reasoning. @@ -39,16 +60,18 @@ same pull request and explain the new reasoning. | Area | Rule | |---|---| | Money | Always integer minor units (`amount_minor`). Never floats. | -| Language | Determined solely by the URL prefix. Never redirect or rewrite by IP — it stops crawlers from seeing other languages. | -| Translations | Split into `*_translations` tables. Never add `name_en` / `name_de` style columns. | -| Content blocks | Features and use cases match across languages via `group_key`. | -| Order status | Only the transitions in `src/lib/orders/state.ts` are legal. | -| Stock | Decremented after payment is confirmed, never at add-to-cart or order creation. | -| Webhooks | Must be idempotent. Stripe redelivers. | -| Secrets | Never in the repo. Use `wrangler secret` or `.dev.vars`. | -| Remote D1 commands | Reference the **binding** (`DB`), never the database name. Someone deploying via the button gets a database named after their own project, and a hardcoded name breaks their migrations. | -| Prices | Never accepted from the client. Recomputed server-side at checkout, always. | -| Dependencies | Ask whether the platform already provides it. Every dependency is inherited attack surface. | +| Prices | Never accepted from a client. The cart stores variants and quantities only. | +| MOQ | Enforced by the form and again by the server. A form can be bypassed. | +| Stock | Protected by `CHECK (stock >= 0)`. A decrement that would oversell fails, and rolls back the whole D1 batch it is part of — keep it that way rather than relying on `WHERE stock >= qty`, which matches no row without failing. | +| Manual prices | Never overwritten by an exchange-rate refresh. | +| Language | Decided by the URL alone. Never redirect or switch by IP — crawlers would see one language. | +| References | A `reference` field names the target's slug **in the same language**. `test/content.test.ts` checks the sample content. | +| Bundle identity | A bundle needs a `mallok.json` only when something outside the content names it: the sample variants attach to the product by its translation group. Where the file exists, `test/content.test.ts` keeps it in step with the bundle. | +| Database access | Raw SQL through D1, no ORM. Batch reads and writes: a tick of the cron shares one invocation's CPU budget with every other plugin. | +| Migrations | Additive only, idempotent, and a comment has a line to itself — Mallok's migrator drops whole-line comments and then splits on semicolons. | +| Theme | No ` }) { - const json = JSON.stringify(data).replace(/<\/script/gi, "<\\/script"); - - return ( - ', - }, - ], - }); - - expect(nasty.html).not.toContain("'); - - expect(html).not.toContain(""); - expect(sniffImageType(html)).toBeNull(); - }); - - it("returns null for an SVG, which can carry script", () => { - const svg = new TextEncoder().encode(''); - expect(sniffImageType(svg)).toBeNull(); - }); -}); - -describe("validateImage", () => { - it("accepts a real JPEG", () => { - const result = validateImage(JPEG, "image/jpeg"); - expect(result.ok).toBe(true); - if (!result.ok) return; - expect(result.contentType).toBe("image/jpeg"); - }); - - it("rejects an empty file", () => { - const result = validateImage(new Uint8Array(0), "image/png"); - expect(result.ok).toBe(false); - }); - - it("rejects a file over the size limit", () => { - const big = new Uint8Array(MAX_IMAGE_BYTES + 1); - big.set([0xff, 0xd8, 0xff]); - - const result = validateImage(big, "image/jpeg"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/exceeds/i); - }); - - it("rejects HTML disguised as an image, whatever it claims to be", () => { - const html = new TextEncoder().encode(""); - - // A client-declared Content-Type can say anything. A file stored as an image - // that is really HTML becomes an XSS vector once served from our own origin. - const result = validateImage(html, "image/png"); - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/not a supported image/i); - }); - - it("rejects a file whose declared type contradicts its actual bytes", () => { - const result = validateImage(PNG, "image/jpeg"); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/declared/i); - }); - - it("trusts the sniffed type when the declaration is missing or odd", () => { - const result = validateImage(JPEG, "application/octet-stream"); - expect(result.ok).toBe(true); - }); -}); - -describe("buildImageKey", () => { - it("uses the product slug so the filename carries meaning for image SEO", () => { - const key = buildImageKey("stainless-ball-valve-dn50", 1, "image/jpeg"); - expect(key).toBe( - "products/stainless-ball-valve-dn50/stainless-ball-valve-dn50-01.jpg", - ); - }); - - it("pads the sequence so keys sort correctly", () => { - expect(buildImageKey("valve", 2, "image/png")).toContain("valve-02.png"); - expect(buildImageKey("valve", 12, "image/png")).toContain("valve-12.png"); - }); - - it("strips characters that could escape the intended prefix", () => { - const key = buildImageKey("../../etc/passwd", 1, "image/png"); - - expect(key).not.toContain(".."); - expect(key).not.toContain("etc/passwd"); - expect(key.startsWith("products/")).toBe(true); - }); - - it("refuses a slug that sanitises down to nothing", () => { - expect(() => buildImageKey("../..", 1, "image/png")).toThrow(/slug/i); - }); - - it("maps every allowed type to an extension", () => { - for (const type of ALLOWED_IMAGE_TYPES) { - expect(buildImageKey("valve", 1, type)).toMatch(/\.(jpg|png|webp|avif)$/); - } - }); -}); - -describe("isAllowedImageType", () => { - it("rejects SVG, which can execute script when served inline", () => { - expect(isAllowedImageType("image/svg+xml")).toBe(false); - }); - - it("accepts the four shipped raster formats", () => { - for (const type of ALLOWED_IMAGE_TYPES) { - expect(isAllowedImageType(type)).toBe(true); - } - }); -}); - -describe("imageUrl", () => { - it("serves through the app rather than exposing the bucket", () => { - expect(imageUrl("products/valve/valve-01.jpg")).toBe( - "/api/images/products/valve/valve-01.jpg", - ); - }); -}); diff --git a/tests/lib/money.test.ts b/tests/lib/money.test.ts deleted file mode 100644 index bbd6e65..0000000 --- a/tests/lib/money.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - formatMoney, - fromMinor, - multiplyMinor, - sumMinor, - toMinor, -} from "@/lib/money"; - -describe("toMinor", () => { - it("converts a decimal amount to integer minor units", () => { - expect(toMinor(99, "USD")).toBe(9900); - expect(toMinor(99.5, "USD")).toBe(9950); - expect(toMinor(0.01, "USD")).toBe(1); - }); - - it("rounds rather than truncates float artefacts", () => { - // Under IEEE 754, 1.005 is really 1.00499999..., and truncating gives 100 - expect(toMinor(1.005, "USD")).toBe(101); - }); - - it("rejects non-finite input instead of producing NaN money", () => { - expect(() => toMinor(Number.NaN, "USD")).toThrow(/finite/i); - expect(() => toMinor(Number.POSITIVE_INFINITY, "USD")).toThrow(/finite/i); - }); -}); - -describe("fromMinor", () => { - it("converts integer minor units back to a decimal amount", () => { - expect(fromMinor(9900, "USD")).toBe(99); - expect(fromMinor(9199, "EUR")).toBe(91.99); - }); - - it("rejects non-integer minor units", () => { - expect(() => fromMinor(99.5, "USD")).toThrow(/integer/i); - }); -}); - -describe("multiplyMinor", () => { - it("keeps the result an integer", () => { - expect(multiplyMinor(9900, 0.92)).toBe(9108); - expect(multiplyMinor(9900, 1.03)).toBe(10197); - }); - - it("rounds half away from zero", () => { - expect(multiplyMinor(101, 0.5)).toBe(51); - }); -}); - -describe("sumMinor", () => { - it("adds integer amounts without float drift", () => { - expect(sumMinor([1010, 2020, 3030])).toBe(6060); - expect(sumMinor([])).toBe(0); - }); -}); - -describe("formatMoney", () => { - it("formats per locale and currency", () => { - // Assert on the essential fragments only, so the test does not pin down where - // a given ICU version puts spaces and symbols - const usd = formatMoney(9900, "USD", "en"); - expect(usd).toContain("99"); - expect(usd).toContain("$"); - - const eur = formatMoney(9199, "EUR", "de"); - expect(eur).toContain("91,99"); - expect(eur).toContain("€"); - }); -}); diff --git a/tests/lib/orders/admin.test.ts b/tests/lib/orders/admin.test.ts deleted file mode 100644 index ef28b10..0000000 --- a/tests/lib/orders/admin.test.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { env } from "cloudflare:test"; -import { eq } from "drizzle-orm"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import * as schema from "@/db/schema"; -import { priceCart } from "@/lib/cart/pricing"; -import { - cancelOrder, - getOrder, - listOrders, - markDelivered, - refundOrder, - shipOrder, -} from "@/lib/orders/admin"; -import { createPendingOrder, markOrderPaid } from "@/lib/orders/orders"; -import { seedDatabase } from "@/scripts/seed"; - -const THREADED = "seed-variant-dn50-threaded"; - -const ADDRESS = { - recipient: "Jane Buyer", - line1: "1 Harbour Road", - city: "Aberdeen", - postalCode: "AB11 5RY", - country: "GB", -}; - -async function makeOrder(quantity = 10) { - const cart = await priceCart( - createDb(env.DB), - [{ variantId: THREADED, quantity }], - "en", - "USD", - ); - if (!cart.ok) throw new Error("fixture cart should price cleanly"); - - return createPendingOrder(createDb(env.DB), { - cart, - locale: "en", - shippingAddress: ADDRESS, - customerId: null, - }); -} - -async function stockOf(variantId: string) { - const [row] = await createDb(env.DB) - .select() - .from(schema.productVariants) - .where(eq(schema.productVariants.id, variantId)); - return row.stock; -} - -beforeEach(async () => { - await env.DB.exec("DELETE FROM inventory_adjustments"); - await env.DB.exec("DELETE FROM stripe_events"); - await env.DB.exec("DELETE FROM order_items"); - await env.DB.exec("DELETE FROM orders"); - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); - await seedDatabase(createDb(env.DB)); -}); - -describe("listOrders", () => { - it("lists newest first", async () => { - await makeOrder(); - await makeOrder(); - - const orders = await listOrders(createDb(env.DB)); - expect(orders).toHaveLength(2); - expect(orders[0].createdAt).toBeGreaterThanOrEqual(orders[1].createdAt); - }); - - it("filters by status", async () => { - const first = await makeOrder(); - await makeOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: first.id, - eventId: "evt_1", - paymentIntentId: "pi_1", - }); - - const paid = await listOrders(createDb(env.DB), { status: "paid" }); - expect(paid).toHaveLength(1); - expect(paid[0].orderNo).toBe(first.orderNo); - }); -}); - -describe("getOrder", () => { - it("returns the order with items and parsed address", async () => { - const created = await makeOrder(); - const order = await getOrder(createDb(env.DB), created.orderNo); - - expect(order).not.toBeNull(); - expect(order!.items).toHaveLength(1); - expect(order!.shippingAddress.city).toBe("Aberdeen"); - }); - - it("returns null for an unknown order number", async () => { - expect(await getOrder(createDb(env.DB), "ND-NOPE")).toBeNull(); - }); - - it("survives a corrupt stored address", async () => { - const created = await makeOrder(); - await env.DB.exec( - `UPDATE orders SET shipping_address_json = 'not json' WHERE id = '${created.id}'`, - ); - - const order = await getOrder(createDb(env.DB), created.orderNo); - expect(order!.shippingAddress).toEqual({}); - }); -}); - -describe("shipOrder", () => { - async function paidOrder() { - const created = await makeOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: created.id, - eventId: `evt_${created.id}`, - paymentIntentId: `pi_${created.id}`, - }); - return created; - } - - it("records the tracking number and moves to shipped", async () => { - const created = await paidOrder(); - await shipOrder(createDb(env.DB), created.id, "TRACK-123"); - - const order = await getOrder(createDb(env.DB), created.orderNo); - expect(order!.status).toBe("shipped"); - expect(order!.trackingNo).toBe("TRACK-123"); - }); - - it("refuses to ship without a tracking number", async () => { - const created = await paidOrder(); - await expect( - shipOrder(createDb(env.DB), created.id, " "), - ).rejects.toThrow(/tracking number/i); - }); - - it("refuses to ship an unpaid order", async () => { - const created = await makeOrder(); - await expect( - shipOrder(createDb(env.DB), created.id, "TRACK-123"), - ).rejects.toThrow(/transition/i); - }); -}); - -describe("cancelOrder", () => { - it("cancels a pending order", async () => { - const created = await makeOrder(); - await cancelOrder(createDb(env.DB), created.id); - - expect((await getOrder(createDb(env.DB), created.orderNo))!.status).toBe( - "cancelled", - ); - }); - - it("refuses to cancel a paid order", async () => { - const created = await makeOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: created.id, - eventId: "evt_c", - paymentIntentId: "pi_c", - }); - - await expect(cancelOrder(createDb(env.DB), created.id)).rejects.toThrow( - /transition/i, - ); - }); -}); - -describe("refundOrder", () => { - it("returns the stock that was taken when the order was paid", async () => { - const before = await stockOf(THREADED); - const created = await makeOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: created.id, - eventId: "evt_r", - paymentIntentId: "pi_r", - }); - expect(await stockOf(THREADED)).toBe(before - 10); - - await refundOrder(createDb(env.DB), created.id); - - expect(await stockOf(THREADED)).toBe(before); - expect((await getOrder(createDb(env.DB), created.orderNo))!.status).toBe( - "refunded", - ); - }); - - it("writes a refund adjustment for the audit trail", async () => { - const created = await makeOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: created.id, - eventId: "evt_r2", - paymentIntentId: "pi_r2", - }); - await refundOrder(createDb(env.DB), created.id); - - const adjustments = await createDb(env.DB) - .select() - .from(schema.inventoryAdjustments) - .where(eq(schema.inventoryAdjustments.refId, created.id)); - - expect(adjustments.map((a) => a.reason).sort()).toEqual([ - "order_paid", - "refund", - ]); - }); - - it("does not invent stock when refunding an oversold order", async () => { - const created = await makeOrder(); - await env.DB.exec( - `UPDATE product_variants SET stock = 1 WHERE id = '${THREADED}'`, - ); - await markOrderPaid(createDb(env.DB), { - orderId: created.id, - eventId: "evt_over", - paymentIntentId: "pi_over", - }); - - const stockBeforeRefund = await stockOf(THREADED); - await refundOrder(createDb(env.DB), created.id); - - // An oversold order never took the stock, so a refund must not conjure it back - expect(await stockOf(THREADED)).toBe(stockBeforeRefund); - }); - - it("refuses to refund a pending order", async () => { - const created = await makeOrder(); - await expect(refundOrder(createDb(env.DB), created.id)).rejects.toThrow( - /transition/i, - ); - }); -}); - -describe("markDelivered", () => { - it("moves a shipped order to delivered", async () => { - const created = await makeOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: created.id, - eventId: "evt_d", - paymentIntentId: "pi_d", - }); - await shipOrder(createDb(env.DB), created.id, "T-1"); - await markDelivered(createDb(env.DB), created.id); - - expect((await getOrder(createDb(env.DB), created.orderNo))!.status).toBe( - "delivered", - ); - }); -}); diff --git a/tests/lib/orders/orders.test.ts b/tests/lib/orders/orders.test.ts deleted file mode 100644 index e0d2b1d..0000000 --- a/tests/lib/orders/orders.test.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { env } from "cloudflare:test"; -import { eq } from "drizzle-orm"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import * as schema from "@/db/schema"; -import { createPendingOrder, markOrderPaid } from "@/lib/orders/orders"; -import { priceCart } from "@/lib/cart/pricing"; -import { seedDatabase } from "@/scripts/seed"; - -const THREADED = "seed-variant-dn50-threaded"; - -const ADDRESS = { - recipient: "Jane Buyer", - line1: "1 Harbour Road", - city: "Aberdeen", - postalCode: "AB11 5RY", - country: "GB", -}; - -async function pricedCart(quantity = 10) { - const result = await priceCart( - createDb(env.DB), - [{ variantId: THREADED, quantity }], - "en", - "USD", - ); - if (!result.ok) { - throw new Error("fixture cart should price cleanly"); - } - return result; -} - -async function orderById(id: string) { - const [row] = await createDb(env.DB) - .select() - .from(schema.orders) - .where(eq(schema.orders.id, id)); - return row; -} - -async function stockOf(variantId: string) { - const [row] = await createDb(env.DB) - .select() - .from(schema.productVariants) - .where(eq(schema.productVariants.id, variantId)); - return row.stock; -} - -beforeEach(async () => { - await env.DB.exec("DELETE FROM inventory_adjustments"); - await env.DB.exec("DELETE FROM stripe_events"); - await env.DB.exec("DELETE FROM order_items"); - await env.DB.exec("DELETE FROM orders"); - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); - await seedDatabase(createDb(env.DB)); -}); - -describe("createPendingOrder", () => { - it("creates the order in pending with a readable order number", async () => { - const cart = await pricedCart(); - const order = await createPendingOrder(createDb(env.DB), { - cart, - locale: "en", - shippingAddress: ADDRESS, - customerId: null, - }); - - const row = await orderById(order.id); - expect(row.status).toBe("pending"); - expect(row.orderNo).toMatch(/^ND-/); - expect(row.totalMinor).toBe(99_000); - expect(row.currency).toBe("USD"); - }); - - it("snapshots SKU, name and unit price on each line", async () => { - const cart = await pricedCart(); - const order = await createPendingOrder(createDb(env.DB), { - cart, - locale: "en", - shippingAddress: ADDRESS, - customerId: null, - }); - - const [item] = await createDb(env.DB) - .select() - .from(schema.orderItems) - .where(eq(schema.orderItems.orderId, order.id)); - - expect(item.skuSnapshot).toBe("BV-316L-DN50-NPT"); - expect(item.nameSnapshot).toBe("Stainless Steel Ball Valve DN50"); - expect(item.unitPriceMinor).toBe(9900); - }); - - it("does not touch stock — that happens only after payment", async () => { - const before = await stockOf(THREADED); - const cart = await pricedCart(); - - await createPendingOrder(createDb(env.DB), { - cart, - locale: "en", - shippingAddress: ADDRESS, - customerId: null, - }); - - expect(await stockOf(THREADED)).toBe(before); - }); - - it("records the locale so notification emails go out in the buyer's language", async () => { - const cart = await pricedCart(); - const order = await createPendingOrder(createDb(env.DB), { - cart, - locale: "de", - shippingAddress: ADDRESS, - customerId: null, - }); - - expect((await orderById(order.id)).locale).toBe("de"); - }); -}); - -describe("markOrderPaid", () => { - async function pendingOrder() { - const cart = await pricedCart(); - return createPendingOrder(createDb(env.DB), { - cart, - locale: "en", - shippingAddress: ADDRESS, - customerId: null, - }); - } - - it("moves the order to paid and decrements stock", async () => { - const order = await pendingOrder(); - const before = await stockOf(THREADED); - - const result = await markOrderPaid(createDb(env.DB), { - orderId: order.id, - eventId: "evt_1", - paymentIntentId: "pi_1", - }); - - expect(result.status).toBe("paid"); - expect(await stockOf(THREADED)).toBe(before - 10); - }); - - it("writes an inventory adjustment for the audit trail", async () => { - const order = await pendingOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: order.id, - eventId: "evt_1", - paymentIntentId: "pi_1", - }); - - const adjustments = await createDb(env.DB) - .select() - .from(schema.inventoryAdjustments); - - expect(adjustments).toHaveLength(1); - expect(adjustments[0].delta).toBe(-10); - expect(adjustments[0].reason).toBe("order_paid"); - expect(adjustments[0].refId).toBe(order.id); - }); - - it("is idempotent: a redelivered event must not decrement stock twice", async () => { - const order = await pendingOrder(); - const before = await stockOf(THREADED); - - await markOrderPaid(createDb(env.DB), { - orderId: order.id, - eventId: "evt_dup", - paymentIntentId: "pi_1", - }); - const second = await markOrderPaid(createDb(env.DB), { - orderId: order.id, - eventId: "evt_dup", - paymentIntentId: "pi_1", - }); - - expect(second.status).toBe("paid"); - expect(second.alreadyProcessed).toBe(true); - expect(await stockOf(THREADED)).toBe(before - 10); - }); - - it("marks the order oversold when stock ran out between order and payment", async () => { - const order = await pendingOrder(); - await env.DB.exec( - `UPDATE product_variants SET stock = 2 WHERE id = '${THREADED}'`, - ); - - const result = await markOrderPaid(createDb(env.DB), { - orderId: order.id, - eventId: "evt_oversold", - paymentIntentId: "pi_2", - }); - - expect(result.status).toBe("oversold"); - // Insufficient stock must never decrement below zero - expect(await stockOf(THREADED)).toBe(2); - }); - - it("records the payment intent id on the order", async () => { - const order = await pendingOrder(); - await markOrderPaid(createDb(env.DB), { - orderId: order.id, - eventId: "evt_1", - paymentIntentId: "pi_abc", - }); - - expect((await orderById(order.id)).stripePaymentIntentId).toBe("pi_abc"); - }); - - it("refuses to pay an order that is already shipped", async () => { - const order = await pendingOrder(); - await env.DB.exec( - `UPDATE orders SET status = 'shipped' WHERE id = '${order.id}'`, - ); - - await expect( - markOrderPaid(createDb(env.DB), { - orderId: order.id, - eventId: "evt_late", - paymentIntentId: "pi_3", - }), - ).rejects.toThrow(/transition/i); - }); - - it("throws for an unknown order rather than silently succeeding", async () => { - await expect( - markOrderPaid(createDb(env.DB), { - orderId: "no-such-order", - eventId: "evt_x", - paymentIntentId: "pi_x", - }), - ).rejects.toThrow(/not found/i); - }); -}); diff --git a/tests/lib/orders/state.test.ts b/tests/lib/orders/state.test.ts deleted file mode 100644 index 493b3f9..0000000 --- a/tests/lib/orders/state.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - assertTransition, - canTransition, - ORDER_STATUSES, - type OrderStatus, -} from "@/lib/orders/state"; - -describe("canTransition", () => { - it("walks the happy path", () => { - expect(canTransition("pending", "paid")).toBe(true); - expect(canTransition("paid", "shipped")).toBe(true); - expect(canTransition("shipped", "delivered")).toBe(true); - }); - - it("allows refunds from paid, shipped and delivered", () => { - expect(canTransition("paid", "refunded")).toBe(true); - expect(canTransition("shipped", "refunded")).toBe(true); - expect(canTransition("delivered", "refunded")).toBe(true); - }); - - it("allows cancelling only while unpaid", () => { - expect(canTransition("pending", "cancelled")).toBe(true); - expect(canTransition("paid", "cancelled")).toBe(false); - }); - - it("routes an oversold order out of pending", () => { - expect(canTransition("pending", "oversold")).toBe(true); - // An oversold order can only be refunded or cancelled by hand; it must never - // pretend to be paid and ship - expect(canTransition("oversold", "refunded")).toBe(true); - expect(canTransition("oversold", "cancelled")).toBe(true); - expect(canTransition("oversold", "shipped")).toBe(false); - }); - - it("refuses to skip payment", () => { - expect(canTransition("pending", "shipped")).toBe(false); - expect(canTransition("pending", "delivered")).toBe(false); - }); - - it("refuses to move backwards", () => { - expect(canTransition("shipped", "paid")).toBe(false); - expect(canTransition("paid", "pending")).toBe(false); - }); - - it("treats terminal states as terminal", () => { - for (const status of ORDER_STATUSES) { - expect(canTransition("refunded", status)).toBe(false); - expect(canTransition("cancelled", status)).toBe(false); - } - }); - - it("rejects a transition to the same state", () => { - expect(canTransition("paid", "paid")).toBe(false); - }); - - it("rejects an unknown status string", () => { - expect(canTransition("bogus" as OrderStatus, "paid")).toBe(false); - }); -}); - -describe("assertTransition", () => { - it("passes a legal transition through", () => { - expect(() => assertTransition("pending", "paid")).not.toThrow(); - }); - - it("names both states when refusing", () => { - expect(() => assertTransition("shipped", "pending")).toThrow( - /shipped.*pending/, - ); - }); -}); diff --git a/tests/lib/pricing.test.ts b/tests/lib/pricing.test.ts deleted file mode 100644 index 0dc27cb..0000000 --- a/tests/lib/pricing.test.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - applyPsychologicalRounding, - convertPrice, - needsRecalculation, -} from "@/lib/pricing"; - -describe("applyPsychologicalRounding", () => { - it("rounds up to the next .99 ending", () => { - expect(applyPsychologicalRounding(9108, "ending99")).toBe(9199); - expect(applyPsychologicalRounding(9200, "ending99")).toBe(9299); - }); - - it("leaves an amount already ending in .99 untouched", () => { - expect(applyPsychologicalRounding(9199, "ending99")).toBe(9199); - }); - - it("rounds up to a whole unit under the integer strategy", () => { - expect(applyPsychologicalRounding(9108, "integer")).toBe(9200); - expect(applyPsychologicalRounding(9200, "integer")).toBe(9200); - }); -}); - -describe("convertPrice", () => { - it("applies rate, buffer, then psychological rounding", () => { - // 9900 * 0.92 = 9108; * 1.03 = 9381.24 -> 9381 -> rounded to .99 -> 9399 - expect(convertPrice({ baseMinor: 9900, rate: 0.92 })).toBe(9399); - }); - - it("honours an explicit zero buffer", () => { - // 9900 * 0.92 = 9108 → 9199 - expect(convertPrice({ baseMinor: 9900, rate: 0.92, bufferRate: 0 })).toBe( - 9199, - ); - }); - - it("rejects a non-positive rate rather than producing a free product", () => { - expect(() => convertPrice({ baseMinor: 9900, rate: 0 })).toThrow(/rate/i); - expect(() => convertPrice({ baseMinor: 9900, rate: -1 })).toThrow(/rate/i); - }); -}); - -describe("needsRecalculation", () => { - it("stays put while the rate drifts under the threshold", () => { - // 0.92 -> 0.93 is 1.09% of drift, under the 2% threshold - expect(needsRecalculation({ rateUsed: 0.92, currentRate: 0.93 })).toBe( - false, - ); - }); - - it("triggers once the rate drifts beyond the threshold", () => { - // 0.92 -> 0.95 is 3.26% of drift - expect(needsRecalculation({ rateUsed: 0.92, currentRate: 0.95 })).toBe(true); - }); - - it("triggers symmetrically when the rate falls", () => { - // 0.92 -> 0.88 is 4.35% of drift - expect(needsRecalculation({ rateUsed: 0.92, currentRate: 0.88 })).toBe(true); - }); - - it("always recalculates when no previous rate was recorded", () => { - expect(needsRecalculation({ rateUsed: 0, currentRate: 0.92 })).toBe(true); - }); -}); diff --git a/tests/lib/pricing/cron.test.ts b/tests/lib/pricing/cron.test.ts deleted file mode 100644 index b53674a..0000000 --- a/tests/lib/pricing/cron.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { env } from "cloudflare:test"; -import { eq } from "drizzle-orm"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import * as schema from "@/db/schema"; -import { runExchangeRateCron } from "@/lib/pricing/cron"; -import { seedDatabase } from "@/scripts/seed"; - -const ECB_XML = ` - - - - - -`; - -function stubFetch(response: Response): typeof fetch { - return (async () => response.clone()) as typeof fetch; -} - -beforeEach(async () => { - await env.DB.exec("DELETE FROM exchange_rates"); - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); - await seedDatabase(createDb(env.DB)); -}); - -describe("runExchangeRateCron", () => { - it("stores rates and reprices in one pass", async () => { - const outcome = await runExchangeRateCron( - createDb(env.DB), - stubFetch(new Response(ECB_XML)), - ); - - expect(outcome.ok).toBe(true); - if (!outcome.ok) return; - - expect(outcome.date).toBe("2026-09-04"); - expect(outcome.result.updated).toBeGreaterThan(0); - - const rates = await createDb(env.DB).select().from(schema.exchangeRates); - expect(rates.map((r) => r.quoteCurrency).sort()).toEqual(["EUR", "GBP"]); - }); - - it("prices EUR and GBP off the USD base price", async () => { - await runExchangeRateCron(createDb(env.DB), stubFetch(new Response(ECB_XML))); - - const prices = await createDb(env.DB) - .select() - .from(schema.variantPrices) - .where( - eq(schema.variantPrices.variantId, "seed-variant-dn50-threaded"), - ); - - const eur = prices.find((p) => p.currency === "EUR"); - const gbp = prices.find((p) => p.currency === "GBP"); - - // 9900 * 0.860437 * 1.03 = 8774.5 -> 8775 -> rounded to .99 -> 8799 - expect(eur?.amountMinor).toBe(8799); - // 9900 * 0.739098 * 1.03 = 7536.5 -> 7537 -> rounded to .99 -> 7599 - expect(gbp?.amountMinor).toBe(7599); - expect(eur?.source).toBe("auto"); - }); - - it("reports failure without throwing when the feed is unreachable", async () => { - const failing = (async () => { - throw new TypeError("network unreachable"); - }) as typeof fetch; - - const outcome = await runExchangeRateCron(createDb(env.DB), failing); - - expect(outcome.ok).toBe(false); - if (outcome.ok) return; - expect(outcome.reason).toMatch(/network unreachable/); - }); - - it("keeps the previous snapshot when a later fetch fails", async () => { - await runExchangeRateCron(createDb(env.DB), stubFetch(new Response(ECB_XML))); - const before = await createDb(env.DB).select().from(schema.exchangeRates); - - await runExchangeRateCron( - createDb(env.DB), - stubFetch(new Response("boom", { status: 503 })), - ); - - const after = await createDb(env.DB).select().from(schema.exchangeRates); - expect(after).toHaveLength(before.length); - expect(after.map((r) => r.rate).sort()).toEqual( - before.map((r) => r.rate).sort(), - ); - }); - - it("reports a non-2xx response as a failure", async () => { - const outcome = await runExchangeRateCron( - createDb(env.DB), - stubFetch(new Response("nope", { status: 500 })), - ); - - expect(outcome.ok).toBe(false); - if (outcome.ok) return; - expect(outcome.reason).toMatch(/500/); - }); -}); diff --git a/tests/lib/pricing/ecb.test.ts b/tests/lib/pricing/ecb.test.ts deleted file mode 100644 index 10afabf..0000000 --- a/tests/lib/pricing/ecb.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { parseEcbRates, ratesFromBase } from "@/lib/pricing/ecb"; - -// An excerpt of the ECB daily reference rates, in its real response shape -const SAMPLE = ` - - Reference rates - - - - - - - -`; - -describe("parseEcbRates", () => { - it("extracts the reference date", () => { - expect(parseEcbRates(SAMPLE).date).toBe("2026-09-04"); - }); - - it("extracts every quoted currency as a EUR-based rate", () => { - const { ratesFromEur } = parseEcbRates(SAMPLE); - - expect(ratesFromEur.USD).toBe(1.1622); - expect(ratesFromEur.GBP).toBe(0.85898); - expect(ratesFromEur.JPY).toBe(181.59); - }); - - it("implies EUR against itself as 1", () => { - expect(parseEcbRates(SAMPLE).ratesFromEur.EUR).toBe(1); - }); - - it("throws on a response with no rates rather than silently returning nothing", () => { - expect(() => parseEcbRates("")).toThrow(/no rates/i); - }); - - it("throws when the payload has rates but no date", () => { - const noDate = SAMPLE.replace("time='2026-09-04'", ""); - expect(() => parseEcbRates(noDate)).toThrow(/date/i); - }); -}); - -describe("ratesFromBase", () => { - const { ratesFromEur } = parseEcbRates(SAMPLE); - - it("converts EUR-based quotes into USD-based quotes", () => { - const rates = ratesFromBase(ratesFromEur, "USD", ["EUR", "GBP"]); - - // 1 USD = 1/1.1622 EUR - expect(rates.EUR).toBeCloseTo(0.860437, 6); - // 1 USD = 0.85898/1.1622 GBP - expect(rates.GBP).toBeCloseTo(0.739098, 6); - }); - - it("omits the base currency from its own rate table", () => { - const rates = ratesFromBase(ratesFromEur, "USD", ["USD", "EUR"]); - expect(rates.USD).toBeUndefined(); - }); - - it("throws when the base currency is missing from the source rates", () => { - expect(() => ratesFromBase({ EUR: 1 }, "USD", ["EUR"])).toThrow(/USD/); - }); - - it("skips quote currencies the source does not carry", () => { - const rates = ratesFromBase(ratesFromEur, "USD", ["EUR", "CHF"]); - expect(rates.CHF).toBeUndefined(); - expect(rates.EUR).toBeDefined(); - }); -}); diff --git a/tests/lib/pricing/recalculate.test.ts b/tests/lib/pricing/recalculate.test.ts deleted file mode 100644 index 42faa0c..0000000 --- a/tests/lib/pricing/recalculate.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { env } from "cloudflare:test"; -import { eq } from "drizzle-orm"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import * as schema from "@/db/schema"; -import { refreshExchangeRates, recalculatePrices } from "@/lib/pricing/recalculate"; -import { seedDatabase } from "@/scripts/seed"; - -const THREADED = "seed-variant-dn50-threaded"; - -async function pricesFor(variantId: string) { - return createDb(env.DB) - .select() - .from(schema.variantPrices) - .where(eq(schema.variantPrices.variantId, variantId)); -} - -beforeEach(async () => { - await env.DB.exec("DELETE FROM exchange_rates"); - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); - await seedDatabase(createDb(env.DB)); -}); - -describe("refreshExchangeRates", () => { - it("stores one row per quote currency", async () => { - await refreshExchangeRates(createDb(env.DB), { - EUR: 0.86, - GBP: 0.739, - }); - - const rows = await createDb(env.DB).select().from(schema.exchangeRates); - expect(rows.map((r) => r.quoteCurrency).sort()).toEqual(["EUR", "GBP"]); - expect(rows.every((r) => r.baseCurrency === "USD")).toBe(true); - expect(rows.every((r) => r.source === "ecb")).toBe(true); - }); - - it("overwrites the previous snapshot rather than accumulating rows", async () => { - await refreshExchangeRates(createDb(env.DB), { EUR: 0.86 }); - await refreshExchangeRates(createDb(env.DB), { EUR: 0.9 }); - - const rows = await createDb(env.DB).select().from(schema.exchangeRates); - expect(rows).toHaveLength(1); - expect(rows[0].rate).toBe(0.9); - }); -}); - -describe("recalculatePrices", () => { - it("creates auto prices for currencies that had none", async () => { - await refreshExchangeRates(createDb(env.DB), { EUR: 0.86, GBP: 0.739 }); - const result = await recalculatePrices(createDb(env.DB)); - - const prices = await pricesFor(THREADED); - const eur = prices.find((p) => p.currency === "EUR"); - - // 9900 * 0.86 = 8514; with the 1.03 buffer = 8769.42 -> 8769 -> rounded to - // .99 -> 8799 - expect(eur?.amountMinor).toBe(8799); - expect(eur?.source).toBe("auto"); - expect(eur?.rateUsed).toBe(0.86); - expect(result.updated).toBeGreaterThan(0); - }); - - it("leaves the base-currency price untouched", async () => { - await refreshExchangeRates(createDb(env.DB), { EUR: 0.86 }); - await recalculatePrices(createDb(env.DB)); - - const usd = (await pricesFor(THREADED)).find((p) => p.currency === "USD"); - expect(usd?.amountMinor).toBe(9900); - expect(usd?.source).toBe("base"); - }); - - it("never overwrites a manually set price", async () => { - await env.DB.exec( - `INSERT INTO variant_prices (variant_id, currency, amount_minor, source, updated_at) VALUES ('${THREADED}', 'EUR', 12345, 'manual', 1700000000)`, - ); - await refreshExchangeRates(createDb(env.DB), { EUR: 0.86 }); - await recalculatePrices(createDb(env.DB)); - - const eur = (await pricesFor(THREADED)).find((p) => p.currency === "EUR"); - expect(eur?.amountMinor).toBe(12345); - expect(eur?.source).toBe("manual"); - }); - - it("holds the price steady while the rate drifts under the threshold", async () => { - await refreshExchangeRates(createDb(env.DB), { EUR: 0.86 }); - await recalculatePrices(createDb(env.DB)); - const before = (await pricesFor(THREADED)).find((p) => p.currency === "EUR"); - - // 0.86 -> 0.867 is 0.81% of drift, under the 2% threshold - await refreshExchangeRates(createDb(env.DB), { EUR: 0.867 }); - const result = await recalculatePrices(createDb(env.DB)); - - const after = (await pricesFor(THREADED)).find((p) => p.currency === "EUR"); - expect(after?.amountMinor).toBe(before?.amountMinor); - expect(result.updated).toBe(0); - expect(result.skipped).toBeGreaterThan(0); - }); - - it("repricing kicks in once the rate drifts beyond the threshold", async () => { - await refreshExchangeRates(createDb(env.DB), { EUR: 0.86 }); - await recalculatePrices(createDb(env.DB)); - - // 0.86 -> 0.95 is 10.5% of drift - await refreshExchangeRates(createDb(env.DB), { EUR: 0.95 }); - await recalculatePrices(createDb(env.DB)); - - const eur = (await pricesFor(THREADED)).find((p) => p.currency === "EUR"); - expect(eur?.rateUsed).toBe(0.95); - expect(eur?.amountMinor).toBeGreaterThan(8799); - }); - - it("does nothing when no rates have been fetched yet", async () => { - const result = await recalculatePrices(createDb(env.DB)); - - expect(result.updated).toBe(0); - const prices = await pricesFor(THREADED); - expect(prices.map((p) => p.currency)).toEqual(["USD"]); - }); -}); diff --git a/tests/lib/queries/inventory.test.ts b/tests/lib/queries/inventory.test.ts deleted file mode 100644 index 3593f8a..0000000 --- a/tests/lib/queries/inventory.test.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import { getLiveInventory } from "@/lib/queries/inventory"; -import { seedDatabase } from "@/scripts/seed"; - -beforeEach(async () => { - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); - await seedDatabase(createDb(env.DB)); -}); - -const THREADED = "seed-variant-dn50-threaded"; - -describe("getLiveInventory", () => { - it("returns current stock and price for the requested variants", async () => { - const items = await getLiveInventory(createDb(env.DB), [THREADED], "USD"); - - expect(items).toHaveLength(1); - expect(items[0].variantId).toBe(THREADED); - expect(items[0].stock).toBe(120); - expect(items[0].priceMinor).toBe(9900); - expect(items[0].priceCurrency).toBe("USD"); - }); - - it("reflects a stock change made after the page was generated", async () => { - await env.DB.exec( - `UPDATE product_variants SET stock = 3 WHERE id = '${THREADED}'`, - ); - - const items = await getLiveInventory(createDb(env.DB), [THREADED], "USD"); - expect(items[0].stock).toBe(3); - }); - - it("ignores unknown variant ids instead of failing the whole request", async () => { - const items = await getLiveInventory( - createDb(env.DB), - [THREADED, "does-not-exist"], - "USD", - ); - - expect(items.map((i) => i.variantId)).toEqual([THREADED]); - }); - - it("returns an empty list for an empty request", async () => { - expect(await getLiveInventory(createDb(env.DB), [], "USD")).toEqual([]); - }); - - it("falls back to the base currency and reports it", async () => { - const items = await getLiveInventory(createDb(env.DB), [THREADED], "EUR"); - - expect(items[0].priceMinor).toBe(9900); - expect(items[0].priceCurrency).toBe("USD"); - }); -}); diff --git a/tests/lib/queries/product-detail.test.ts b/tests/lib/queries/product-detail.test.ts deleted file mode 100644 index 23bcc01..0000000 --- a/tests/lib/queries/product-detail.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import { - getProductDetail, - getUseCaseAlternates, - listProductSlugs, - listUseCasePages, -} from "@/lib/queries/products"; -import { seedDatabase } from "@/scripts/seed"; - -beforeEach(async () => { - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); - await seedDatabase(createDb(env.DB)); -}); - -const SLUG = "stainless-ball-valve-dn50"; - -describe("getProductDetail", () => { - it("returns null for an unknown slug", async () => { - const detail = await getProductDetail( - createDb(env.DB), - "no-such-product", - "en", - "USD", - ); - expect(detail).toBeNull(); - }); - - it("returns null for an archived product", async () => { - await env.DB.exec("UPDATE products SET status = 'archived'"); - - const detail = await getProductDetail(createDb(env.DB), SLUG, "en", "USD"); - expect(detail).toBeNull(); - }); - - it("returns the translation for the requested locale", async () => { - const detail = await getProductDetail(createDb(env.DB), SLUG, "fr", "EUR"); - - expect(detail).not.toBeNull(); - expect(detail!.name).toBe("Vanne à bille inox DN50"); - expect(detail!.description).toContain("acier inoxydable 316L"); - }); - - it("returns features and use cases ordered by sort order", async () => { - const detail = await getProductDetail(createDb(env.DB), SLUG, "en", "USD"); - - expect(detail!.features.map((f) => f.title)).toEqual([ - "316L stainless body", - "Full bore, zero flow restriction", - ]); - expect(detail!.useCases.map((u) => u.scenarioSlug)).toEqual([ - "offshore-seawater-lines", - "food-grade-dosing", - ]); - }); - - it("marks which use cases have their own landing page", async () => { - const detail = await getProductDetail(createDb(env.DB), SLUG, "en", "USD"); - - const withPage = detail!.useCases.filter((u) => u.hasOwnPage); - expect(withPage.map((u) => u.scenarioSlug)).toEqual([ - "offshore-seawater-lines", - ]); - }); - - it("returns variants with MOQ and lead time", async () => { - const detail = await getProductDetail(createDb(env.DB), SLUG, "en", "USD"); - - const threaded = detail!.variants.find( - (v) => v.sku === "BV-316L-DN50-NPT", - ); - expect(threaded!.moq).toBe(10); - expect(threaded!.leadTimeDaysMin).toBe(15); - expect(threaded!.leadTimeDaysMax).toBe(20); - expect(threaded!.priceMinor).toBe(9900); - expect(threaded!.priceCurrency).toBe("USD"); - }); - - it("falls back to the base currency and reports it", async () => { - const detail = await getProductDetail(createDb(env.DB), SLUG, "de", "EUR"); - - const threaded = detail!.variants.find( - (v) => v.sku === "BV-316L-DN50-NPT", - ); - expect(threaded!.priceMinor).toBe(9900); - expect(threaded!.priceCurrency).toBe("USD"); - }); - - it("uses the requested currency once a price exists for it", async () => { - await env.DB.exec( - "INSERT INTO variant_prices (variant_id, currency, amount_minor, source, updated_at) VALUES ('seed-variant-dn50-threaded', 'EUR', 9199, 'auto', 1700000000)", - ); - - const detail = await getProductDetail(createDb(env.DB), SLUG, "de", "EUR"); - const threaded = detail!.variants.find( - (v) => v.sku === "BV-316L-DN50-NPT", - ); - - expect(threaded!.priceMinor).toBe(9199); - expect(threaded!.priceCurrency).toBe("EUR"); - }); -}); - -describe("listProductSlugs", () => { - it("returns slugs of active products only", async () => { - expect(await listProductSlugs(createDb(env.DB))).toEqual([SLUG]); - - await env.DB.exec("UPDATE products SET status = 'archived'"); - expect(await listProductSlugs(createDb(env.DB))).toEqual([]); - }); -}); - -describe("listUseCasePages", () => { - it("returns only use cases flagged to have their own page", async () => { - const pages = await listUseCasePages(createDb(env.DB)); - - expect(pages).toHaveLength(4); // one per language - expect(pages.every((p) => p.productSlug === SLUG)).toBe(true); - expect(pages.map((p) => p.locale).sort()).toEqual(["de", "en", "es", "fr"]); - }); - - it("excludes use cases belonging to archived products", async () => { - await env.DB.exec("UPDATE products SET status = 'archived'"); - expect(await listUseCasePages(createDb(env.DB))).toEqual([]); - }); -}); - -describe("getUseCaseAlternates", () => { - it("maps each locale to that locale's own landing-page slug", async () => { - const alternates = await getUseCaseAlternates( - createDb(env.DB), - SLUG, - "en", - "offshore-seawater-lines", - ); - - // hreflang must point at a slug that exists in that language; pointing at the - // English one gives a 404 - expect(alternates).toEqual({ - en: "offshore-seawater-lines", - de: "offshore-seewasserleitungen", - fr: "circuits-eau-de-mer-offshore", - es: "lineas-agua-de-mar-offshore", - }); - }); - - it("resolves from a non-default locale too", async () => { - const alternates = await getUseCaseAlternates( - createDb(env.DB), - SLUG, - "de", - "offshore-seewasserleitungen", - ); - - expect(alternates!.fr).toBe("circuits-eau-de-mer-offshore"); - }); - - it("returns null when the use case has no landing page in that locale", async () => { - expect( - await getUseCaseAlternates(createDb(env.DB), SLUG, "en", "food-grade-dosing"), - ).toBeNull(); - }); - - it("omits locales whose translation is missing rather than guessing a slug", async () => { - await env.DB.exec( - "DELETE FROM product_use_cases WHERE locale = 'fr' AND group_key = 'offshore-seawater'", - ); - - const alternates = await getUseCaseAlternates( - createDb(env.DB), - SLUG, - "en", - "offshore-seawater-lines", - ); - - expect(alternates!.fr).toBeUndefined(); - expect(alternates!.de).toBe("offshore-seewasserleitungen"); - }); -}); diff --git a/tests/lib/queries/products.test.ts b/tests/lib/queries/products.test.ts deleted file mode 100644 index a26c6f8..0000000 --- a/tests/lib/queries/products.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import { listActiveProducts } from "@/lib/queries/products"; -import { seedDatabase } from "@/scripts/seed"; - -beforeEach(async () => { - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); - await seedDatabase(createDb(env.DB)); -}); - -describe("listActiveProducts", () => { - it("returns active products with the requested locale's name", async () => { - const products = await listActiveProducts(createDb(env.DB), "de"); - - expect(products.length).toBeGreaterThan(0); - expect(products[0].name).toBe("Edelstahl-Kugelhahn DN50"); - expect(products[0].slug).toBe("stainless-ball-valve-dn50"); - }); - - it("returns the lowest price in minor units for the requested currency", async () => { - const products = await listActiveProducts(createDb(env.DB), "en", "USD"); - - // Two SKUs priced at USD 99 and 168; the listing shows the lower one - expect(products[0].fromPriceMinor).toBe(9900); - expect(products[0].priceCurrency).toBe("USD"); - }); - - it("falls back to the base currency and reports it, instead of mislabelling the amount", async () => { - // The seed data is priced in USD only: asking for EUR must not display 9900 - // as EUR 99 - const products = await listActiveProducts(createDb(env.DB), "de", "EUR"); - - expect(products[0].fromPriceMinor).toBe(9900); - expect(products[0].priceCurrency).toBe("USD"); - }); - - it("uses the requested currency once a price exists for it", async () => { - await env.DB.exec( - "INSERT INTO variant_prices (variant_id, currency, amount_minor, source, updated_at) VALUES ('seed-variant-dn50-threaded', 'EUR', 9199, 'auto', 1700000000)", - ); - - const products = await listActiveProducts(createDb(env.DB), "de", "EUR"); - - expect(products[0].fromPriceMinor).toBe(9199); - expect(products[0].priceCurrency).toBe("EUR"); - }); - - it("collapses a multi-variant product into a single row", async () => { - const products = await listActiveProducts(createDb(env.DB), "en"); - - expect(products).toHaveLength(1); - }); - - it("excludes archived products", async () => { - await env.DB.exec("UPDATE products SET status = 'archived'"); - - const products = await listActiveProducts(createDb(env.DB), "en"); - expect(products).toHaveLength(0); - }); -}); diff --git a/tests/lib/security/rate-limit.test.ts b/tests/lib/security/rate-limit.test.ts deleted file mode 100644 index ed5ed75..0000000 --- a/tests/lib/security/rate-limit.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { env } from "cloudflare:test"; -import { describe, expect, it } from "vitest"; -import { - checkRateLimit, - clientIdentifier, - RATE_LIMITS, - rateLimitedResponse, -} from "@/lib/security/rate-limit"; - -const RULE = { limit: 3, windowSeconds: 60 }; - -function uniqueKey(name: string): string { - return `${name}:${crypto.randomUUID()}`; -} - -describe("checkRateLimit", () => { - it("allows requests up to the limit", async () => { - const key = uniqueKey("allow"); - - for (let attempt = 1; attempt <= RULE.limit; attempt += 1) { - const result = await checkRateLimit(env.SESSIONS, key, RULE); - expect(result.allowed).toBe(true); - expect(result.remaining).toBe(RULE.limit - attempt); - } - }); - - it("blocks once the limit is reached", async () => { - const key = uniqueKey("block"); - - for (let attempt = 0; attempt < RULE.limit; attempt += 1) { - await checkRateLimit(env.SESSIONS, key, RULE); - } - - const result = await checkRateLimit(env.SESSIONS, key, RULE); - expect(result.allowed).toBe(false); - expect(result.remaining).toBe(0); - expect(result.retryAfterSeconds).toBe(RULE.windowSeconds); - }); - - it("keeps separate buckets per key, so one client cannot block another", async () => { - const victim = uniqueKey("victim"); - const attacker = uniqueKey("attacker"); - - for (let attempt = 0; attempt < RULE.limit; attempt += 1) { - await checkRateLimit(env.SESSIONS, attacker, RULE); - } - - expect((await checkRateLimit(env.SESSIONS, attacker, RULE)).allowed).toBe(false); - expect((await checkRateLimit(env.SESSIONS, victim, RULE)).allowed).toBe(true); - }); - - it("keeps separate buckets per endpoint", async () => { - const id = crypto.randomUUID(); - - for (let attempt = 0; attempt < RULE.limit; attempt += 1) { - await checkRateLimit(env.SESSIONS, `checkout:${id}`, RULE); - } - - expect( - (await checkRateLimit(env.SESSIONS, `cart:${id}`, RULE)).allowed, - ).toBe(true); - }); -}); - -describe("clientIdentifier", () => { - it("uses the Cloudflare-set client IP", () => { - const request = new Request("https://shop.example", { - headers: { "cf-connecting-ip": "203.0.113.9" }, - }); - - expect(clientIdentifier(request)).toBe("203.0.113.9"); - }); - - it("ignores x-forwarded-for, which any client can forge", () => { - const request = new Request("https://shop.example", { - headers: { "x-forwarded-for": "1.2.3.4" }, - }); - - // Keying a limit on a forgeable header lets an attacker pick their own identity - // and walk straight past it - expect(clientIdentifier(request)).toBe("local"); - }); - - it("prefers the platform header even when a forged one is present", () => { - const request = new Request("https://shop.example", { - headers: { - "cf-connecting-ip": "203.0.113.9", - "x-forwarded-for": "1.2.3.4", - }, - }); - - expect(clientIdentifier(request)).toBe("203.0.113.9"); - }); -}); - -describe("rateLimitedResponse", () => { - it("returns 429 with Retry-After", async () => { - const response = rateLimitedResponse({ - allowed: false, - remaining: 0, - retryAfterSeconds: 600, - }); - - expect(response.status).toBe(429); - expect(response.headers.get("Retry-After")).toBe("600"); - expect(await response.json()).toEqual({ error: "rate_limited" }); - }); - - it("is never cached, so a 429 cannot be served to someone else", () => { - const response = rateLimitedResponse({ - allowed: false, - remaining: 0, - retryAfterSeconds: 60, - }); - - expect(response.headers.get("Cache-Control")).toBe("no-store"); - }); -}); - -describe("configured limits", () => { - it("makes checkout the tightest, since it creates orders and calls Stripe", () => { - expect(RATE_LIMITS.checkout.limit).toBeLessThan(RATE_LIMITS.cart.limit); - }); - - it("leaves headroom for the order status page to poll", () => { - // The success page polls every 3 seconds for up to 2 minutes: about 40 calls - expect(RATE_LIMITS.orderStatus.limit).toBeGreaterThanOrEqual(40); - }); - - it("defines a positive window for every endpoint", () => { - for (const rule of Object.values(RATE_LIMITS)) { - expect(rule.limit).toBeGreaterThan(0); - expect(rule.windowSeconds).toBeGreaterThan(0); - } - }); -}); diff --git a/tests/lib/seo.test.ts b/tests/lib/seo.test.ts deleted file mode 100644 index 5875ce8..0000000 --- a/tests/lib/seo.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { SITE } from "@/config/site"; -import { - absoluteUrl, - buildAlternates, - buildAlternatesFromMap, - localePath, -} from "@/lib/seo"; - -describe("localePath", () => { - it("prefixes the locale and joins segments", () => { - expect(localePath("de", "products", "ball-valve")).toBe( - "/de/products/ball-valve", - ); - }); - - it("returns the locale root when given no segments", () => { - expect(localePath("en")).toBe("/en"); - }); - - it("never produces duplicate slashes", () => { - expect(localePath("fr", "/products/", "/x/")).toBe("/fr/products/x"); - }); -}); - -describe("absoluteUrl", () => { - it("prepends the configured site origin", () => { - expect(absoluteUrl("/en/products")).toBe(`${SITE.url}/en/products`); - }); - - it("tolerates a path without a leading slash", () => { - expect(absoluteUrl("en")).toBe(`${SITE.url}/en`); - }); -}); - -describe("buildAlternates", () => { - const alternates = buildAlternates("de", (locale) => - localePath(locale, "products", "ball-valve"), - ); - - it("points canonical at the current locale's absolute URL", () => { - expect(alternates.canonical).toBe( - `${SITE.url}/de/products/ball-valve`, - ); - }); - - it("lists every shipped locale", () => { - expect(Object.keys(alternates.languages).sort()).toEqual( - ["de", "en", "es", "fr", "x-default"].sort(), - ); - }); - - it("maps x-default to the default locale", () => { - expect(alternates.languages["x-default"]).toBe( - `${SITE.url}/en/products/ball-valve`, - ); - }); - - it("emits absolute URLs for every language", () => { - for (const url of Object.values(alternates.languages)) { - expect(url.startsWith(SITE.url)).toBe(true); - } - }); -}); - -describe("buildAlternatesFromMap", () => { - const alternates = buildAlternatesFromMap( - "en", - { - en: "offshore-seawater-lines", - de: "offshore-seewasserleitungen", - fr: "circuits-eau-de-mer-offshore", - }, - (locale, slug) => localePath(locale, "products", "valve", slug), - ); - - it("uses each locale's own slug", () => { - expect(alternates.languages.de).toBe( - `${SITE.url}/de/products/valve/offshore-seewasserleitungen`, - ); - }); - - it("omits locales with no slug rather than pointing them at a 404", () => { - expect(alternates.languages.es).toBeUndefined(); - }); - - it("keeps canonical self-referential", () => { - expect(alternates.canonical).toBe( - `${SITE.url}/en/products/valve/offshore-seawater-lines`, - ); - }); - - it("maps x-default to the default locale when it is present", () => { - expect(alternates.languages["x-default"]).toBe( - `${SITE.url}/en/products/valve/offshore-seawater-lines`, - ); - }); - - it("omits x-default entirely when the default locale is missing", () => { - const withoutDefault = buildAlternatesFromMap( - "de", - { de: "offshore-seewasserleitungen" }, - (locale, slug) => localePath(locale, "products", "valve", slug), - ); - expect(withoutDefault.languages["x-default"]).toBeUndefined(); - }); -}); diff --git a/tests/lib/settings/settings.test.ts b/tests/lib/settings/settings.test.ts deleted file mode 100644 index db61358..0000000 --- a/tests/lib/settings/settings.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { env } from "cloudflare:test"; -import { beforeEach, describe, expect, it } from "vitest"; -import { createDb } from "@/db/client"; -import { siteSettings } from "@/db/schema"; -import { - getSetting, - getSettings, - isValidEmail, - saveSetting, - SETTING_DEFAULTS, -} from "@/lib/settings/settings"; - -const db = createDb(env.DB); - -beforeEach(async () => { - await db.delete(siteSettings); -}); - -describe("reading settings", () => { - it("falls back to the declared default when nothing was written", async () => { - const settings = await getSettings(db); - - expect(settings.securityContactEmail).toBe( - SETTING_DEFAULTS.securityContactEmail, - ); - }); - - it("returns a stored value", async () => { - await saveSetting(db, "securityContactEmail", "security@example.com"); - - expect(await getSetting(db, "securityContactEmail")).toBe( - "security@example.com", - ); - }); - - it("never returns undefined for a declared key", async () => { - // A setting read as undefined renders as "undefined" on a page, or worse, - // publishes "Contact: mailto:undefined" to the internet. - const settings = await getSettings(db); - - for (const [key, value] of Object.entries(settings)) { - expect(value, `${key} is undefined`).toBeTypeOf("string"); - } - }); -}); - -describe("writing settings", () => { - it("overwrites rather than accumulating rows", async () => { - await saveSetting(db, "securityContactEmail", "first@example.com"); - await saveSetting(db, "securityContactEmail", "second@example.com"); - - const rows = await db.select().from(siteSettings); - expect(rows).toHaveLength(1); - expect(rows[0].value).toBe("second@example.com"); - }); - - it("trims surrounding whitespace", async () => { - await saveSetting(db, "securityContactEmail", " security@example.com "); - - expect(await getSetting(db, "securityContactEmail")).toBe( - "security@example.com", - ); - }); - - it("allows clearing, which withdraws security.txt", async () => { - await saveSetting(db, "securityContactEmail", "security@example.com"); - const result = await saveSetting(db, "securityContactEmail", ""); - - expect(result.ok).toBe(true); - expect(await getSetting(db, "securityContactEmail")).toBe(""); - }); - - it("refuses a key that is not declared", async () => { - const result = await saveSetting(db, "arbitraryKey", "value"); - - expect(result).toEqual({ ok: false, reason: "unknown_key" }); - expect(await db.select().from(siteSettings)).toHaveLength(0); - }); - - it("refuses an invalid address rather than publishing it", async () => { - const result = await saveSetting(db, "securityContactEmail", "not-an-email"); - - expect(result).toEqual({ ok: false, reason: "invalid_email" }); - expect(await getSetting(db, "securityContactEmail")).toBe(""); - }); -}); - -describe("isValidEmail", () => { - it("accepts ordinary addresses", () => { - for (const value of [ - "security@example.com", - "security+reports@example.co.uk", - "a.b-c@sub.example.org", - ]) { - expect(isValidEmail(value), value).toBe(true); - } - }); - - it("rejects what would send reports nowhere", () => { - // This value is published to the public, so a typo does not merely - // inconvenience the operator — it tells researchers someone is listening - // when nobody is. - for (const value of [ - "", - "no-at-sign", - "@example.com", - "security@", - "security@localhost", - "security @example.com", - "security@example .com", - "security@.example.com", - "security@example..com", - "security@example.com.", - "two@at@example.com", - ]) { - expect(isValidEmail(value), `${value} should be rejected`).toBe(false); - } - }); - - it("rejects an over-long address", () => { - expect(isValidEmail(`${"a".repeat(250)}@example.com`)).toBe(false); - }); -}); diff --git a/tests/lib/storefront/i18n.test.ts b/tests/lib/storefront/i18n.test.ts deleted file mode 100644 index 2585bba..0000000 --- a/tests/lib/storefront/i18n.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { LOCALES } from "@/config/locales"; -import { getStorefrontMessages } from "@/lib/storefront/i18n"; - -/** Every leaf path, so two languages can be compared by shape. */ -function paths(value: unknown, prefix = ""): string[] { - if (typeof value !== "object" || value === null) { - return [prefix]; - } - return Object.entries(value as Record).flatMap(([key, child]) => - paths(child, prefix ? `${prefix}.${key}` : key), - ); -} - -describe("storefront catalogue", () => { - it("covers every storefront language", () => { - for (const locale of LOCALES) { - expect(getStorefrontMessages(locale), `${locale} is missing`).toBeDefined(); - } - }); - - it("has identical structure in every language", () => { - const reference = paths(getStorefrontMessages("en")).sort(); - - for (const locale of LOCALES) { - expect(paths(getStorefrontMessages(locale)).sort(), `${locale} drifted`).toEqual( - reference, - ); - } - }); - - it("has no empty strings", () => { - for (const locale of LOCALES) { - const messages = getStorefrontMessages(locale); - for (const [section, entries] of Object.entries(messages)) { - for (const [key, value] of Object.entries(entries)) { - expect( - String(value).trim().length, - `${locale}.${section}.${key} is empty`, - ).toBeGreaterThan(0); - } - } - } - }); -}); - -describe("breadcrumb label", () => { - /** - * The breadcrumb is the reason this catalogue exists. It used to read - * `locale === "de" ? "Produkte" : "Products"` in the theme, so French and - * Spanish visitors saw an English breadcrumb — and the route layer emitted a - * hardcoded English "Products" into every language's `BreadcrumbList` - * structured data, which is what Google read. - */ - it("is genuinely translated in every language", () => { - const labels = LOCALES.map((locale) => getStorefrontMessages(locale).nav.products); - - expect(labels).toEqual(["Products", "Produkte", "Produits", "Productos"]); - }); - - it("differs from English wherever the language differs", () => { - for (const locale of LOCALES) { - if (locale === "en") continue; - expect( - getStorefrontMessages(locale).nav.products, - `${locale} still shows the English breadcrumb`, - ).not.toBe("Products"); - } - }); -}); - -describe("commerce vocabulary", () => { - it("translates the terms a buyer needs to read correctly", () => { - // Getting any of these wrong is a bug rather than a style choice, which is - // exactly why they live here instead of inside a theme. - for (const key of ["minimumOrder", "leadTime", "priceOnRequest", "outOfStock"] as const) { - const values = LOCALES.map((locale) => getStorefrontMessages(locale).product[key]); - expect(new Set(values).size, `${key} is not translated`).toBeGreaterThan(1); - } - }); - - it("keeps language endonyms identical across languages", () => { - // "Deutsch" is Deutsch in every language; a switcher that translates these - // is unusable for the very reader who needs it. - for (const locale of LOCALES) { - expect(getStorefrontMessages(locale).language).toEqual({ - en: "English", - de: "Deutsch", - fr: "Français", - es: "Español", - }); - } - }); -}); - -describe("the transactional path", () => { - /** - * The storefront was fully multilingual in its SEO content and entirely - * English from add-to-cart onwards: a French buyer read French application - * notes, then met "Add to cart", "Your cart is empty" and "Subtotal" in - * English at the moment they were about to pay. - */ - const CONVERSION_CRITICAL = [ - ["addToCart", "add"], - ["cart", "empty"], - ["cart", "subtotal"], - ["cart", "remove"], - ["checkout", "continueToPayment"], - ["checkout", "total"], - ["orderStatus", "pending"], - ["orderStatus", "oversold"], - ] as const; - - it.each(CONVERSION_CRITICAL)("translates %s.%s in every language", (section, key) => { - const values = LOCALES.map( - (locale) => - (getStorefrontMessages(locale)[section] as Record)[key], - ); - - for (const value of values) { - expect(value.trim().length).toBeGreaterThan(0); - } - // Four languages must not all say the same thing here - expect(new Set(values).size).toBeGreaterThan(1); - }); - - it("keeps the quantity placeholder in every translation", () => { - // A translation that drops {n} silently renders "Minimum order quantity is ." - for (const locale of LOCALES) { - const t = getStorefrontMessages(locale); - expect(t.cart.belowMoq, `${locale}.cart.belowMoq lost {n}`).toContain("{n}"); - expect( - t.cart.insufficientStock, - `${locale}.cart.insufficientStock lost {n}`, - ).toContain("{n}"); - } - }); - - it("covers every order status the state machine can reach", () => { - // A status with no message renders undefined on the page a buyer lands on - // straight after paying. - const statuses = [ - "pending", - "paid", - "shipped", - "delivered", - "cancelled", - "refunded", - "oversold", - ] as const; - - for (const locale of LOCALES) { - const messages = getStorefrontMessages(locale).orderStatus; - for (const status of statuses) { - expect(messages[status], `${locale} is missing ${status}`).toBeTruthy(); - } - } - }); -}); diff --git a/tests/lib/stripe/client.test.ts b/tests/lib/stripe/client.test.ts deleted file mode 100644 index b9d9cca..0000000 --- a/tests/lib/stripe/client.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - createCheckoutSession, - createPaymentIntent, - createRefund, -} from "@/lib/stripe/client"; - -type Captured = { url: string; init: RequestInit }; - -function recordingFetch(response: unknown, status = 200) { - const calls: Captured[] = []; - const impl = (async (url: string, init: RequestInit) => { - calls.push({ url, init }); - return new Response(JSON.stringify(response), { - status, - headers: { "Content-Type": "application/json" }, - }); - }) as unknown as typeof fetch; - - return { impl, calls }; -} - -function bodyOf(call: Captured): URLSearchParams { - return new URLSearchParams(String(call.init.body)); -} - -describe("createPaymentIntent", () => { - it("sends the amount in minor units and lowercase currency", async () => { - const { impl, calls } = recordingFetch({ - id: "pi_1", - client_secret: "cs_1", - status: "requires_payment_method", - }); - - await createPaymentIntent( - "sk_test_123", - { - amountMinor: 99_000, - currency: "EUR", - orderId: "order-1", - orderNo: "ND-260904-ABC123", - }, - impl, - ); - - const body = bodyOf(calls[0]); - expect(calls[0].url).toBe("https://api.stripe.com/v1/payment_intents"); - expect(body.get("amount")).toBe("99000"); - expect(body.get("currency")).toBe("eur"); - }); - - it("attaches the order id so the webhook can find the order", async () => { - const { impl, calls } = recordingFetch({ - id: "pi_1", - client_secret: "cs_1", - status: "requires_payment_method", - }); - - await createPaymentIntent( - "sk_test_123", - { - amountMinor: 100, - currency: "USD", - orderId: "order-42", - orderNo: "ND-260904-XYZ789", - }, - impl, - ); - - const body = bodyOf(calls[0]); - expect(body.get("metadata[order_id]")).toBe("order-42"); - expect(body.get("metadata[order_no]")).toBe("ND-260904-XYZ789"); - }); - - it("sends an idempotency key keyed on the order, so retries do not double-charge", async () => { - const { impl, calls } = recordingFetch({ - id: "pi_1", - client_secret: "cs_1", - status: "requires_payment_method", - }); - - await createPaymentIntent( - "sk_test_123", - { - amountMinor: 100, - currency: "USD", - orderId: "order-7", - orderNo: "ND-1", - }, - impl, - ); - - const headers = calls[0].init.headers as Record; - expect(headers["Idempotency-Key"]).toBe("order:order-7"); - expect(headers.Authorization).toBe("Bearer sk_test_123"); - }); - - it("surfaces Stripe's error message without leaking the whole response", async () => { - const { impl } = recordingFetch( - { error: { message: "Amount must be at least 50 cents" } }, - 400, - ); - - await expect( - createPaymentIntent( - "sk_test_123", - { amountMinor: 1, currency: "USD", orderId: "o", orderNo: "n" }, - impl, - ), - ).rejects.toThrow(/at least 50 cents/); - }); - - it("still throws a useful error when Stripe returns no message", async () => { - const { impl } = recordingFetch({}, 500); - - await expect( - createPaymentIntent( - "sk_test_123", - { amountMinor: 100, currency: "USD", orderId: "o", orderNo: "n" }, - impl, - ), - ).rejects.toThrow(/500/); - }); -}); - -describe("createRefund", () => { - it("refunds against the payment intent", async () => { - const { impl, calls } = recordingFetch({ id: "re_1", status: "succeeded" }); - - await createRefund("sk_test_123", { paymentIntentId: "pi_9" }, impl); - - expect(calls[0].url).toBe("https://api.stripe.com/v1/refunds"); - expect(bodyOf(calls[0]).get("payment_intent")).toBe("pi_9"); - }); - - it("keys idempotency on the payment intent", async () => { - const { impl, calls } = recordingFetch({ id: "re_1", status: "succeeded" }); - - await createRefund("sk_test_123", { paymentIntentId: "pi_9" }, impl); - - const headers = calls[0].init.headers as Record; - expect(headers["Idempotency-Key"]).toBe("refund:pi_9"); - }); -}); - -describe("createCheckoutSession", () => { - it("charges the server-computed total as a single line item", async () => { - const { impl, calls } = recordingFetch({ - id: "cs_1", - url: "https://checkout.stripe.com/c/pay/cs_1", - }); - - await createCheckoutSession( - "sk_test_123", - { - amountMinor: 99_000, - currency: "USD", - orderId: "order-1", - orderNo: "ND-1", - productName: "Order ND-1", - successUrl: "https://shop.example/en/orders/ND-1", - cancelUrl: "https://shop.example/en/cart", - }, - impl, - ); - - const body = bodyOf(calls[0]); - expect(calls[0].url).toBe("https://api.stripe.com/v1/checkout/sessions"); - expect(body.get("line_items[0][price_data][unit_amount]")).toBe("99000"); - expect(body.get("line_items[0][quantity]")).toBe("1"); - expect(body.get("mode")).toBe("payment"); - }); - - it("propagates the order id onto the payment intent, which the webhook reads", async () => { - const { impl, calls } = recordingFetch({ id: "cs_1", url: "https://x" }); - - await createCheckoutSession( - "sk_test_123", - { - amountMinor: 100, - currency: "USD", - orderId: "order-99", - orderNo: "ND-9", - productName: "Order ND-9", - successUrl: "https://shop.example/ok", - cancelUrl: "https://shop.example/cart", - }, - impl, - ); - - const body = bodyOf(calls[0]); - // The webhook receives payment_intent.succeeded, and order_id being here is the - // only way it can find the order - expect(body.get("payment_intent_data[metadata][order_id]")).toBe("order-99"); - expect(body.get("metadata[order_id]")).toBe("order-99"); - }); - - it("keys idempotency on the order so a double submit reuses the session", async () => { - const { impl, calls } = recordingFetch({ id: "cs_1", url: "https://x" }); - - await createCheckoutSession( - "sk_test_123", - { - amountMinor: 100, - currency: "USD", - orderId: "order-3", - orderNo: "ND-3", - productName: "Order ND-3", - successUrl: "https://shop.example/ok", - cancelUrl: "https://shop.example/cart", - }, - impl, - ); - - const headers = calls[0].init.headers as Record; - expect(headers["Idempotency-Key"]).toBe("checkout:order-3"); - }); -}); diff --git a/tests/lib/stripe/webhook.test.ts b/tests/lib/stripe/webhook.test.ts deleted file mode 100644 index e9eddbf..0000000 --- a/tests/lib/stripe/webhook.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { verifyStripeSignature } from "@/lib/stripe/webhook"; - -const SECRET = "whsec_test_secret"; -const PAYLOAD = '{"id":"evt_1","type":"payment_intent.succeeded"}'; - -async function sign(payload: string, timestamp: number, secret = SECRET) { - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const mac = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(`${timestamp}.${payload}`), - ); - return [...new Uint8Array(mac)] - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); -} - -async function header(payload: string, offsetSeconds = 0, secret = SECRET) { - const timestamp = Math.floor(Date.now() / 1000) + offsetSeconds; - return `t=${timestamp},v1=${await sign(payload, timestamp, secret)}`; -} - -describe("verifyStripeSignature", () => { - it("accepts a correctly signed payload", async () => { - const result = await verifyStripeSignature( - PAYLOAD, - await header(PAYLOAD), - SECRET, - ); - expect(result.ok).toBe(true); - }); - - it("rejects a payload that was altered after signing", async () => { - const signature = await header(PAYLOAD); - const result = await verifyStripeSignature( - PAYLOAD.replace("evt_1", "evt_2"), - signature, - SECRET, - ); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/signature/i); - }); - - it("rejects a signature made with a different secret", async () => { - const result = await verifyStripeSignature( - PAYLOAD, - await header(PAYLOAD, 0, "whsec_wrong"), - SECRET, - ); - expect(result.ok).toBe(false); - }); - - it("rejects a stale timestamp, so captured requests cannot be replayed", async () => { - const result = await verifyStripeSignature( - PAYLOAD, - await header(PAYLOAD, -60 * 60), - SECRET, - ); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/timestamp/i); - }); - - it("rejects a timestamp far in the future", async () => { - const result = await verifyStripeSignature( - PAYLOAD, - await header(PAYLOAD, 60 * 60), - SECRET, - ); - expect(result.ok).toBe(false); - }); - - it("rejects a malformed header", async () => { - for (const bad of ["", "garbage", "t=abc,v1=def", "v1=onlysignature"]) { - const result = await verifyStripeSignature(PAYLOAD, bad, SECRET); - expect(result.ok).toBe(false); - } - }); - - it("accepts a header carrying several v1 signatures during secret rotation", async () => { - const timestamp = Math.floor(Date.now() / 1000); - const good = await sign(PAYLOAD, timestamp); - const other = await sign(PAYLOAD, timestamp, "whsec_other"); - - const result = await verifyStripeSignature( - PAYLOAD, - `t=${timestamp},v1=${other},v1=${good}`, - SECRET, - ); - expect(result.ok).toBe(true); - }); - - it("refuses to verify when no secret is configured", async () => { - const result = await verifyStripeSignature( - PAYLOAD, - await header(PAYLOAD), - "", - ); - - expect(result.ok).toBe(false); - if (result.ok) return; - expect(result.reason).toMatch(/secret/i); - }); -}); diff --git a/tests/scripts/build-seed-sql.test.ts b/tests/scripts/build-seed-sql.test.ts deleted file mode 100644 index dabdca9..0000000 --- a/tests/scripts/build-seed-sql.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { buildSeedSql, sqlString } from "@/scripts/build-seed-sql"; - -describe("sqlString", () => { - it("wraps a plain string in single quotes", () => { - expect(sqlString("hello")).toBe("'hello'"); - }); - - it("doubles embedded single quotes", () => { - expect(sqlString("d'actionneur")).toBe("'d''actionneur'"); - }); - - it("emits NULL for null rather than the string 'null'", () => { - expect(sqlString(null)).toBe("NULL"); - }); - - it("neutralises a statement-terminating payload", () => { - expect(sqlString("'; DROP TABLE products; --")).toBe( - "'''; DROP TABLE products; --'", - ); - }); -}); - -describe("buildSeedSql", () => { - const sql = buildSeedSql(1_700_000_000); - - it("emits inserts for every seeded table", () => { - expect(sql).toContain("INSERT OR IGNORE INTO products"); - expect(sql).toContain("INSERT OR IGNORE INTO product_translations"); - expect(sql).toContain("INSERT OR IGNORE INTO product_features"); - expect(sql).toContain("INSERT OR IGNORE INTO product_use_cases"); - expect(sql).toContain("INSERT OR IGNORE INTO product_variants"); - expect(sql).toContain("INSERT OR IGNORE INTO variant_prices"); - }); - - it("is re-runnable, so it must not use bare INSERT", () => { - expect(sql).not.toMatch(/INSERT INTO/); - }); - - it("carries content for all four locales", () => { - expect(sql).toContain("Stainless Steel Ball Valve DN50"); - expect(sql).toContain("Edelstahl-Kugelhahn DN50"); - expect(sql).toContain("Vanne à bille inox DN50"); - expect(sql).toContain("Válvula de bola de acero inoxidable DN50"); - }); - - it("writes prices as integer minor units", () => { - expect(sql).toContain("9900"); - expect(sql).not.toMatch(/,\s*99\.0*,/); - }); -}); diff --git a/tests/scripts/seed.test.ts b/tests/scripts/seed.test.ts deleted file mode 100644 index 300de63..0000000 --- a/tests/scripts/seed.test.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { env } from "cloudflare:test"; -import { eq } from "drizzle-orm"; -import { beforeEach, describe, expect, it } from "vitest"; -import { LOCALES } from "@/config/locales"; -import { createDb } from "@/db/client"; -import * as schema from "@/db/schema"; -import { seedDatabase } from "@/scripts/seed"; - -beforeEach(async () => { - await env.DB.exec("DELETE FROM variant_prices"); - await env.DB.exec("DELETE FROM product_variants"); - await env.DB.exec("DELETE FROM product_use_cases"); - await env.DB.exec("DELETE FROM product_features"); - await env.DB.exec("DELETE FROM product_translations"); - await env.DB.exec("DELETE FROM products"); -}); - -describe("seedDatabase", () => { - it("creates at least one active product", async () => { - const db = createDb(env.DB); - await seedDatabase(db); - - const products = await db.select().from(schema.products); - expect(products.length).toBeGreaterThan(0); - expect(products.every((p) => p.status === "active")).toBe(true); - }); - - it("provides a translation in every shipped locale for every product", async () => { - const db = createDb(env.DB); - await seedDatabase(db); - - const products = await db.select().from(schema.products); - for (const product of products) { - const translations = await db - .select() - .from(schema.productTranslations) - .where(eq(schema.productTranslations.productId, product.id)); - expect(translations.map((t) => t.locale).sort()).toEqual( - [...LOCALES].sort(), - ); - } - }); - - it("seeds features and use cases so the SEO layout has content to render", async () => { - const db = createDb(env.DB); - await seedDatabase(db); - - const features = await db.select().from(schema.productFeatures); - const useCases = await db.select().from(schema.productUseCases); - expect(features.length).toBeGreaterThan(0); - expect(useCases.length).toBeGreaterThan(0); - }); - - it("gives every use case marked has_own_page a slug to build the URL from", async () => { - const db = createDb(env.DB); - await seedDatabase(db); - - const useCases = await db.select().from(schema.productUseCases); - for (const useCase of useCases) { - if (useCase.hasOwnPage === 1) { - expect(useCase.scenarioSlug).toBeTruthy(); - } - } - }); - - it("prices every variant in the base currency with source 'base'", async () => { - const db = createDb(env.DB); - await seedDatabase(db); - - const variants = await db.select().from(schema.productVariants); - expect(variants.length).toBeGreaterThan(0); - - for (const variant of variants) { - const prices = await db - .select() - .from(schema.variantPrices) - .where(eq(schema.variantPrices.variantId, variant.id)); - const base = prices.find((p) => p.currency === "USD"); - expect(base).toBeDefined(); - expect(base!.source).toBe("base"); - expect(Number.isInteger(base!.amountMinor)).toBe(true); - } - }); - - it("is idempotent so re-running it does not duplicate rows", async () => { - const db = createDb(env.DB); - await seedDatabase(db); - const firstCount = (await db.select().from(schema.products)).length; - - await seedDatabase(db); - const secondCount = (await db.select().from(schema.products)).length; - - expect(secondCount).toBe(firstCount); - }); -}); diff --git a/tests/setup.test.ts b/tests/setup.test.ts deleted file mode 100644 index 6594a3c..0000000 --- a/tests/setup.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from "vitest"; - -describe("test harness", () => { - it("runs inside a Workers-like runtime with crypto available", () => { - expect(typeof crypto.randomUUID).toBe("function"); - expect(crypto.randomUUID()).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - }); -}); diff --git a/tests/themes/contract.test.ts b/tests/themes/contract.test.ts deleted file mode 100644 index 0cfcb68..0000000 --- a/tests/themes/contract.test.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { env } from "cloudflare:test"; -import { describe, expect, it } from "vitest"; - -/** - * Theme stylesheets must not collide. - * - * The registry imports every registered theme statically, so every theme's - * stylesheet lands in the same bundle no matter which one THEME selects. A - * build with both themes registered puts `.theme-default` and - * `.theme-editorial` rules in one chunk. If either declared its tokens on - * `:root`, the last stylesheet loaded would win and THEME would silently stop - * meaning anything. - * - * This is not hypothetical: the default theme shipped exactly that way until a - * second theme existed to expose it. - */ -const STYLESHEETS: { name: string; css: string }[] = Object.entries( - env.THEME_STYLESHEETS, -).map(([name, css]) => ({ name, css })); - -/** Top-level selectors, with comments and nested media blocks accounted for. */ -function topLevelSelectors(css: string): string[] { - const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, ""); - const selectors: string[] = []; - let depth = 0; - let buffer = ""; - - for (const char of withoutComments) { - if (char === "{") { - if (depth === 0 || buffer.trim().startsWith("@")) { - const text = buffer.trim(); - if (text && !text.startsWith("@")) { - selectors.push(text); - } - } else { - const text = buffer.trim(); - if (text) selectors.push(text); - } - depth += 1; - buffer = ""; - } else if (char === "}") { - depth -= 1; - buffer = ""; - } else { - buffer += char; - } - } - - return selectors; -} - -describe.each(STYLESHEETS)("$name theme stylesheet", ({ name, css }) => { - it("declares nothing on :root, which would leak across themes", () => { - const selectors = topLevelSelectors(css); - expect(selectors.filter((selector) => selector.includes(":root"))).toEqual([]); - }); - - it("scopes every rule to its own theme class", () => { - const selectors = topLevelSelectors(css); - - expect(selectors.length).toBeGreaterThan(0); - for (const selector of selectors) { - // Every comma-separated part must sit under this theme's root class - for (const part of selector.split(",")) { - expect(part.trim(), `"${part.trim()}" escapes .theme-${name}`).toMatch( - new RegExp(`^\\.theme-${name}\\b`), - ); - } - } - }); - - it("defines the tokens the shared components read", () => { - for (const token of ["--bg", "--ink", "--accent", "--space-4", "--shell-max"]) { - expect(css, `${name} is missing ${token}`).toContain(`${token}:`); - } - }); -}); - -describe("themes do not share a root class", () => { - it("finds every theme on disk", () => { - expect(STYLESHEETS.length).toBeGreaterThanOrEqual(2); - expect(STYLESHEETS.map(({ name }) => name).sort()).toContain("editorial"); - }); - - it("gives each theme a distinct scope", () => { - const scopes = STYLESHEETS.map(({ name }) => `.theme-${name}`); - expect(new Set(scopes).size).toBe(scopes.length); - }); - - it("keeps one theme's rules out of another's stylesheet", () => { - for (const { name, css } of STYLESHEETS) { - for (const other of STYLESHEETS) { - if (other.name === name) continue; - expect(css, `${name} reaches into .theme-${other.name}`).not.toContain( - `.theme-${other.name}`, - ); - } - } - }); -}); - -describe("a theme's Shell applies its own scope", () => { - /** - * The scope class is written by hand in each Shell. Copying a theme and - * renaming the directory without renaming that class would put two themes - * back on one scope — the exact collision the scoping fix removed, and one - * that no type error would catch. - */ - it.each(Object.keys(env.THEME_SHELLS))("%s applies .theme-%s", (name) => { - expect(env.THEME_SHELLS[name]).toContain(`className="theme-${name}"`); - }); - - it("never applies another theme's scope", () => { - for (const [name, source] of Object.entries(env.THEME_SHELLS)) { - for (const other of Object.keys(env.THEME_SHELLS)) { - if (other === name) continue; - expect(source, `${name}'s Shell applies .theme-${other}`).not.toContain( - `className="theme-${other}"`, - ); - } - } - }); -}); diff --git a/tests/themes/resolve.test.ts b/tests/themes/resolve.test.ts deleted file mode 100644 index 9fea8e3..0000000 --- a/tests/themes/resolve.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveThemeName } from "@/themes/resolve"; - -const REGISTERED = ["default", "minimal"] as const; - -describe("resolveThemeName", () => { - it("uses the fallback when nothing is configured", () => { - expect(resolveThemeName(undefined, REGISTERED, "default")).toEqual({ - name: "default", - fellBack: false, - }); - }); - - it("resolves a registered name", () => { - expect(resolveThemeName("minimal", REGISTERED, "default")).toEqual({ - name: "minimal", - fellBack: false, - }); - }); - - it("falls back and reports it for an unknown name", () => { - // A blank site because a theme name was misspelled is far worse than the same - // site rendered with the default theme - expect(resolveThemeName("typo", REGISTERED, "default")).toEqual({ - name: "default", - fellBack: true, - }); - }); - - it("treats an empty string as not configured", () => { - expect(resolveThemeName("", REGISTERED, "default").fellBack).toBe(false); - }); - - it("is case sensitive, matching the directory name exactly", () => { - expect(resolveThemeName("Default", REGISTERED, "default").fellBack).toBe(true); - }); -}); diff --git a/tests/worker-entry.ts b/tests/worker-entry.ts deleted file mode 100644 index aad4224..0000000 --- a/tests/worker-entry.ts +++ /dev/null @@ -1,16 +0,0 @@ -/** - * A Worker entry point used only by the tests. - * - * vitest-pool-workers needs a loadable main entry to start its isolated - * runtime, and the production entry `.open-next/worker.js` is a build artefact - * that does not exist while unit tests run. This stub decouples the tests from - * the build: they use the bindings (D1, KV, R2) directly and never send a - * request through SELF. - */ -const testWorker = { - async fetch(): Promise { - return new Response("nundar test worker", { status: 200 }); - }, -}; - -export default testWorker; diff --git a/tsconfig.json b/tsconfig.json index 246347c..d645545 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,39 +1,32 @@ { "compilerOptions": { - "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], - "allowJs": true, - "skipLibCheck": true, - "strict": true, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", + "target": "es2022", + "module": "preserve", "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "react-jsx", - "incremental": true, - // cloudflare:test types (env, applyD1Migrations, ...) used by the test files - "types": ["@cloudflare/vitest-pool-workers/types"], - "plugins": [ - { - "name": "next" - } + "lib": ["es2023"], + // The pool's types supply `cloudflare:test` for the shop plugin's tests. + "types": [ + "@cloudflare/workers-types", + "@cloudflare/vitest-pool-workers/types", + "node" ], - "paths": { - "@/*": ["./src/*"] - } + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + // On, and it has to be: `@cloudflare/workers-types` and `@types/node` both + // declare `console`, `crypto`, `Blob`, `TextDecoder` and two dozen more, + // so checking them together reports about forty conflicts that belong to + // neither package. Mallok's own published declarations are checked with it + // off, by a consumer project that installs the tarball and nothing else — + // that is the setting that caught them reaching for zod, mdast and hast. + "skipLibCheck": true, + "noEmit": true }, - "include": [ - "next-env.d.ts", - "**/*.ts", - "**/*.tsx", - ".next/types/**/*.ts", - ".next/dev/types/**/*.ts", - "**/*.mts" - ], - // src/worker.ts imports .open-next/worker.js, a build artefact produced later - // in the same build and therefore absent while typechecking. It is a thin - // wiring layer; the logic it wires up lives in the testable src/lib. - "exclude": ["node_modules", "src/worker.ts"] + "include": ["src/**/*.ts", "test/**/*.ts", "*.ts"] } diff --git a/types/open-next-worker.d.ts b/types/open-next-worker.d.ts deleted file mode 100644 index bddcd3f..0000000 --- a/types/open-next-worker.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/** - * `.open-next/worker.js` is produced by `opennextjs-cloudflare build`. In a - * clean clone it does not exist until the first build, yet src/worker.ts has to - * import it. - * - * A wildcard module declaration only applies when TypeScript cannot resolve the - * real file, so once the artefact exists its own types win. With this in place, - * `pnpm typecheck` works on a fresh clone without building first. - */ -declare module "*/.open-next/worker.js" { - const handler: { - fetch: ExportedHandlerFetchHandler; - }; - export default handler; - - export const DOQueueHandler: typeof import("cloudflare:workers").DurableObject; - export const DOShardedTagCache: typeof import("cloudflare:workers").DurableObject; - export const BucketCachePurge: typeof import("cloudflare:workers").DurableObject; -} diff --git a/vitest.config.mts b/vitest.config.mts deleted file mode 100644 index 4d40509..0000000 --- a/vitest.config.mts +++ /dev/null @@ -1,76 +0,0 @@ -import fs from "node:fs"; -import path from "node:path"; -import { cloudflareTest, readD1Migrations } from "@cloudflare/vitest-pool-workers"; -import { defineConfig } from "vitest/config"; - -// The test database is built from the same migration files as production, so a -// test schema can never drift away from the real one -const migrations = await readD1Migrations( - path.join(import.meta.dirname, "drizzle/migrations"), -); - -/** - * Every theme's stylesheet, read here on the Node side because the Workers test - * runtime has no filesystem and Vite's `?raw` does not survive the CSS - * pipeline. - * - * Discovered by scanning the themes directory rather than listed by hand, so a - * theme added later is covered by the scoping guard automatically instead of - * only once somebody remembers to register it here. - */ -const themesDir = path.join(import.meta.dirname, "src/themes"); -const themeStylesheets = Object.fromEntries( - fs - .readdirSync(themesDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => [entry.name, path.join(themesDir, entry.name, "tokens.css")] as const) - .filter(([, file]) => fs.existsSync(file)) - .map(([name, file]) => [name, fs.readFileSync(file, "utf8")]), -); - -/** - * Each theme's Shell source, for the same reason. - * - * The root class is written by hand in a theme's Shell, so copying a theme and - * renaming its directory without renaming that class would put two themes back - * on one scope — reviving exactly the collision the scoping fix removed. This - * lets a test catch that without rendering, which the bare Workers runtime - * cannot do because next/link will not load in it. - */ -const themeShells = Object.fromEntries( - fs - .readdirSync(themesDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => [entry.name, path.join(themesDir, entry.name, "layout/Shell.tsx")] as const) - .filter(([, file]) => fs.existsSync(file)) - .map(([name, file]) => [name, fs.readFileSync(file, "utf8")]), -); - -export default defineConfig({ - // Tests run inside the real Workers runtime rather than a Node mock: the ways - // D1, KV and R2 actually behave — transaction semantics, the shape of - // constraint errors — only surface in the real thing - plugins: [ - cloudflareTest({ - wrangler: { configPath: "./wrangler.jsonc" }, - // Overrides main from wrangler.jsonc, which points at an OpenNext build - // artefact that does not exist yet when tests run - main: "./tests/worker-entry.ts", - miniflare: { - bindings: { - TEST_MIGRATIONS: migrations, - THEME_STYLESHEETS: themeStylesheets, - THEME_SHELLS: themeShells, - }, - }, - }), - ], - resolve: { - alias: { - "@": path.resolve(import.meta.dirname, "./src"), - }, - }, - test: { - setupFiles: ["./tests/apply-migrations.ts"], - }, -}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..4d4f262 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,47 @@ +import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; +import { defineConfig } from 'vitest/config'; + +/** + * The shop plugin's tests run inside the real Workers runtime, against this + * site's own Worker entry. + * + * The real thing rather than a Node mock, because what the plugin depends on — + * how a D1 batch rolls back, what a constraint failure looks like, how + * Mallok mounts a plugin route — only behaves truthfully in workerd. + * + * The environment is declared here in full instead of being read from + * `wrangler.jsonc`: reading that file would make the pool load the `.dev.vars` + * beside it, which is a developer's local secrets, in a test run. + */ +export default defineConfig({ + plugins: [ + cloudflareTest({ + main: 'src/worker/index.ts', + miniflare: { + compatibilityDate: '2026-08-01', + compatibilityFlags: ['nodejs_compat'], + d1Databases: ['DB'], + r2Buckets: ['MEDIA'], + bindings: { + MALLOK_SECRET: 'test-secret-do-not-use', + MALLOK_SITE: 'test', + // Lets a test create the administrator without a one-time setup + // key. A deployed site never has this. + MALLOK_DEV_ALLOW_SETUP_WITHOUT_KEY: 'true', + }, + // The same Text rule `wrangler.jsonc` declares: migrations and theme + // files are imported as strings. + modulesRules: [ + { + type: 'Text', + include: ['**/*.liquid', '**/*.css', '**/*.sql', '**/*.md'], + fallthrough: true, + }, + ], + }, + }), + ], + test: { + include: ['test/shop/**/*.test.ts', 'test/theme/**/*.test.ts'], + }, +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 4277fb7..a2606ae 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,103 +1,83 @@ +/** + * This site's Cloudflare configuration. + * + * `mallok create` fills in the Worker name, the D1 database id, the R2 bucket + * and — if you gave one — the custom domain. It edits this file in place; + * there is no second, nested config anywhere. + * + * The "Deploy to Cloudflare" button reads this file too, which is why every + * binding carries a default name. + */ { "$schema": "node_modules/wrangler/config-schema.json", - "name": "nundar", - // Custom entry: reuses OpenNext's fetch handler and adds the cron scheduled handler - "main": "src/worker.ts", - // Must not exceed the newest date the local workerd binary supports, or - // vitest-pool-workers refuses to start - "compatibility_date": "2026-08-22", - "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"], - + "name": "mallok-nundar", + "main": "src/worker/index.ts", + "compatibility_date": "2026-08-01", + "workers_dev": true, + "minify": true, + // Static Assets carries the admin app (`_mallok/app/**`) and the theme's + // files (`theme///**`). `mallok prepare` stages both into + // `dist/assets` before a build. A request that matches one is served by + // Cloudflare without invoking the Worker. "assets": { + "directory": "./dist/assets", "binding": "ASSETS", - "directory": ".open-next/assets" + "not_found_handling": "none" }, - - "observability": { - "enabled": true - }, - - // The primary database. - // - // database_id is a local placeholder: development and tests run against - // miniflare's simulated D1 and never read it. Deploying with the Deploy to - // Cloudflare button provisions the database and writes the real id back here. - // Deploying by hand means running `wrangler d1 create ` and pasting the - // returned id in. - // - // Migration and seed scripts always reference the **binding** DB, never the - // database name: someone else's deployment may name the database differently, - // and a hardcoded name would send their migrations looking for a database - // that does not exist. "d1_databases": [ { "binding": "DB", - "database_name": "nundar", - "database_id": "local-placeholder-replace-before-deploy", - "migrations_dir": "drizzle/migrations" + "database_name": "mallok-nundar-db", + "database_id": "00000000-0000-0000-0000-000000000000" } ], - "r2_buckets": [ - // Product originals and their pre-generated size variants { - // Must not be called IMAGES: that name is reserved by Cloudflare Images, - // and using it makes `wrangler types` emit ImagesBinding instead of R2Bucket "binding": "MEDIA", - "bucket_name": "nundar-media" - }, - // OpenNext's incremental (ISR) cache - { - "binding": "NEXT_INC_CACHE_R2_BUCKET", - "bucket_name": "nundar-inc-cache" - } - ], - - // Guest carts, admin sessions, rate-limit counters - "kv_namespaces": [ - { - "binding": "SESSIONS", - "id": "local-placeholder-replace-before-deploy" + "bucket_name": "mallok-nundar-media" } ], - - // Transactional email (order confirmation, shipping notice). The sending - // domain must be enabled first: - // npx wrangler email sending enable - "send_email": [{ "name": "EMAIL" }], - - // OpenNext's revalidation queue requires the Worker to reference itself - "services": [ + // One trigger per site: scheduled publishing and the media reclaim. + "triggers": { + "crons": ["* * * * *"] + }, + // Best-effort protection for plugin routes such as inquiry submission. The + // namespace id must be unique within the account; `mallok create` derives a + // stable one from the site slug. + "ratelimits": [ { - "binding": "WORKER_SELF_REFERENCE", - "service": "nundar" + "name": "RATE_LIMITER", + "namespace_id": "510736757", + "simple": { "limit": 10, "period": 60 } } ], - - // The ISR trio: the queue deduplicates time-based revalidation, the sharded - // tag cache backs on-demand revalidation, and BucketCachePurge does the purging - "durable_objects": { - "bindings": [ - { "name": "NEXT_CACHE_DO_QUEUE", "class_name": "DOQueueHandler" }, - { "name": "NEXT_TAG_CACHE_DO_SHARDED", "class_name": "DOShardedTagCache" }, - { "name": "NEXT_CACHE_DO_PURGE", "class_name": "BucketCachePurge" } - ] + "vars": { + "MALLOK_SITE": "nundar", + // Filled in by `mallok create --domain`. The Worker copies it into + // `site.domain` when the wizard finishes, so canonical URLs, hreflang and + // the sitemap describe the host the site is actually served on. + "MALLOK_DOMAIN": "" + // There is no var here asking for a setup key. The first-run wizard + // requires one by **default**: a site with no `MALLOK_SETUP_KEY` refuses + // to create an administrator at all, rather than falling back to letting + // anyone through. That window — between the first deploy and the secrets + // being set — is when a site is claimable by whoever finds its address, + // and it used to depend on a var being present and spelled correctly. + // + // `MALLOK_DEV_ALLOW_SETUP_WITHOUT_KEY` turns the requirement off for + // local `wrangler dev`, where there is no `mallok create` to mint a + // secret. It must never appear here: `mallok create` refuses to deploy a + // configuration that carries it. }, - - "migrations": [ + // A theme's templates and stylesheets are bundled as plain text. + "rules": [ { - "tag": "v1", - "new_sqlite_classes": [ - "DOQueueHandler", - "DOShardedTagCache", - "BucketCachePurge" - ] + "type": "Text", + "globs": ["**/*.liquid", "**/*.css", "**/*.sql", "**/*.md"], + "fallthrough": true } ], - - // Daily at 06:00 UTC: pull ECB rates and recompute prices whose drift passed - // the threshold - "triggers": { - "crons": ["0 6 * * *"] + "observability": { + "enabled": true } }