diff --git a/CLAUDE.md b/CLAUDE.md index ab2424b..f93a67a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,25 +49,35 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - **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. +- **What a page shows of the shop** is `plugins.shop`, built by `lib/storefront.ts` from rows alone and read by the `renderData` hook in `lib/render-data.ts`. That file's header is the contract a theme is written against. Mallok gives the hook one database call, a read: one batch of two statements serves a product page and a list alike, and a cold product page is three round trips in all (`test/theme/prices.test.ts` counts them). Every value arrives ready to print — a plugin cannot give a template a filter. The stock is read to decide a state and goes no further. +- **A page says which products it depends on**: the hook returns each product's translation group as a cache tag, which the page carries as `p:shop:` — a product page its own, a list or the home page every product's it shows, a product with no variants too, so that its first one reaches the page. +- **Variants are edited in the admin**, in a `records` panel under the editor of every product (`attachTo` in `plugin.json`; the handlers are `lib/variant-records.ts`). Mallok checks each value against the field the manifest declares and never writes the table itself: `saveVariant` judges what only the shop can — a SKU that is taken, a lead time that ends before it starts — and writes the variant, its prices and the stock ledger in one batch after one read. `price` is the base price; `other_prices` are entered by hand and stored `manual`, and need a base price; any other currency is derived from the base price at the stored rate. Whether a derived price is out of date is asked inside the write, of the table as the batch runs — a decision taken from the earlier reading goes wrong when two saves cross. The stock field comes to the form empty and writes only when a figure is typed. A deleted product takes its variants with it (`onContentDelete`, once its last language has gone); one kept because an order names it gives its SKU back. +- **Whoever changes what a page shows purges that product's tag**: a save or a delete in the admin, and a repricing chunk for the products whose prices moved, one call for the chunk. A handler does not wait for its purge — Mallok gathers two seconds of purges into one call, and a save that waited would take those two seconds — it hands the promise to `ctx.waitUntil`. A purge that does not happen leaves the pages to expire and is not a failed save. Mallok's `purgeTags` resolves, rather than rejects, when a purge was not attempted or was turned down, so its answer is read (`purgeOutcome`), never assumed. - **`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. +- **The cart is two routes** (`routes/cart.ts`). `GET /_mallok/p/shop/cart` is the cart page: the plugin returns a view — `lib/cart-view.ts`, whose header is the contract — and the theme's `shop/cart` layout draws it. `POST /_mallok/p/shop/cart/update` is where every form that changes the cart posts: add, set, remove, choose a currency. It never renders: every answer is a 303 to the cart page, and a change that is refused goes there with the reason and the variant in the address (`?refused=below_moq&variant=…`). The cart page reads the figures from the database and says them — nothing else from the address reaches the page. Do not go back to rendering the refusal from the POST: Mallok lists a page under the route that rendered it, so its language switcher and canonical would point at an address that only takes a POST. Mallok keys a handler by its path, so the page and the form cannot share one. The language is the segment after the plugin's id (`/_mallok/p/shop/de/cart`), and `lib/paths.ts` builds every such address. The cart cookie is scoped to `/_mallok/p/shop`, which every language shares: Mallok bypasses its edge cache for any public request that carries a cookie. A cookie that names no live cart — expired, or an id somebody chose — is never taken up: the id is all that protects a cart, so only the shop issues one. +- **A product can be bought when a visitor can see it**: `status = 'published' AND published_at <= now`, Mallok's own test — a page published for later is not out yet. Its name and its address are read from the core's `content` table (`title`, `path`), in the buyer's language, else the default one, else any it is out in. +- **One rule says which currency a page or a cart is in** (`settleCurrency` in `lib/currency.ts`): the one asked for when everything has a price in it, else the base currency, else whatever is shared. A page and the cart it leads to must not disagree, so neither has a rule of its own. +- **`readCartFacts` (`lib/cart-pricing.ts`) is the one reading of a cart**: every line judged against the database, in one round trip. `priceCart` turns it into an order's lines or its list of problems; the cart page shows it line by line. Do not grow a second calculation beside it. +- **A public page is the same for every visitor.** What is in a cart is never in one: the header's link to the cart, and the form a product page offers for a size, are rendered into the cached page and identical for all. The hook tells every page where the cart is (`cart_path`, `cart_action`) and costs no query for it. - **Orders and payment are built but not reachable** (design §14). `lib/orders.ts` places an order and confirms its payment, `lib/order-fulfilment.ts` ships, cancels and refunds, `lib/outbox.ts` records what each change still owes, `lib/stripe-webhook.ts` decides what a Stripe delivery means and which status to answer, `lib/stripe-signature.ts` and `lib/stripe-client.ts` talk to Stripe over `fetch`, `lib/order-email.ts` builds the buyer's emails. No route, page or admin screen calls them, and nothing drains the outbox: those wait for Mallok. Do not add a route that works around that. - **A change to an order is conditional on the status it was read in** (`ORDER_STILL_IN_STATUS` in `lib/order-guard.ts`), and the statement that changes the status comes last in its batch. That is what makes a racing second call write nothing. - **A change to an order writes its outbox row in the same batch** (`orderChangedOutbox`). What follows the change — an email, a purge — is owed from that row. Never send from a function's return value: it is lost whenever the Worker stops after the batch. Rows are read back by `seq`, the order they were committed in, never by `created_at`, which is whatever time the caller passed. -- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`, `availability`, `stripe-signature`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`. +- Pure logic (`money`, `pricing`, `ecb`, `order-state`, `currency`, `availability`, `storefront`, `stripe-signature`) takes no database; DB functions take a `D1Database` and, where time matters, a `now`. ### The theme and content - 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 — the home page's headline and sections, the footer, the links behind the buttons — is a theme option, never a string in a template; per-language option values go under `themeOptions.$locales` in `site.json`, and `test/project.test.ts` fails when a language is left without one. The tagline is not an option: it is Mallok's own setting, a map of language to text in `site.json`. It follows the site's name in the home page's title, and describes that page unless the `home_description` option gives the language a fuller text. - **Kinds**: `product`, `collection`, `application` (the sample's industry pages, at `/industries`), `case`, `faq`, `tool`, `article`, `page`. Every kind with a `base` has a `listLayout` here, because each list is a page worth having; a kind without one has no list page — its base path answers 404 — and no entry in `site.kinds`. A template links to a kind's list only through `site.kinds.`, inside `{% if %}`: the breadcrumb (`partials/crumbs.liquid`) and the home page's link to the catalogue do, and `test/theme/unlisted.test.ts` holds both on a site that serves products without a list. +- **The cart page is the layout `layouts/shop-cart.liquid`**, declared under `pluginLayouts` in `theme.json` as `shop/cart`. It reads `plugin_page`, not `content`; `plugins` is empty on it. Every control is a plain form posting to `plugin_page.action`, a quantity field carries the minimum order in `min` and `step`, and the page has no script. The plugin has no words: a refusal or a line's problem arrives as a kind (`below_moq`, `insufficient_stock`, `unavailable`, `no_price`, `quantity_too_large`, `cart_full`), the pack has a `problem_` for each, and the number it is about is printed after the words. The layout titles itself through `{% block title %}` in `layouts/base.liquid`, since Mallok titles a plugin's page with the site's name. +- **`plugins.shop` is optional everywhere.** It is absent when the plugin is off, when its read failed, in `mallok build` and in the admin's preview, and a product may have no variants. A template wraps what it prints from it in `{% if %}`, and the page is whole without it: the sizes with their SKUs, and no prices. A variant is found by walking `plugins.shop.variants`, never by `variants[sku]`: Liquid answers `size`, `first` and `last` on any collection itself. The words around a value — "Unit price", "In stock", "business days" — are the theme's, in its packs; `avail_` names the three states. - **A product is one page with its sizes on it**, not a page per size. `facets` are the attributes a buyer filters by; `sizes` maps each SKU to what distinguishes it; `specs` is the full table. `partials/spec-table.liquid` (the specification finder, the catalogue, a collection's products, a product's neighbours) takes its columns from the first product that has `facets` and fills every row by attribute name, so every product in a language must use the same names. Below 72rem the same table is laid out as cards, two to a row on a tablet: seven columns need about 1100px in German. - **References resolve by slug within the same language.** An `application` and a `case` name their `product`; the product page lists them through `content.backrefs.application` and `content.backrefs.case`. A `product` names its `collection`; the collection page lists `content.backrefs.product`. Mallok resolves `reference` only, not `reference[]`. - **A link in a Markdown body is plain text to Mallok**: it is not rewritten per language and nothing reports a dead one. Write the path of the page in the same language (`/de/products/`); `test/content.test.ts` checks every one. - `[[inquiry]]` on a line of its own becomes the inquiry plugin's form when that plugin is enabled. The plugin has its own labels in English and Chinese and reads any other language's from the theme's pack: the six `inquiry_*` keys in `locales/de.json`, `fr.json` and `es.json`. They are deliberately not in `en.json` — a pack falls back to the default one, so English keys would replace the plugin's own text in every language this theme has no pack for. -- The header puts the site name, the navigation, the language control and one button on a single line from 1280px. The sample's ten links fit in all four languages with little to spare (German is the longest); a longer label in `site.json` overflows that line, and only a look at the page at 1280px shows it. -- **A script only adds to a page that is already whole.** There are two. `assets/finder.js` puts filters above the specification table on the home page and in the catalogue. `assets/calculators.js` runs the three fastener calculators on a `tool` page whose front matter says `calculators: fasteners`, above a text that prints the same formulas, constants and tables — and `test/theme-scripts.test.ts` holds every constant in the script, and every figure it computes, to that page. In both cases the form is in the page `hidden`, with its labels from the language pack and its numbers in `value` attributes, so the script reads no language and the page offers nothing it cannot do. A script asks for a field with `querySelector`, never through `form.elements`: that list answers `length` with a count, whatever a field is called. A script is a plain file — no build step, no imports — declared in `theme.json`'s `clientScripts` with its exact size, and loaded only as ``, only by the layout that has what it works on. It hands its pure functions to `module.exports` when a `module` exists, which is how `test/theme-scripts.test.ts` runs them under `node:vm`; what it does to a page is checked in a browser, by hand. +- The header puts the site name, the navigation, the language control, the cart and one button on a single line from 1280px. The sample's ten links fit in all four languages with little to spare: at 1280px Spanish, the longest by its button, has about a dozen pixels left. A longer label in `site.json` or a longer `quote_short` in a pack runs past the page's right edge there, and only a look at the page at 1280px shows it — measure the last item against the content edge, not whether the page scrolls: the bar's own padding hides the first thirty pixels of an overrun. +- **A script only adds to a page that is already whole.** There are three. `assets/finder.js` puts filters above the specification table on the home page and in the catalogue. `assets/currency.js` is the currency switch: every price is rendered in the page's own currency and carries its amount in each other currency the page offers as `data-` attributes beside a `data-price` marker; the script puts one in its place when a button of `partials/currency.liquid` is pressed, and keeps the choice in `localStorage` — never a cookie, which would take that visitor's pages out of the shared cache. The switch and the attributes are printed only when the page has more than one currency to show, and the structured data stays in the page's own. `assets/calculators.js` runs the three fastener calculators on a `tool` page whose front matter says `calculators: fasteners`, above a text that prints the same formulas, constants and tables — and `test/theme-scripts.test.ts` holds every constant in the script, and every figure it computes, to that page. For the finder and the calculators the form is in the page `hidden`, with its labels from the language pack and its numbers in `value` attributes, so the script reads no language and the page offers nothing it cannot do. A script asks for a field with `querySelector`, never through `form.elements`: that list answers `length` with a count, whatever a field is called. A script is a plain file — no build step, no imports — declared in `theme.json`'s `clientScripts` with its exact size, and loaded only as ``, only by the layout that has what it works on. It hands its pure functions to `module.exports` when a `module` exists, which is how `test/theme-scripts.test.ts` runs them under `node:vm`; what it does to a page is checked in a browser, by hand. - Fonts are files in `assets/fonts/`, declared in `style.css` and preloaded in `layouts/base.liquid`; nothing is loaded from another host. Changing any asset means bumping `version` in `theme.json`: assets are served from a versioned path and cached for good. - 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: each product's fixes the `translation_group` that `seed/shop-sample.sql` attaches its variants to. `test/content.test.ts` checks the reference rule, keeps every identity file in step with its bundle, and holds the seed's variants to the SKUs the product pages list. - The default language (English) is unprefixed; others are `//…`. English pages default to USD, the rest to EUR (`lib/currency.ts`), never by IP. @@ -76,9 +86,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference: `mallok@0.1.0-rc.11` allows each of these; the code here does not use it yet. -- No `renderData` hook, so prices and variants are not on pages. -- No page route and no `pluginLayouts` in the theme, so there is no cart page. -- The variants panel is a read-only table; variants are seeded from `seed/shop-sample.sql`. +- A cart leads nowhere yet: it cannot be sent as one inquiry, and there is no checkout. The cart page ends on a link to the quote page. - No raw-body route, so no Stripe webhook, and so no checkout. - The sample's products name one collection each, though `reference[]` is resolved now. @@ -86,6 +94,10 @@ When one is built, take it off this list and prove the behaviour with a test or ### Known limits of mallok 0.1.0-rc.11 that shape the code +- `renderData` is told the items a list or the home page shows, and nothing of what a content page lists through a reference. So the products on a collection's page, and a product's neighbours, have no prices; `test/theme/prices.test.ts` holds that, and fails the day Mallok passes them. +- A `remove` handler of a records panel can only throw, which the admin shows as a failure with no reason; so deleting a variant that is on an order archives it instead of refusing. A panel's own order is always descending, so the variants list opens on the last one changed, not in the order a page shows them. +- `renderData` does not run for a not-found page, so the header there has no link to the cart. +- Nothing tells a plugin of a deletion it missed, and nothing here sweeps up afterwards: a variant with no product may as well be one that was loaded before its product was published, and a sweep cannot tell the two apart. - Switching a plugin on or off, or changing a setting, can leave cached pages as they were — and the admin's "Clear cached pages" can report success without clearing anything. So the plugins are switched on before the first publish (`scripts/lib/local-shop.mjs`, the README), and `test/shop/helpers.ts` deletes the cached home page by hand after changing settings. Keep both until a Mallok release says the purge can be trusted. ## Testing @@ -102,8 +114,13 @@ When one is built, take it off this list and prove the behaviour with a test or ## Commerce invariants (do not "simplify" them away) - Money is integer minor units (`lib/money.ts`), never a float. +- A page never prints how many are left: availability is a state (`lib/availability.ts`). A count would have to be purged from the edge cache on every sale. +- A page is in one currency. A currency is offered only when every priced variant on the page has a price in it (`currenciesFor`). Where there is none the page prints no price, and its structured data offers none. +- The structured data offers what the page prints and nothing else: the same variants, the same amounts, the same states (`offersFor`; the test compares the two digit for digit). - 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. +- MOQ and stock are enforced server-side in `quantityIssue`, shared by add-to-cart and cart pricing. The quantity field on a page starts at the minimum order and steps by it; that is a convenience, and the server is the check. +- A refused change writes nothing and creates no cart; a sum is stated only for a cart every line of which can be ordered as it stands. +- A redirect is judged by the path the browser will read, not the text that was sent: `/.//host` is one slash as typed and another site once resolved. - `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 relies on the constraint. - Stock comes off when a payment is confirmed, never when an order is placed, and only for stock-tracked variants. `markOrderPaid` is one batch — event, stock, ledger, outbox, status — and must stay one: splitting it brings back the state where stock is taken for an unpaid order, or an order is paid and its email never owed. - One payment takes stock once, however it is reported: the same event again, a different event for the same payment intent, or two deliveries at the same moment. The parallel tests in `test/shop/orders.test.ts` hold this; do not weaken them into sequential ones. @@ -113,7 +130,9 @@ When one is built, take it off this list and prove the behaviour with a test or - Money columns check `typeof(x) = 'integer'`: SQLite stores 99.5 in an `INTEGER` column rather than refuse it. - A Stripe failure is described by Stripe's identifiers and the status, never its free-text message: nothing rules out that text repeating a buyer's email address. - An order line is a snapshot of SKU, name and unit price. Nothing that later happens to the product may change a past order. -- A `manual` price is never overwritten; the base price is never rewritten; an `auto` price moves only past the drift threshold. +- A `manual` price is never overwritten; the base price is never rewritten; an `auto` price moves only past the drift threshold — or when the base price it is derived from is changed in the admin. Saving a variant for any other reason leaves its derived prices as they are. +- Stock set in the admin is a figure, and the ledger records the difference from the stock as it is when the write lands, in the same batch — not from what the form was opened with. A save that names no figure writes no stock at all. +- A variant that has ever been ordered is never deleted, by the admin or with its product: an order's lines and the ledger name it. It is archived. - 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. diff --git a/README.md b/README.md index cac4d7e..3e078a1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![CI](https://github.com/fobstack/Nundar/actions/workflows/ci.yml/badge.svg)](https://github.com/fobstack/Nundar/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-MIT%20OR%20Apache--2.0-blue)](LICENSE) -> **Status: in development.** Nundar is being rebuilt on Mallok. The catalogue side works today. Prices on pages, the cart page and checkout are not there yet — see [What works today](#what-works-today). It is not ready to run a real shop. +> **Status: in development.** Nundar is being rebuilt on Mallok. The catalogue side works today. Prices and a cart are on the pages; checkout is not there yet — see [What works today](#what-works-today). It is not ready to run a real shop. --- @@ -56,10 +56,10 @@ Nundar builds on `mallok@0.1.0-rc.11`, the first release with the plugin API the | | Works today | Not built yet | |---|---|---| -| **Pages** | A home page with a specification finder; product pages with their sizes and SKUs; collection, industry, case study, question, engineering reference and contact pages — all in English, German, French and Spanish, with `hreflang`, canonicals, sitemap and FAQ structured data; self-hosted fonts; no client JavaScript except two small scripts, each added to a page that is complete without it: filters for the finder, and calculators on the engineering reference page | Prices, variants and availability on the page; `Offer` structured data | -| **Catalogue data** | Variants, prices as integer minor units, stock, MOQ, lead time, a made-to-order policy | Editing them in the admin (read-only for now; the sample data is loaded from SQL) | -| **Pricing** | USD base price; EUR and GBP derived from ECB rates with a buffer, rounding to a price point and a drift threshold; manual prices never overwritten | | -| **Cart** | Add, set and remove through a plain form POST, with MOQ and stock enforced server-side | The cart page; submitting a cart as one inquiry | +| **Pages** | A home page with a specification finder; product pages with their sizes and SKUs, and for each size its price, minimum order, availability and lead time, read from the shop while the page is rendered and cached with it; the same offers in the page's `Product` structured data; a starting price under each product in the finder and the catalogue; collection, industry, case study, question, engineering reference and contact pages — all in English, German, French and Spanish, with `hreflang`, canonicals, sitemap and FAQ structured data; self-hosted fonts; prices in US dollars on English pages and in euros on the others, with a switch to any currency the shop prices in; no client JavaScript except three small scripts, each added to a page that is complete without it: filters for the finder, the currency switch, and calculators on the engineering reference page | Prices beside the products a collection page lists (Mallok does not yet tell a plugin which products a content page shows) | +| **Catalogue data** | Variants, prices as integer minor units, stock, MOQ, lead time, a made-to-order policy — edited in the admin, in a form under each product's editor; every change of stock goes into a ledger; a deleted product takes its variants with it | | +| **Pricing** | USD base price; EUR and GBP derived from ECB rates with a buffer, rounding to a price point and a drift threshold; manual prices never overwritten; the pages of a product are purged from the cache when one of its prices moves | | +| **Cart** | A form beside each size on a product page, and a cart page in the site's own design and language: set a quantity, remove a line, choose a currency. No script anywhere in it. MOQ and stock are enforced by the server, which says what it refused on the cart page | Submitting a cart as one inquiry; checkout | | **Orders and payment** | The logic, tested and not yet reachable: orders with line snapshots, a payment that takes stock exactly once however often Stripe reports it, oversold orders, refunds that return stock, Stripe signature checks, order emails in four languages | The checkout and order pages, the webhook route, and order handling in the admin | The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md). @@ -67,8 +67,11 @@ The reasoning and the plan are in [`docs/superpowers/specs/2026-09-30-nundar-on- ### Commerce rules that are easy to get wrong, and are tested - Money is always integer minor units. Never a float, anywhere. +- A page says whether a size can be had, never how many are left: a count would be wrong after the next sale, a state rarely is. +- The structured data offers exactly the prices the page prints. A test compares the two digit for digit. - The cart stores variants and quantities only — never a price. - MOQ is enforced by the form *and* by the server, because a form can be bypassed. +- A public page is the same for every visitor and stays in the cache: nothing about a cart is in it, and the cart's cookie is sent only to the shop's own routes. - Stock carries a database constraint, so a payment's decrement cannot go negative: a test proves the whole D1 batch rolls back. - Stock comes off when a payment is confirmed, never before, and once: tests deliver the same payment twice at the same moment. - A payment is believed only with Stripe's signature on the exact bytes received, and only for five minutes. @@ -124,7 +127,7 @@ The sample is a supplier of titanium fasteners that does not exist. Its products - **Content** is in `content/`, one directory per page, with a Markdown file per language. Replace it with your own and publish. - **The site's own copy** — the name, the navigation, the home page's headline and sections, the footer — is in `site.json`. Each language has its own values there; none of it is fixed in the theme. -- **Variants and prices** are in `seed/shop-sample.sql`, one variant for each SKU a product page lists. +- **Variants and prices** are in `seed/shop-sample.sql`, one variant for each SKU a product page lists, each with a US dollar base price and a euro and a sterling price entered by hand. Leave a currency out and the shop derives its price from the base price and the day's exchange rate. - **Fonts** are served from the site itself (`src/theme/assets/fonts/`), under the SIL Open Font License; the licence texts and the source of each file are beside them. The theme loads nothing from a third party. ## Commands @@ -142,7 +145,7 @@ The sample is a supplier of titanium fasteners that does not exist. Its products ## Deploying -Not yet. A deployed shop today would show a catalogue with no prices and no cart page. When Nundar is ready, deployment is Mallok's own: `npx mallok create . --slug `, which creates the Worker, the D1 database and the R2 bucket on your own Cloudflare account. That path has not been run for this repository. +Not yet. A deployed shop today would show a catalogue with prices and a cart that leads nowhere: there is no checkout, and a cart cannot be sent as an inquiry. Nothing here has been measured on a real Cloudflare account either. When Nundar is ready, deployment is Mallok's own: `npx mallok create . --slug `, which creates the Worker, the D1 database and the R2 bucket on your own Cloudflare account. That path has not been run for this repository. ## Languages and currencies diff --git a/README.zh-CN.md b/README.zh-CN.md index 5f55f3e..f55bf09 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -4,7 +4,7 @@ **[Mallok](https://github.com/fobstack/mallok) 的商城插件和商城主题。** 为想靠买家真实搜索词获得流量的跨境卖家而做,不去和所有人争抢同一个大词。 -> **状态:开发中。** Nundar 正在 Mallok 上重建。商品目录这一侧已经可用;页面上的价格、购物车页和结账还没有,见[现在能用什么](#现在能用什么)。目前还不能用来经营真实店铺。 +> **状态:开发中。** Nundar 正在 Mallok 上重建。页面上已有价格和购物车;结账还没有,见[现在能用什么](#现在能用什么)。目前还不能用来经营真实店铺。 --- @@ -55,10 +55,10 @@ Nundar 基于 `mallok@0.1.0-rc.11`,这是第一个带有商城其余部分所 | | 现在可用 | 尚未实现 | |---|---|---| -| **页面** | 带规格查找表的首页;列出尺寸和 SKU 的商品页;聚合页、行业页、案例页、问答页、工程资料页和联系页——全部有英、德、法、西四种语言,带 `hreflang`、canonical、sitemap 和 FAQ 结构化数据;字体由站点自己提供;除了两个小脚本之外没有客户端 JavaScript,而且页面没有它们也是完整的:一个给规格查找表加筛选,一个在工程资料页上提供计算器 | 页面上的价格、规格和库存状态;`Offer` 结构化数据 | -| **目录数据** | 规格、以整数最小单位存储的价格、库存、起订量、交期、按单生产策略 | 在后台编辑这些数据(目前只读,示例数据由 SQL 载入) | -| **定价** | 美元基准价;欧元和英镑按 ECB 汇率换算,带缓冲、价位取整和漂移阈值;手动价格永不被覆盖 | | -| **购物车** | 通过普通表单提交加入、修改、移除,服务端校验起订量和库存 | 购物车页;把购物车作为一次询盘提交 | +| **页面** | 带规格查找表的首页;列出尺寸和 SKU 的商品页,每个尺寸带价格、起订量、库存状态和交期(渲染页面时从商城读出,随页面一起缓存);同样的报价写进页面的 `Product` 结构化数据;规格查找表和目录里每个商品名下显示起价;聚合页、行业页、案例页、问答页、工程资料页和联系页——全部有英、德、法、西四种语言,带 `hreflang`、canonical、sitemap 和 FAQ 结构化数据;字体由站点自己提供;英文页面以美元标价,其余语言以欧元标价,并可切换到商城支持的任一币种;除了三个小脚本之外没有客户端 JavaScript,而且页面没有它们也是完整的:给规格查找表加筛选、币种切换、工程资料页上的计算器 | 聚合页所列商品旁的价格(Mallok 目前不会告诉插件一个内容页列出了哪些商品) | +| **目录数据** | 规格、以整数最小单位存储的价格、库存、起订量、交期、按单生产策略——在后台每个商品编辑器下方的表单里编辑;库存的每次变动都记入流水;删除商品时一并清理它的规格 | | +| **定价** | 美元基准价;欧元和英镑按 ECB 汇率换算,带缓冲、价位取整和漂移阈值;手动价格永不被覆盖;某个商品的价格变动时,清除该商品页面的缓存 | | +| **购物车** | 商品页每个尺寸旁有一个表单;购物车页沿用站点自己的设计和语言,可改数量、移除、选币种,全程不需要脚本。起订量和库存由服务端校验,被拒绝的原因直接显示在购物车页上 | 把购物车作为一次询盘提交;结账 | | **订单与付款** | 逻辑已写好并有测试,但还没有入口可以用到:带行快照的订单、无论 Stripe 通知多少次都只扣一次库存的付款、超卖订单、退还库存的退款、Stripe 签名校验、四种语言的订单邮件 | 结账页和订单页、webhook 路由、后台的订单处理 | 原因和计划见 [`docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md`](docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md)。 @@ -66,8 +66,11 @@ Nundar 基于 `mallok@0.1.0-rc.11`,这是第一个带有商城其余部分所 ### 容易做错、并且有测试保证的商业规则 - 金额一律是整数最小单位,任何地方都不用浮点数。 +- 页面只说一个尺寸能不能买到,不说还剩多少:数量在下一笔成交后就错了,状态很少变。 +- 结构化数据里的报价与页面上印出的价格完全一致,有测试逐位比对。 - 购物车只存规格和数量,绝不存价格。 - 起订量由表单校验,**并且**由服务端再校验一次,因为表单可以被绕过。 +- 公开页面对每个访客都一样,可以一直留在缓存里:页面上没有任何与购物车有关的内容,购物车的 cookie 只发给商城自己的路由。 - 库存带数据库约束,付款时的扣减不可能变成负数:有测试证明整个 D1 batch 会回滚。 - 库存只在付款确认后扣减,绝不提前,而且只扣一次:有测试让同一笔付款在同一时刻到达两次。 - 一笔付款只有带着 Stripe 对原始字节的签名才会被采信,而且只在五分钟内有效。 @@ -123,7 +126,7 @@ npm run seed:local - **内容**在 `content/` 里,每个页面一个目录,每种语言一个 Markdown 文件。换成你自己的再发布即可。 - **站点自己的文案**——名称、导航、首页的标题和各个区块、页脚——在 `site.json` 里。每种语言在那里有各自的值;这些文案没有一句写死在主题里。 -- **规格和价格**在 `seed/shop-sample.sql` 里,商品页上列出的每个 SKU 对应一个规格。 +- **规格和价格**在 `seed/shop-sample.sql` 里,商品页上列出的每个 SKU 对应一个规格,各有一个美元基准价和手工录入的欧元、英镑价格。某个币种不填,商城就按基准价和当天汇率换算。 - **字体**由站点自己提供(`src/theme/assets/fonts/`),采用 SIL Open Font License;许可文本和每个文件的来源放在同一目录。主题不从任何第三方加载资源。 ## 常用命令 @@ -141,7 +144,7 @@ npm run seed:local ## 部署 -暂时不要部署。现在部署出来的商城只有目录,没有价格,也没有购物车页。等 Nundar 就绪后,部署走 Mallok 自己的流程:`npx mallok create . --slug `,它会在你自己的 Cloudflare 账号上创建 Worker、D1 数据库和 R2 存储桶。这条路径还没有在本仓库上跑过。 +暂时不要部署。现在部署出来的商城有带价格的目录和购物车,但购物车没有下一步:还不能结账,也不能把购物车作为询盘提交;并且这里的一切都还没有在真实的 Cloudflare 账号上实测过。等 Nundar 就绪后,部署走 Mallok 自己的流程:`npx mallok create . --slug `,它会在你自己的 Cloudflare 账号上创建 Worker、D1 数据库和 R2 存储桶。这条路径还没有在本仓库上跑过。 ## 语言和币种 diff --git a/SECURITY.md b/SECURITY.md index 7af7496..6b6cbab 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -46,7 +46,7 @@ security document describes each. Nundar implements none of them again. | Stripe keys and the webhook signing secret | Not stored yet | They will be plugin secrets, which Mallok encrypts. No code path reads them today. | | Cart contents | D1, keyed by an unguessable 128-bit id | Variant ids and quantities only — **never prices**. A test asserts the table has no price column. | | The cart cookie | `HttpOnly`, `SameSite=Lax`, `Secure` on HTTPS, scoped to `/_mallok/p/shop` | It identifies a cart and nothing else. It is not a session and grants nothing in the admin. | -| Prices, stock, variants | D1 | Changed only through the admin or the plugin's own scheduled repricing. | +| Prices, stock, variants | D1 | Changed only through the admin's variant form — which needs a signed-in administrator, or a token with `content:write` — or the plugin's own scheduled repricing. Every change of stock is a row in a ledger. | ## Design decisions that are security controls @@ -64,21 +64,48 @@ These are deliberate and should not be "simplified" away: - **Every SQL statement is parameterised.** Lists of ids travel as one JSON parameter rather than being spliced into the statement. - **The redirect after a cart change goes only to a path on the same site.** - Another origin, a protocol-relative `//host` and a backslash trick all fall - back to the home page, so the cart cannot be used as an open redirect. -- **A cart cookie that is not a well-formed id is ignored**, and a fresh id is - issued. + It is judged by the path as a browser will read it, not by the text that + was sent: another origin, a protocol-relative `//host`, a backslash, and a + path that only becomes `//host` once its dot segments are resolved all + fall back to the cart page, so the cart cannot be used as an open redirect. +- **What a refused change shows comes from the database, not from the + address.** The buyer is sent to the cart page with the reason and the + variant in the address; the page looks the figures up. A link somebody made + up can choose among the shop's own sentences and put no words of its own + on the page. +- **Only what a visitor can see can be bought.** A product that is a draft, + or published for a later date, cannot be put in a cart by knowing its + variant's id. +- **A public page carries nothing of anyone's cart.** The form beside a size + and the header's link to the cart are the same for every visitor, so the + page stays in the shared cache. The cart cookie is sent only to the shop's + own routes, and the cart page is served `private, no-store` and `noindex`. +- **A form posted from another site is refused** by Mallok before the shop + sees it, so a page elsewhere cannot fill or empty a visitor's cart. +- **A page says whether a size can be had, never how many are left.** The + count is read to decide the state and goes no further. Only the cart page, + which is one visitor's own, says how many can be had — when it refuses a + quantity that is more than that. +- **A cart cookie that is not a well-formed id is ignored**, and so is one + that names no live cart: a fresh id is issued. The id is the only thing + that protects a cart, so the shop never takes up one a visitor chose, and + an expired cart's lines do not come back with the next thing added. - **Cart size is bounded**: at most 100 lines and 10,000 units per line, so one cart cannot be grown without limit. -- **The cart route is rate limited** through Mallok's rate-limit binding. +- **The cart's routes are rate limited** through Mallok's rate-limit bindings, each route with its own count per visitor, at the relaxed tier: a buyer changes a cart many times in ordinary use. - **Logs never contain personal data.** The scheduled work logs counts and a reference date. -- **The theme runs two scripts in a visitor's browser, and says so.** Each is - a file of the site's own, declared in `theme.json` with its size. One is +- **The theme runs three scripts in a visitor's browser, and says so.** Each + is a file of the site's own, declared in `theme.json` with its size. One is loaded on the home page and the catalogue to filter a table that is - already in the page; the other on an engineering reference page that asks - for it, to compute from numbers the visitor types. Neither sends anything - anywhere or stores anything. There is no inline + already in the page; one on an engineering reference page that asks for + it, to compute from numbers the visitor types; one on pages with prices in + more than one currency, to show another currency's amounts, which are + already in the page. None sends anything anywhere. The currency switch + keeps one thing, the code of the currency chosen, in the browser's own + storage — not in a cookie, which would be sent with every page — and works + without it where storage is blocked. The cart needs no script: adding, + changing and removing are plain forms. There is no inline script and no inline event handler. Tests fail if a template loads a script that is not declared, if a declared size is not the file's, or if any other page carries a script that is not structured data. @@ -141,19 +168,29 @@ to be remembered when the routes are written: Stated plainly rather than left for an auditor to find: -- **The rate limit is coarse.** Mallok's binding counts per Cloudflare location, - is eventually consistent, and gives every route of a plugin one shared budget - per address. It slows scripted abuse; it is not an exact quota. A determined - client can still create carts, and each costs D1 writes against the - account's daily allowance. +- **The rate limit is coarse.** Cloudflare's binding counts per location and + is eventually consistent, by its own description. It slows scripted abuse; + it is not an exact quota. A determined client can still create carts, and + each costs D1 writes against the account's daily allowance; the cart page + costs D1 reads on every request, since it is never cached. - **The exchange-rate feed is a third-party dependency.** If the ECB serves wrong data, automatic prices recompute from it. The drift threshold and the buffer limit the blast radius, and a failed fetch keeps the previous snapshot, but no sanity band on the rate itself is enforced yet. -- **Prices are not shown on cached pages yet**, so nothing can drift between a - page and the cart today. When they are, the page and its structured data - will be purged whenever a price or an availability state changes; until that - exists, do not show prices by another route. +- **A cached page can show a price that has since changed.** A page carries + the price it was rendered with, and is purged when a variant is saved in + the admin or a repricing run moves a price. Where the purge does not reach + — a site without a purge token, a purge Cloudflare refuses, or the defect + Mallok has recorded in which a purge is reported and does not happen — the + page keeps the old price until its cache entry expires, an hour by default. + The cart never does: it prices every line from the database on every + request, so what a buyer is shown before ordering is the price they pay. + Whether purges evict on a deployed site has not been verified here. +- **Setting stock in the admin overwrites it.** When a seller types a figure, + a payment that lands between opening the form and saving it is lost from + the figure, though not from the ledger, which records the difference from + the stock as it was when the write landed. A save that names no figure + writes no stock: the field comes to the form empty for that reason. - **Sample content and sample variants are public test data.** Do not load `seed/shop-sample.sql` into a production database. The sample catalogue describes a supplier that does not exist: its certifications, test figures diff --git a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md index bb00800..b084139 100644 --- a/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md +++ b/docs/superpowers/specs/2026-09-30-nundar-on-mallok-design.md @@ -547,3 +547,78 @@ The theme is `0.5.0`: two options were removed. The longer breadcrumb was measur **Two things noticed on the way, both written up for the Mallok side and neither worked around.** A content page's structured data is its own node and nothing else: there is no `BreadcrumbList`, and a theme cannot add one, since no template may carry a JSON-LD block. And the admin has no field for a home page description apart from the tagline, which is why `home_description` remains a theme option. **Not measured by anyone yet.** Mallok has no CPU figure for a cold render with `renderData` active, and no plugin has used API 2 on a deployed site. The measurements §9 asks for before each phase ships are still owed, on a real account. + +## 17. What phase 1B changed or found (2026-10-08) + +Phase 1B is the part of phase 1 that waited for Mallok (§12): prices on pages, the cart page, editing variants in the admin, and the inquiry cart. With `mallok@0.1.0-rc.11` in place (§16) the first three are built, on the plugin contract as it shipped and with nothing worked around. The fourth is not, and waits for a decision (below). + +**Prices on pages (§5.1).** Built as designed. The shop declares `"pluginApi": 2` and a `renderData` hook: one batch of two statements reads the variants and prices of the products a page shows, and the theme prints them — on a product page a row for each size with its price, minimum order, state and lead time; in the finder and the catalogue, what each product starts at. A cold product page is three D1 round trips, counted in workerd. + +Where the build is more specific than §5.1: + +- *A page is in one currency.* A currency is offered only when every priced variant on the page has a price in it; the language's own comes first when it is among them, the base currency otherwise. It is the rule `priceCart` already settled an order by, and for the same reason: amounts in two currencies cannot be compared or added. +- *The structured data.* One priced variant is an `Offer`; several are an `AggregateOffer` holding them, the form Google documents a price range for. A made-to-order variant is `BackOrder`: schema.org has `MadeToOrder`, but Google's documented values for `Offer.availability` do not include it (product structured data, read 2026-10-08), and `BackOrder` says the same to a buyer — it can be ordered now and ships later. `eligibleQuantity` carries a minimum order above one and `deliveryLeadTime` a lead time the page states (2026-09-03 §4.5.2). A test compares every offered price with the price its row prints, digit for digit. +- *Sizes.* A product page lists the sizes its front matter names, in its own order and words, and gives each the variant that carries its SKU; a variant the page does not name follows them, named by its option values. So nothing that can be bought is left off a page, and a page is whole without the plugin. +- *Collection pages have no prices.* The hook is told the items a list or the home page shows, and nothing of what a content page lists through a reference. The products on a collection's page, and a product's neighbours, therefore have names and no prices. This is a gap in Mallok, written up for it; a test here holds the state and fails the day it changes. + +**The currency switch (§5.1).** Built as designed: every price carries its amount in each other currency the page offers, a third declared script swaps them, and the choice is kept in `localStorage`. The switch and the attributes are in a page only when there is more than one currency to show. + +**The cart (§5.2).** Built, with three differences from the design: + +- *The forms post to `/_mallok/p/shop/cart/update`, not to `/cart`.* Mallok keys a route's handler by its path, so the page (`GET cart`) and the form cannot share one. +- *The language is a segment after the plugin's id* (`/_mallok/p/shop/de/cart`), which is how Mallok built API-3. The cookie's scope, `/_mallok/p/shop`, covers every language. +- *A refused change is sent to the cart page*, which says what was refused above the cart as it still is. The answer is a 303 with the reason and the variant in the address; the page reads the figures from the database and the theme has the words. The first build rendered the cart page from the POST itself, at 422; the review below showed why not. + +The quantity field starts at the minimum order and steps by it, as 2026-09-03 §4.5.1 asks; the server enforces the minimum and does not enforce the step. The cart page and an order are built from one reading of the cart (`readCartFacts`), so the two cannot disagree. The page costs two D1 round trips beyond Mallok's own, whatever the number of lines. + +**Variants in the admin (§5.6).** A `records` panel attached to the `product` kind, with the fields declared in `plugin.json` and three handlers. A base price derives the other currencies at the stored rate; a price entered by hand is `manual`, needs a base price, and is never recomputed; a derived price is recomputed on a save only when the base price changed, so that saving for another reason does not move a price inside the drift threshold. Stock is set to a figure and the ledger records the difference, measured in the same batch as the write; the field comes to the form empty, and a save that names no figure writes none. A variant that has been ordered is archived, never deleted — which also settles §14's open item 14 for the admin: nothing here deletes a variant an order names. + +**Purging (§5.1, §5.5).** A page carries `p:shop:` for every product it prices. A save or a delete in the admin purges that product's tag; a repricing chunk purges, in one call, the products whose prices it moved and no others. A purge that fails leaves pages to expire. + +**The sample.** The seeded variants gained a euro and a sterling price, entered by hand, so that a German page is in euros and the switch has something to switch. + +**Phase 1's exit criteria (§9), one by one.** + +| Criterion | State | +|---|---| +| Add-to-cart works with JavaScript disabled | Met. The forms are plain POSTs; the cart page carries no script at all. Tests and the smoke run submit the forms a page offers and never run one | +| MOQ enforced server-side, with a test that bypasses the form | Met, in workerd and in the smoke run on a real local Worker | +| `Offer` JSON-LD equals the rendered price | Met: compared digit for digit, in both currencies a page can be in | +| A price change purges exactly the affected pages | Met as far as a test without a zone can see: the tags asked for are exactly the products whose prices moved, and the pages carry those tags. **Whether Cloudflare then evicts them is not verified** — it needs a deployed site, and Mallok's own check on one found purges failing for a reason not yet known (§16) | +| Cold product render p50/p95 CPU and D1 round trips on a real Free account | **Not met.** Round trips are counted locally: three. CPU has not been measured by anyone | +| The cart submitted as one inquiry | **Not built.** See below | + +**How it was verified.** 52 file tests and 633 tests inside workerd, 184 of the latter new. 209 deliberate breakages of the new code — the hook, the views, the routes, the handlers, every template and all three scripts — of which 202 turned a test red, five change nothing a caller can observe, and two showed checks that could never fire, which were removed. The smoke run walks the whole path on a real local Worker: the sample published, a product page's form posted as it stands, a refused quantity followed to the page that explains it, the cart page read in English and in German, a variant added and removed the way the admin's form does. In a browser: a buyer's path clicked through by hand, the admin's form opened and saved, and every page of the sample measured at thirteen widths from 320 to 1920 px, the cart pages with three lines in them. + +The measuring found one regression before it was committed: with the cart's link in it, the header's one line ran past the page's edge at 1280 px in three of four languages. The gap between its links is now tightest where that line begins and opens out from there; Spanish, the longest, has about a dozen pixels to spare. It also showed that Spanish had been twelve pixels over since the theme was ported — inside the bar's own padding, so the page never scrolled and the earlier measurement, which looked for scrolling, did not see it. + +**What an independent review then found.** As with the payment core (§14), the finished work was given to a second reviewer with the properties it must hold and no account of how it holds them. Eleven defects and seven smaller things, most of them reproduced in workerd before they were reported. Each was checked here before it was acted on, and each fix has a test that fails without it. + +1. *An open redirect.* The path a form may send the buyer back to was judged by the text that was sent. `/.//host` begins with one slash and is `//host`, another site, once its dot segment is resolved. It is judged by the resolved path now. +2. *Structured data for prices the page did not print.* With no currency every priced variant shares, the page prints no price, and the offers were still built in the language's own currency. They are built only in a currency the page shows. +3. *Two saves crossing left a derived price from the wrong base price.* Whether to derive again was decided from the reading at the start of a save; another save that changed the base price in between left euros derived from a dollar price that had been overwritten. The question is now asked of the table inside the write. +4. *A form that could never succeed.* A minimum order above what a cart line may hold was offered a form, and every submission was refused. Such a variant has no form, and the admin refuses the minimum. +5. *The refusal page's language switcher led nowhere.* Mallok lists a page under the route that rendered it; rendered by the POST, the page's four language links and its canonical pointed at an address that answers a link with "method not allowed". A refusal is a redirect to the cart page now, which also means a reload does not post the form again. +6. *"From" was not the lowest price after switching currency.* The starting price in each currency was the price of whichever variant is cheapest in the page's own. It is the lowest in each. +7. *The cart and the page fell back differently*, and a part priced only in euros could be stuck without a price in a dollar cart with no control to change it. One function settles both. +8. *A part vanished from the cart when its buyer changed language*, if its product was out in another language only. The name is taken from the buyer's language, then the default one, then any the product is out in. +9. *An expired cart came back with the next thing added*, old lines and all; and a cart id a visitor made up was taken up as theirs. A cookie that names no live cart is not taken up at all. +10. *A purge that did not happen was counted.* Mallok's purge resolves, rather than rejects, when it was not attempted or was turned down. Its answer is read now. +11. *A product published for later could be put in a cart* by anyone who knew its variant's id. A product can be bought when a visitor can see it, by Mallok's own test. + +The smaller things: archiving a variant now takes it out of carts, as its help text said; the lead time in the structured data is in business days, as on the page, not days; every Update button on the cart page is named for its line; a saved form no longer writes back the stock it was opened with; a variant kept for an order after its product is deleted gives its SKU back; and two stale sentences in the README and the theme's description were corrected. One suggestion was looked at and not taken: a sweep for variants whose product is gone. A variant with no product may as well be one that was loaded before its product was published, and a sweep that cannot tell the two apart would delete a catalogue. + +Along the way the review's reading of the cart turned up a column: Mallok's `content` table holds each item's public address, so a cart line links to its product after all. + +**Left open.** + +1. *The inquiry cart.* Phase 1 ends with "submitting the cart as one inquiry, which is Mallok 0.2's inquiry cart" (§9). Whether that is the shop plugin's to build — a table of its own, a form on the cart page, an email to the seller — or Mallok's inquiry plugin's, which the shop would hand a cart to, is the owner's decision; building it here would be a second inquiry store beside Mallok's. Until then the cart page ends on the link to the quote page. +2. *Measurements on a real account*, and whether purges evict (the table above). +3. *The rounding rule and unit prices.* A derived price is rounded up to the next `.99` or whole unit (2026-09-03 §4.4). For a part priced at two dollars that turns $1.60, $1.85 and $2.05 alike into €1.99. The rule is the owner's and is unchanged; a catalogue of small parts wants a third strategy that rounds to the cent, or hand-entered prices, which is what the sample uses. +4. *Stock set in the admin is a figure, not a difference.* When the seller types one, a payment that lands between opening the form and saving it is overwritten by it. The ledger stays true — it records the difference from the stock as it was when the write landed — but the seller is not told. A save that names no figure touches no stock. +5. *The not-found page has no cart link*: it waits for Mallok (written up for it). +6. *The cart page's forms are not rate-limited beyond Mallok's relaxed tier* (120 a minute per visitor per route), which is best-effort by Cloudflare's own description. +7. *Ten thousand to a line.* `MAX_LINE_QUANTITY` came over from the previous implementation as a guard against scripted abuse. A catalogue of small parts sells washers by the fifty thousand; whether the ceiling is right for one is the owner's to say. Until then a minimum order above it is refused in the admin. +8. *The step is the browser's.* A quantity field moves in steps of the minimum order (2026-09-03 §4.5.1); the server enforces the minimum and accepts any quantity above it. Whether multiples are a rule or a convenience is not written down. +9. *A price of nothing.* The admin refuses one; a row with one, from a seed or by hand, is shown and can go in a cart, where it totals zero — which meets §14's open item 4 when checkout is built. +10. *Deletions the plugin missed.* If the delete hook fails, or the plugin is off when a product is deleted, its variants stay: on no page and in no order, with their SKUs taken. Nothing tells a plugin afterwards. diff --git a/scripts/smoke-shop.mjs b/scripts/smoke-shop.mjs index e5adb62..2b55e94 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -9,7 +9,9 @@ * 3. load the sample variants from `seed/shop-sample.sql`; * 4. request every kind of page, follow the navigation in every language, * and fetch the theme's own files; - * 5. add to the cart through the same form POST a product page sends. + * 5. add to the cart with the form a product page offers, and read the + * cart page it leads to; + * 6. add a variant the way the admin's form does, and delete it. * * It exists because the unit tests, thorough as they are, run each piece in * isolation. This is the one place the theme, the plugin, the content and the @@ -40,7 +42,11 @@ let failure = null; async function page(path) { const response = await fetch(`${base}${path}`, { signal: timeout() }); - return { status: response.status, html: await response.text() }; + return { + status: response.status, + html: await response.text(), + tags: (response.headers.get('cache-tag') ?? '').split(','), + }; } /** @@ -68,14 +74,41 @@ async function filesUnder(directory, prefix = '') { return found; } -function addToCart(fields, cookie) { - return fetch(`${base}/_mallok/p/shop/cart`, { +/** + * The form a page offers for one SKU, as a browser would submit it: where + * it posts, and the value of every field it carries. + */ +function offerForm(html, sku) { + const row = [...html.matchAll(/
  • ([\s\S]*?)<\/li>/g)] + .map((match) => match[1]) + .find((inside) => inside.includes(`${sku}`)); + const form = /]*\baction="([^"]*)"[^>]*>([\s\S]*?)<\/form>/.exec( + row ?? '', + ); + if (form === null) { + return null; + } + const fields = {}; + for (const [, tag] of form[2].matchAll(/]*)>/g)) { + const name = /\bname="([^"]*)"/.exec(tag)?.[1]; + if (name !== undefined) { + fields[name] = /\bvalue="([^"]*)"/.exec(tag)?.[1] ?? ''; + } + } + return { action: form[1], fields }; +} + +/** Submits a form the way a browser on this site does, without following. */ +function submit(form, change = {}, cookie) { + return fetch(`${base}${form.action}`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', + origin: base, + 'sec-fetch-site': 'same-origin', ...(cookie === undefined ? {} : { cookie }), }, - body: new URLSearchParams(fields).toString(), + body: new URLSearchParams({ ...form.fields, ...change }).toString(), redirect: 'manual', signal: timeout(), }); @@ -89,7 +122,7 @@ try { // 1 to 3. The administrator, the plugins, the settings, the content and // the variants: the same set-up `npm run preview` leaves running. - await fillShop({ base, state, tokenName: 'smoke-shop' }); + const { token } = await fillShop({ base, state, tokenName: 'smoke-shop' }); const variants = await query( `SELECT v.sku FROM p_shop_variant AS v @@ -123,9 +156,71 @@ try { 'the product page does not show the product', ); expect( - product.html.includes('TI-SHC-M5-20'), + product.html.includes( + 'TI-SHC-M5-2020 mm', + ), 'the product page does not list the SKU of a size it offers', ); + // What the shop plugin read for the page while Mallok rendered it: the + // seeded variant's price, its minimum order and its state, in the row of + // the size that carries its SKU. + const offered = + /
  • (?:(?!<\/li>)[\s\S])*TI-SHC-M5-20[\s\S]*?<\/li>/.exec( + product.html, + )?.[0] ?? ''; + expect( + offered.includes('>$2.20') && + offered.includes('
    100
    ') && + offered.includes('In stock') && + offered.includes('
    5–10 business days
    '), + 'the product page does not show the price, minimum order, availability and lead time of a size', + ); + // The row's price also holds its amount in the sample's other currencies, + // and the page has the switch that shows them — hidden, with its script. + expect( + offered.includes( + '$2.20', + ) && + /
    ]* data-currency="usd" hidden>/.test( + product.html, + ) && + /{% endif %} +{% if plugins.shop.currencies.size > 1 %}{% endif %} {% endblock %} diff --git a/src/theme/layouts/product.liquid b/src/theme/layouts/product.liquid index 49a196d..55a8798 100644 --- a/src/theme/layouts/product.liquid +++ b/src/theme/layouts/product.liquid @@ -38,20 +38,38 @@ {% endif %} {%- comment -%} - The sizes on offer, each with the SKU a buyer quotes. Prices and - availability will sit in this table once the shop plugin can supply - them to the page. + The sizes on offer, each with the SKU a buyer quotes. The sizes are + the page's own: written in its front matter, in its language. What + the shop plugin knows of each — its price, the least that can be + ordered, whether it can be had and how soon — goes in the row whose + SKU it carries. A variant the page does not name follows them, under + the name its options give it, so that nothing that can be bought is + left off the page. Without the plugin the list is the sizes alone. + + A variant is found by walking the list, not by `variants[sku]`: + Liquid answers `size`, `first` and `last` on any collection itself, + whatever a SKU happens to be called. {%- endcomment -%} {% assign sizes = content.frontmatter.sizes %} - {% if sizes != blank %} + {% assign shop = plugins.shop %} + {% if sizes != blank or shop.variants.size > 0 %}
    -

    {{ t.sizes }}

    - - - - {% for pair in sizes %}{% endfor %} - -
    {{ t.sku }}{{ t.size }}
    {{ pair[0] }}{{ pair[1] }}
    +
    +

    {{ t.sizes }}

    + {% render "partials/currency" %} +
    +
      + {% for pair in sizes %} + {% assign own = nil %} + {% for variant in shop.variants %}{% if variant.sku == pair[0] %}{% assign own = variant %}{% endif %}{% endfor %} + {% render "partials/offer", sku: pair[0], size: pair[1], variant: own %} + {% endfor %} + {% for variant in shop.variants %} + {% assign named = false %} + {% for pair in sizes %}{% if pair[0] == variant.sku %}{% assign named = true %}{% endif %}{% endfor %} + {% unless named %}{% render "partials/offer", sku: variant.sku, size: variant.label, variant: variant %}{% endunless %} + {% endfor %} +
    {% endif %} @@ -127,4 +145,8 @@ {% endif %} +{%- comment -%} + The currency switch, where there is more than one currency to show. +{%- endcomment -%} +{% if plugins.shop.currencies.size > 1 %}{% endif %} {% endblock %} diff --git a/src/theme/layouts/products.liquid b/src/theme/layouts/products.liquid index aac580a..d6ff8dd 100644 --- a/src/theme/layouts/products.liquid +++ b/src/theme/layouts/products.liquid @@ -22,4 +22,5 @@ Filters for the table above, added by a script when there is a table. {%- endcomment -%} {% if list.items.size > 0 %}{% endif %} +{% if plugins.shop.currencies.size > 1 %}{% endif %} {% endblock %} diff --git a/src/theme/layouts/shop-cart.liquid b/src/theme/layouts/shop-cart.liquid new file mode 100644 index 0000000..303665b --- /dev/null +++ b/src/theme/layouts/shop-cart.liquid @@ -0,0 +1,100 @@ +{% layout "layouts/base" %} +{%- comment -%} + The cart page. The route, what is in the cart and what each line costs are + the shop plugin's (`plugin_page`, described in the plugin's + `lib/cart-view.ts`); how it looks, and every word on it, are this theme's. + + The page is one visitor's own: Mallok serves it uncached and unindexed. + Every control is a plain form posting to `plugin_page.action`, so the cart + works without a script, and the quantity fields carry the minimum order in + `min` and `step` — which the server checks again. +{%- endcomment -%} +{% block title %}{{ t.cart_title }} — {{ site.name }}{% endblock %} +{% block content %} +{% assign cart = plugin_page %} +
    +
    +

    {{ t.cart_title }}

    +
    + + {%- comment -%} + A change that was refused: said here, above the cart as it still is. The + buyer was sent to this page with the reason in its address; the plugin + read the figures from the database and names the reason as a kind. The + words for it are in the pack, and the number it is about follows them. + {%- endcomment -%} + {% if cart.problem %} + {% assign reason = "problem_" | append: cart.problem.kind %} + + {% endif %} + + {% if cart.empty %} +

    {{ t.cart_empty }}

    + {% if site.kinds.product %}

    {{ t.back_to_catalogue }}

    {% endif %} + {% else %} +
      + {% for line in cart.lines %} +
    • +
      + {% if line.name != blank %}

      {% if line.path != blank %}{{ line.name }}{% else %}{{ line.name }}{% endif %}

      {% endif %} + {% if line.sku != blank %}

      {{ line.sku }}

      {% endif %} + {%- comment -%} + What stands in the way of ordering this line as it is. A line whose + product is gone has no name left, and says only that. + {%- endcomment -%} + {% if line.problem != blank %} + {% assign reason = "problem_" | append: line.problem %} +

      {{ t[reason] }}{% if line.problem == "below_moq" %} {{ line.moq }}{% elsif line.problem == "insufficient_stock" %} {{ line.available }}{% endif %}

      + {% endif %} +
      + {% if line.unit_price != blank %} +
      +
      {{ t.unit_price }}
      {{ line.unit_price }}
      +
      {{ t.line_total }}
      {{ line.line_total }}
      +
      + {% endif %} +
      + {% if line.problem != "unavailable" %} +
      + + + + +
      + {% endif %} +
      + + + +
      +
      +
    • + {% endfor %} +
    + +
    + {%- comment -%} + The currency, chosen here by a form: the page is rendered for this + visitor on every request, so the server can simply show another one. + {%- endcomment -%} + {% if cart.currencies.size > 1 %} +
    + + {% for code in cart.currencies %}{% endfor %} +
    + {% endif %} + {% if cart.subtotal != blank %}

    {{ t.subtotal }} {{ cart.subtotal }}

    {% endif %} +

    {{ t.cart_note }}

    + {% if theme.options.quote_href != blank or site.kinds.product %} +
    + {% if theme.options.quote_href != blank %}{{ t.request_quote }}{% endif %} + {% if site.kinds.product %}{{ t.back_to_catalogue }}{% endif %} +
    + {% endif %} +
    + {% endif %} +
    +{% endblock %} diff --git a/src/theme/locales/de.json b/src/theme/locales/de.json index 1c2a48e..e399be2 100644 --- a/src/theme/locales/de.json +++ b/src/theme/locales/de.json @@ -48,6 +48,35 @@ "sizes": "Größen", "sku": "Artikelnummer", "size": "Größe", + "unit_price": "Stückpreis", + "moq": "Mindestmenge", + "availability": "Verfügbarkeit", + "lead_time": "Lieferzeit", + "business_days": "Werktage", + "avail_in_stock": "Auf Lager", + "avail_made_to_order": "Auftragsfertigung", + "avail_out_of_stock": "Nicht auf Lager", + "price_on_request": "Auf Anfrage", + "price_from": "ab", + "currency": "Währung", + "cart": "Warenkorb", + "cart_title": "Ihr Warenkorb", + "cart_empty": "Ihr Warenkorb ist leer.", + "add_to_cart": "In den Warenkorb", + "quantity": "Menge", + "update": "Aktualisieren", + "remove": "Entfernen", + "line_total": "Summe", + "subtotal": "Zwischensumme", + "cart_note": "Versand und Steuern sind nicht enthalten.", + "back_to_catalogue": "Zurück zum Katalog", + "cart_refused": "Diese Änderung wurde nicht übernommen.", + "problem_below_moq": "Die Mindestmenge beträgt", + "problem_insufficient_stock": "Ab Lager verfügbar:", + "problem_unavailable": "Dieses Teil kann nicht mehr bestellt werden.", + "problem_no_price": "Für dieses Teil ist hier kein Preis hinterlegt. Bitte fragen Sie ein Angebot an.", + "problem_quantity_too_large": "Die größte bestellbare Menge beträgt", + "problem_cart_full": "Der Warenkorb kann keine weiteren Positionen aufnehmen.", "spec_highlights": "Worauf es hier ankommt", "used_in": "Branchen, die es spezifizieren", "cases_with": "Fallstudien mit diesem Teil", diff --git a/src/theme/locales/en.json b/src/theme/locales/en.json index bc688c2..d4fccd6 100644 --- a/src/theme/locales/en.json +++ b/src/theme/locales/en.json @@ -48,6 +48,35 @@ "sizes": "Sizes", "sku": "SKU", "size": "Size", + "unit_price": "Unit price", + "moq": "Minimum order", + "availability": "Availability", + "lead_time": "Lead time", + "business_days": "business days", + "avail_in_stock": "In stock", + "avail_made_to_order": "Made to order", + "avail_out_of_stock": "Out of stock", + "price_on_request": "On request", + "price_from": "from", + "currency": "Currency", + "cart": "Cart", + "cart_title": "Your cart", + "cart_empty": "Your cart is empty.", + "add_to_cart": "Add to cart", + "quantity": "Quantity", + "update": "Update", + "remove": "Remove", + "line_total": "Total", + "subtotal": "Subtotal", + "cart_note": "Shipping and taxes are not included.", + "back_to_catalogue": "Back to the catalogue", + "cart_refused": "That change was not made.", + "problem_below_moq": "The minimum order is", + "problem_insufficient_stock": "Available from stock:", + "problem_unavailable": "This part can no longer be ordered.", + "problem_no_price": "This part has no price here. Please ask for a quote.", + "problem_quantity_too_large": "The most that can be ordered at once is", + "problem_cart_full": "The cart cannot hold more different parts.", "spec_highlights": "What matters here", "used_in": "Industries that specify it", "cases_with": "Case studies with this part", diff --git a/src/theme/locales/es.json b/src/theme/locales/es.json index 3db9411..36b7107 100644 --- a/src/theme/locales/es.json +++ b/src/theme/locales/es.json @@ -8,7 +8,7 @@ "home": "Inicio", "breadcrumb": "Ruta de navegación", "request_quote": "Solicitar presupuesto a medida", - "quote_short": "Solicitar presupuesto", + "quote_short": "Pedir presupuesto", "contact_us": "Contactar con nuestro equipo", "find_spec": "Encontrar una especificación", "browse_by_type": "Productos por tipo", @@ -48,6 +48,35 @@ "sizes": "Medidas", "sku": "Referencia", "size": "Medida", + "unit_price": "Precio unitario", + "moq": "Pedido mínimo", + "availability": "Disponibilidad", + "lead_time": "Plazo de entrega", + "business_days": "días hábiles", + "avail_in_stock": "En existencias", + "avail_made_to_order": "Fabricación bajo pedido", + "avail_out_of_stock": "Agotado", + "price_on_request": "A consultar", + "price_from": "desde", + "currency": "Moneda", + "cart": "Carrito", + "cart_title": "Su carrito", + "cart_empty": "Su carrito está vacío.", + "add_to_cart": "Añadir al carrito", + "quantity": "Cantidad", + "update": "Actualizar", + "remove": "Quitar", + "line_total": "Total", + "subtotal": "Subtotal", + "cart_note": "Envío e impuestos no incluidos.", + "back_to_catalogue": "Volver al catálogo", + "cart_refused": "Ese cambio no se ha aplicado.", + "problem_below_moq": "El pedido mínimo es de", + "problem_insufficient_stock": "Disponible en existencias:", + "problem_unavailable": "Esta pieza ya no se puede pedir.", + "problem_no_price": "Esta pieza no tiene precio aquí. Solicite un presupuesto.", + "problem_quantity_too_large": "La cantidad máxima por pedido es de", + "problem_cart_full": "El carrito no admite más referencias distintas.", "spec_highlights": "Lo que importa aquí", "used_in": "Sectores que la especifican", "cases_with": "Casos de éxito con esta pieza", diff --git a/src/theme/locales/fr.json b/src/theme/locales/fr.json index 176e704..9703021 100644 --- a/src/theme/locales/fr.json +++ b/src/theme/locales/fr.json @@ -48,6 +48,35 @@ "sizes": "Dimensions", "sku": "Référence", "size": "Dimension", + "unit_price": "Prix unitaire", + "moq": "Quantité minimale", + "availability": "Disponibilité", + "lead_time": "Délai de livraison", + "business_days": "jours ouvrés", + "avail_in_stock": "En stock", + "avail_made_to_order": "Fabriqué à la commande", + "avail_out_of_stock": "En rupture de stock", + "price_on_request": "Sur demande", + "price_from": "à partir de", + "currency": "Devise", + "cart": "Panier", + "cart_title": "Votre panier", + "cart_empty": "Votre panier est vide.", + "add_to_cart": "Ajouter au panier", + "quantity": "Quantité", + "update": "Mettre à jour", + "remove": "Retirer", + "line_total": "Total", + "subtotal": "Sous-total", + "cart_note": "Hors frais de livraison et taxes.", + "back_to_catalogue": "Retour au catalogue", + "cart_refused": "Cette modification n’a pas été appliquée.", + "problem_below_moq": "La quantité minimale est de", + "problem_insufficient_stock": "Disponible en stock :", + "problem_unavailable": "Cette pièce ne peut plus être commandée.", + "problem_no_price": "Cette pièce n’a pas de prix ici. Merci de demander un devis.", + "problem_quantity_too_large": "La quantité maximale par commande est de", + "problem_cart_full": "Le panier ne peut pas contenir davantage de références.", "spec_highlights": "Ce qui compte ici", "used_in": "Secteurs qui la spécifient", "cases_with": "Études de cas avec cette pièce", diff --git a/src/theme/partials/currency.liquid b/src/theme/partials/currency.liquid new file mode 100644 index 0000000..846bed8 --- /dev/null +++ b/src/theme/partials/currency.liquid @@ -0,0 +1,18 @@ +{%- comment -%} + The currency switch. The prices on the page are in the page's own currency; + each also carries its amount in every other currency the page offers, and + `assets/currency.js` puts one of those in its place when a buyer asks. + + It is in the page hidden: without the script there is nothing to press, + and the page is what it was rendered as. It is not in the page at all when + there is nothing to switch between — no prices, or one currency. + + The buttons say the currencies' own codes, which are the same in every + language; the group's name is from the language pack. The layout that + renders this also loads the script. +{%- endcomment -%} +{% if plugins.shop.currencies.size > 1 %} + +{% endif %} diff --git a/src/theme/partials/header.liquid b/src/theme/partials/header.liquid index 1943c77..0eb5ae4 100644 --- a/src/theme/partials/header.liquid +++ b/src/theme/partials/header.liquid @@ -35,6 +35,16 @@ {% endif %} + {%- comment -%} + The cart, where there is a shop: the plugin says where its cart page + is, in this page's language, and a site without the plugin is told + nothing and shows no link. On the cart page itself the address comes + with the page. It is the same link for every visitor — what is in a + cart is never in a page the cache keeps. + {%- endcomment -%} + {% assign cart_path = plugins.shop.cart_path %} + {% if cart_path == blank %}{% assign cart_path = plugin_page.cart_path %}{% endif %} + {% if cart_path != blank %}{% render "partials/icon", name: "cart" %}{{ t.cart }}{% endif %} {%- comment -%} The short wording: this button shares one line with the whole navigation, in every language. diff --git a/src/theme/partials/icon.liquid b/src/theme/partials/icon.liquid index fafedea..985058d 100644 --- a/src/theme/partials/icon.liquid +++ b/src/theme/partials/icon.liquid @@ -10,6 +10,7 @@ {%- when "box" -%} {%- when "cog" -%} {%- when "shield" -%} + {%- when "cart" -%} {%- when "file" -%} {%- else -%} {%- endcase -%} diff --git a/src/theme/partials/offer.liquid b/src/theme/partials/offer.liquid new file mode 100644 index 0000000..7598558 --- /dev/null +++ b/src/theme/partials/offer.liquid @@ -0,0 +1,42 @@ +{%- comment -%} + One size of a product: the SKU a buyer quotes, what distinguishes it, and — + when the shop plugin has a variant under that SKU — what it costs, the + least that can be ordered, whether it can be had and how soon. + + `variant` is an entry of `plugins.shop.variants`, or nothing: a size the + shop does not sell yet, or a page rendered without the plugin, is the SKU + and the size alone. Its values arrive ready to print; the words around + them are this theme's. + + A price carries its amount in every currency the page offers, as + attributes, for the currency switch to put in its place. + + Called as {% render "partials/offer", sku: …, size: …, variant: … %}. +{%- endcomment -%} +
  • +

    {{ sku }}{% if size != blank %}{{ size }}{% endif %}

    + {% if variant %} + {% assign state = "avail_" | append: variant.availability %} +
    +
    {{ t.unit_price }}
    {% if variant.price != blank %} 1 %} data-price{% for price in variant.prices %} data-{{ price.currency | downcase }}="{{ price.display }}"{% endfor %}{% endif %}>{{ variant.price }}{% else %}{{ t.price_on_request }}{% endif %}
    +
    {{ t.moq }}
    {{ variant.moq }}
    +
    {{ t.availability }}
    {{ t[state] }}
    + {% if variant.lead_time != blank %}
    {{ t.lead_time }}
    {{ variant.lead_time }} {{ t.business_days }}
    {% endif %} +
    + {%- comment -%} + The form that puts this size in the cart: a plain POST, no script. The + quantity starts at the minimum order and moves in steps of it. A size + without a price, or that cannot be had, is offered no form — the server + would refuse it. The currency is the one the page shows; the currency + switch changes this field with the prices. + {%- endcomment -%} + {% if variant.orderable and plugins.shop.cart_action != blank %} +
    + + + + +
    + {% endif %} + {% endif %} +
  • diff --git a/src/theme/partials/spec-table.liquid b/src/theme/partials/spec-table.liquid index 538cd8a..515d15e 100644 --- a/src/theme/partials/spec-table.liquid +++ b/src/theme/partials/spec-table.liquid @@ -25,6 +25,12 @@ page without the script is the whole table, as it is without `filters`. The layout that asks for filters also loads that script. + Under a product's name goes what the shop plugin says of it, when it says + anything: the lowest price it can be ordered at and whether it can be had. + The plugin is told which products a list or the home page shows; it is not + told which ones a content page lists, so the same table on a collection's + page, or under a product, has names and no prices. + Called as {% render "partials/spec-table", items: recent.product %}. {%- endcomment -%} {% assign lead = nil %} @@ -38,6 +44,7 @@ {% endif %} +{% if plugins.shop.products %}{% render "partials/currency" %}{% endif %}
    @@ -52,9 +59,13 @@ {% for item in items %} {% for pair in lead.frontmatter.facets %}{% assign name = pair[0] %}{% assign value = "" %}{% for own in item.frontmatter.facets %}{% if own[0] == name %}{% assign value = own[1] %}{% endif %}{% endfor %}{% endfor %} diff --git a/src/theme/theme.json b/src/theme/theme.json index 4b9ec56..c53a7f1 100644 --- a/src/theme/theme.json +++ b/src/theme/theme.json @@ -1,8 +1,8 @@ { "id": "nundar", "name": "Nundar", - "description": "A commerce theme for a technical catalogue: a specification finder, product pages built around a size table, landing pages for the applications a part is used in, collections by type, case studies and reference pages. HTML first: every page is complete without a script, and the two it declares only add to what is already there — filters for a table, calculators beside the tables of an engineering reference.", - "version": "0.5.0", + "description": "A commerce theme for a technical catalogue: a specification finder, product pages built around a size table, landing pages for the applications a part is used in, collections by type, case studies and reference pages. With the shop plugin, each size carries its price, minimum order and availability, and the cart is a page of the site. HTML first: every page is complete without a script, and the three it declares only add to what is already there — filters for a table, a switch between the currencies a page has prices in, calculators beside the tables of an engineering reference.", + "version": "0.10.0", "themeApi": 1, "home": "layouts/home.liquid", "kinds": { @@ -296,6 +296,9 @@ "locales": ["en", "de", "fr", "es"], "defaultLocale": "en", "imageWidths": [480, 960, 1440], + "pluginLayouts": { + "shop/cart": "layouts/shop-cart.liquid" + }, "clientScripts": [ { "path": "assets/finder.js", @@ -306,6 +309,11 @@ "path": "assets/calculators.js", "purpose": "Engineering reference pages that ask for it only: three fastener calculators above a page that prints the same formulas, constants and tables of results.", "bytes": 9426 + }, + { + "path": "assets/currency.js", + "purpose": "Pages that show prices in more than one currency only: buttons that put each price's amount in another currency in its place, and keep the choice in this browser. The page is complete, in its language's own currency, without it.", + "bytes": 3202 } ] } diff --git a/test/shop/cart-route.test.ts b/test/shop/cart-route.test.ts index 9027d9c..9b4f1c5 100644 --- a/test/shop/cart-route.test.ts +++ b/test/shop/cart-route.test.ts @@ -13,12 +13,20 @@ import { let valve: TestProduct; let draft: TestProduct; -/** Posts the form a product page would post, optionally with a cart cookie. */ +const CART = '/_mallok/p/shop/cart'; + +/** + * Posts the form a product page would post, optionally with a cart cookie. + * `locale` is the language of the page the form is on: it is part of the + * address the form posts to, after the plugin's id. + */ function post( fields: Record, cookie?: string, + locale?: string, ): Promise { - return SELF.fetch(`${ORIGIN}/_mallok/p/shop/cart`, { + const base = locale === undefined ? '' : `/${locale}`; + return SELF.fetch(`${ORIGIN}/_mallok/p/shop${base}/cart/update`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded', @@ -29,6 +37,27 @@ function post( }); } +/** + * What a refused change is answered with: a redirect to the cart page, with + * the reason and the variant it was about in the address. The page reads the + * figures from the database and says them; `test/theme/cart.test.ts` reads + * that page. + */ +function refusal(response: Response): { + status: number; + path: string; + kind: string | null; + variant: string | null; +} { + const location = new URL(response.headers.get('location') ?? '/', ORIGIN); + return { + status: response.status, + path: location.pathname, + kind: location.searchParams.get('refused'), + variant: location.searchParams.get('variant'), + }; +} + /** The `name=value` pair of the cart cookie a response set, if any. */ function cartCookie(response: Response): string | undefined { return response.headers.get('set-cookie')?.split(';')[0]; @@ -52,7 +81,7 @@ async function cartCount(): Promise { return row?.n ?? 0; } -describe('POST /_mallok/p/shop/cart', () => { +describe('POST /_mallok/p/shop/cart/update', () => { beforeAll(async () => { await ensureSite(); valve = await createProduct({ @@ -76,7 +105,27 @@ describe('POST /_mallok/p/shop/cart', () => { }); }); - it('adds a line, sets a cart cookie and sends the buyer back', async () => { + it('adds a line, sets a cart cookie and sends the buyer to the cart', async () => { + const response = await post({ variant: 'dn50', quantity: '10' }); + + expect(response.status).toBe(303); + expect(response.headers.get('location')).toBe(CART); + expect(cartCookie(response)).toMatch(/^nundar_cart=[0-9a-f]{32}$/); + expect(await lines()).toMatchObject([{ variant_id: 'dn50', quantity: 10 }]); + }); + + it('sends the buyer to the cart in the language of the page they came from', async () => { + const response = await post( + { variant: 'dn50', quantity: '10' }, + undefined, + 'de', + ); + + expect(response.status).toBe(303); + expect(response.headers.get('location')).toBe('/_mallok/p/shop/de/cart'); + }); + + it('sends the buyer back where the form says, when it says', async () => { const response = await post({ variant: 'dn50', quantity: '10', @@ -85,7 +134,6 @@ describe('POST /_mallok/p/shop/cart', () => { expect(response.status).toBe(303); expect(response.headers.get('location')).toBe('/products/route-ball-valve'); - expect(await lines()).toMatchObject([{ variant_id: 'dn50', quantity: 10 }]); }); it('scopes the cookie to the plugin’s path so public pages stay cacheable', async () => { @@ -110,12 +158,13 @@ describe('POST /_mallok/p/shop/cart', () => { // skips that, so the server has to say no itself. const response = await post({ variant: 'dn50', quantity: '9' }); - expect(response.status).toBe(422); - expect(await response.json()).toEqual({ - error: 'below_moq', - variantId: 'dn50', - moq: 10, - requested: 9, + // Sent to the cart page, which says why; nothing in the address but + // the reason and the variant. + expect(refusal(response)).toEqual({ + status: 303, + path: CART, + kind: 'below_moq', + variant: 'dn50', }); expect(await lines()).toEqual([]); // A refused request must not leave an empty cart behind either. @@ -144,7 +193,7 @@ describe('POST /_mallok/p/shop/cart', () => { { action: 'set', variant: 'dn50', quantity: '3' }, cookie, ); - expect(tooFew.status).toBe(422); + expect(refusal(tooFew)).toMatchObject({ status: 303, kind: 'below_moq' }); expect(await lines()).toMatchObject([{ quantity: 40 }]); }); @@ -169,12 +218,13 @@ describe('POST /_mallok/p/shop/cart', () => { it('refuses more than the stock of a tracked variant', async () => { const response = await post({ variant: 'dn50', quantity: '101' }); - expect(response.status).toBe(422); - expect(await response.json()).toMatchObject({ - error: 'insufficient_stock', - available: 100, - requested: 101, + expect(refusal(response)).toMatchObject({ + status: 303, + kind: 'insufficient_stock', + variant: 'dn50', }); + expect(await lines()).toEqual([]); + expect(response.headers.get('set-cookie')).toBeNull(); }); it('counts what is already in the cart against the stock', async () => { @@ -185,7 +235,10 @@ describe('POST /_mallok/p/shop/cart', () => { cartCookie(first), ); - expect(second.status).toBe(422); + expect(refusal(second)).toMatchObject({ + status: 303, + kind: 'insufficient_stock', + }); expect(await lines()).toMatchObject([{ quantity: 60 }]); }); @@ -215,10 +268,14 @@ describe('POST /_mallok/p/shop/cart', () => { for (const variant of ['ghost', 'old', 'secret']) { const response = await post({ variant, quantity: '1' }); - expect(response.status).toBe(409); - expect(await response.json()).toMatchObject({ error: 'unavailable' }); + expect(refusal(response)).toMatchObject({ + status: 303, + kind: 'unavailable', + variant, + }); } expect(await lines()).toEqual([]); + expect(await cartCount()).toBe(0); }); it('refuses an absurd quantity', async () => { @@ -230,10 +287,39 @@ describe('POST /_mallok/p/shop/cart', () => { const response = await post({ variant: 'bulk', quantity: '10001' }); - expect(response.status).toBe(422); - expect(await response.json()).toMatchObject({ - error: 'quantity_too_large', + expect(refusal(response)).toMatchObject({ + status: 303, + kind: 'quantity_too_large', }); + expect(await lines()).toEqual([]); + }); + + it('refuses one more kind of part than a cart may hold', async () => { + const first = await post({ variant: 'dn50', quantity: '10' }); + const cookie = cartCookie(first); + const cartId = cookie?.split('=')[1] ?? ''; + // A cart at its limit, filled directly: a hundred requests would say + // nothing more. + await db().batch( + Array.from({ length: 99 }, (_, index) => + db() + .prepare( + 'INSERT INTO p_shop_cart_line (cart_id, variant_id, quantity) VALUES (?, ?, 1)', + ) + .bind(cartId, `filler-${index}`), + ), + ); + await createVariant({ id: 'extra', productGroup: valve.translationGroup }); + + const response = await post({ variant: 'extra', quantity: '1' }, cookie); + + expect(refusal(response)).toMatchObject({ status: 303, kind: 'cart_full' }); + // A part already in the cart can still be given another quantity. + const more = await post( + { action: 'set', variant: 'dn50', quantity: '20' }, + cookie, + ); + expect(more.status).toBe(303); }); it('refuses malformed input', async () => { @@ -242,7 +328,10 @@ describe('POST /_mallok/p/shop/cart', () => { { variant: 'dn50', quantity: '-5' }, { variant: 'dn50', quantity: '1.5' }, { variant: '', quantity: '10' }, + { quantity: '10' }, { action: 'steal', variant: 'dn50', quantity: '10' }, + { action: 'currency', currency: 'JPY' }, + { action: 'currency' }, ]) { const response = await post(fields); expect(response.status).toBe(400); @@ -251,12 +340,11 @@ describe('POST /_mallok/p/shop/cart', () => { }); it('records the language and currency the buyer was browsing in', async () => { - await post({ - variant: 'dn50', - quantity: '10', - locale: 'de', - currency: 'GBP', - }); + await post( + { variant: 'dn50', quantity: '10', currency: 'GBP' }, + undefined, + 'de', + ); const cart = await db() .prepare('SELECT locale, currency FROM p_shop_cart') @@ -264,8 +352,8 @@ describe('POST /_mallok/p/shop/cart', () => { expect(cart).toEqual({ locale: 'de', currency: 'GBP' }); }); - it('defaults the currency from the language: USD for English, EUR otherwise', async () => { - await post({ variant: 'dn50', quantity: '10', locale: 'de' }); + it('starts a cart in its language’s currency: USD for English, EUR otherwise', async () => { + await post({ variant: 'dn50', quantity: '10' }, undefined, 'de'); const german = await db() .prepare('SELECT currency FROM p_shop_cart') .first<{ currency: string }>(); @@ -280,13 +368,8 @@ describe('POST /_mallok/p/shop/cart', () => { expect(english?.currency).toBe('USD'); }); - it('falls back to defaults for a language or currency it does not know', async () => { - await post({ - variant: 'dn50', - quantity: '10', - locale: 'xx', - currency: 'JPY', - }); + it('starts a cart in its language’s currency when the form names one it does not know', async () => { + await post({ variant: 'dn50', quantity: '10', currency: 'JPY' }); const cart = await db() .prepare('SELECT locale, currency FROM p_shop_cart') @@ -294,6 +377,148 @@ describe('POST /_mallok/p/shop/cart', () => { expect(cart).toEqual({ locale: 'en', currency: 'USD' }); }); + it('keeps a cart’s currency when a later change names none', async () => { + const first = await post({ + variant: 'dn50', + quantity: '10', + currency: 'GBP', + }); + + // The cart page's own forms name no currency: only what changes. + await post( + { action: 'set', variant: 'dn50', quantity: '20' }, + cartCookie(first), + 'de', + ); + + const cart = await db() + .prepare('SELECT currency FROM p_shop_cart') + .first<{ currency: string }>(); + expect(cart?.currency).toBe('GBP'); + }); + + it('changes the currency a cart is shown in', async () => { + const first = await post({ variant: 'dn50', quantity: '10' }); + + const response = await post( + { action: 'currency', currency: 'EUR' }, + cartCookie(first), + ); + + expect(response.status).toBe(303); + expect(response.headers.get('location')).toBe(CART); + const cart = await db() + .prepare('SELECT currency FROM p_shop_cart') + .first<{ currency: string }>(); + expect(cart?.currency).toBe('EUR'); + expect(await lines()).toMatchObject([{ variant_id: 'dn50', quantity: 10 }]); + }); + + it('creates no cart for a visitor who only chooses a currency', async () => { + const response = await post({ action: 'currency', currency: 'EUR' }); + + expect(response.status).toBe(303); + expect(await cartCount()).toBe(0); + expect(response.headers.get('set-cookie')).toBeNull(); + }); + + it('creates no cart for a cookie that names one which does not exist', async () => { + // A cart that expired, or an id made up: well formed, and nobody's. + const response = await post( + { action: 'currency', currency: 'EUR' }, + 'nundar_cart=0123456789abcdef0123456789abcdef', + ); + + expect(response.status).toBe(303); + expect(await cartCount()).toBe(0); + // And the made-up id is not confirmed by handing it back. + expect(response.headers.get('set-cookie')).toBeNull(); + }); + + it('is not there under a language the site does not have', async () => { + const response = await post( + { variant: 'dn50', quantity: '10' }, + undefined, + 'xx', + ); + + expect(response.status).toBe(404); + expect(await cartCount()).toBe(0); + }); + + it('sends a refusal to the cart in the language of the page the form was on', async () => { + const response = await post( + { variant: 'dn50', quantity: '1' }, + undefined, + 'de', + ); + + expect(refusal(response)).toMatchObject({ + status: 303, + path: '/_mallok/p/shop/de/cart', + kind: 'below_moq', + }); + }); + + it('does not sell a product that is published for later', async () => { + // Mallok shows a page only once its time has come; a cart must not be + // a way round that. + const later = await createProduct({ + title: 'Not out yet', + slug: 'route-not-out-yet', + }); + await db() + .prepare( + "UPDATE content SET published_at = '2999-01-01T00:00:00.000Z' WHERE id = ?", + ) + .bind(later.id) + .run(); + await createVariant({ id: 'early', productGroup: later.translationGroup }); + + const response = await post({ variant: 'early', quantity: '1' }); + + expect(refusal(response)).toMatchObject({ kind: 'unavailable' }); + expect(await lines()).toEqual([]); + }); + + it('does not bring an expired cart back with the next thing added', async () => { + const first = await post({ variant: 'dn50', quantity: '10' }); + const old = cartCookie(first); + await createVariant({ id: 'dn80', productGroup: valve.translationGroup }); + // Nobody touched it for a month, and the hourly clean-up has not come + // round yet: the rows are still there. + await db() + .prepare("UPDATE p_shop_cart SET expires_at = '2020-01-01T00:00:00.000Z'") + .run(); + + const again = await post({ variant: 'dn80', quantity: '1' }, old); + + expect(again.status).toBe(303); + // A new cart, under a new id; the old one's line is not in it. + const fresh = cartCookie(again); + expect(fresh).toMatch(/^nundar_cart=[0-9a-f]{32}$/); + expect(fresh).not.toBe(old); + const mine = (await lines()).filter( + (line) => `nundar_cart=${line.cart_id}` === fresh, + ); + expect(mine).toMatchObject([{ variant_id: 'dn80', quantity: 1 }]); + }); + + it('issues its own id rather than take up one the visitor chose', async () => { + // The id is all that protects a cart. One a visitor could pick, somebody + // else could have picked for them. + const chosen = 'nundar_cart=0123456789abcdef0123456789abcdef'; + + const response = await post({ variant: 'dn50', quantity: '10' }, chosen); + + expect(response.status).toBe(303); + expect(cartCookie(response)).toMatch(/^nundar_cart=[0-9a-f]{32}$/); + expect(cartCookie(response)).not.toBe(chosen); + expect((await lines()).map((line) => line.cart_id)).not.toContain( + '0123456789abcdef0123456789abcdef', + ); + }); + it('only ever redirects to a path on this site', async () => { for (const target of [ 'https://evil.example/', @@ -301,6 +526,13 @@ describe('POST /_mallok/p/shop/cart', () => { '/\\evil.example', 'javascript:alert(1)', 'evil', + // One slash as sent, two once the dot segment is resolved — and two + // slashes at the start of a path are another site. + '/.//evil.example/x', + '/..//evil.example', + '/a/..//evil.example', + '/%2e//evil.example', + '/./\\evil.example', ]) { const response = await post({ variant: 'dn50', @@ -308,7 +540,7 @@ describe('POST /_mallok/p/shop/cart', () => { return: target, }); expect(response.status).toBe(303); - expect(response.headers.get('location')).toBe('/'); + expect(response.headers.get('location')).toBe(CART); } }); diff --git a/test/shop/currency.test.ts b/test/shop/currency.test.ts index f223531..30037fb 100644 --- a/test/shop/currency.test.ts +++ b/test/shop/currency.test.ts @@ -4,6 +4,7 @@ import { CURRENCIES, defaultCurrencyForLocale, isCurrency, + settleCurrency, } from '../../src/plugins/shop/lib/currency.js'; describe('defaultCurrencyForLocale', () => { @@ -34,3 +35,25 @@ describe('isCurrency', () => { expect(isCurrency(BASE_CURRENCY)).toBe(true); }); }); + +describe('settleCurrency', () => { + it('is the currency asked for when everything has a price in it', () => { + expect(settleCurrency('EUR', ['USD', 'EUR', 'GBP'])).toBe('EUR'); + expect(settleCurrency('GBP', ['USD', 'GBP'])).toBe('GBP'); + }); + + it('is the base currency when the one asked for is not shared', () => { + expect(settleCurrency('EUR', ['USD', 'GBP'])).toBe('USD'); + }); + + it('is whatever is shared when neither is', () => { + // A part priced only in euros, on a page that asked for dollars: shown + // in euros rather than not at all. + expect(settleCurrency('USD', ['EUR'])).toBe('EUR'); + expect(settleCurrency('GBP', ['EUR'])).toBe('EUR'); + }); + + it('is the currency asked for when nothing is shared at all', () => { + expect(settleCurrency('EUR', [])).toBe('EUR'); + }); +}); diff --git a/test/shop/helpers.ts b/test/shop/helpers.ts index 49e18a7..0fd2cae 100644 --- a/test/shop/helpers.ts +++ b/test/shop/helpers.ts @@ -61,7 +61,8 @@ async function bootstrap(): Promise { headers: { ...json, cookie, 'x-mallok-csrf': csrf }, body: JSON.stringify({ name: 'shop-test', - scopes: ['content:write', 'settings:write', 'media:write'], + // `export` reads a plugin panel's rows and records, as the admin does. + scopes: ['content:write', 'settings:write', 'media:write', 'export'], }), }); token = ((await minted.json()) as { token: string }).token; @@ -230,22 +231,32 @@ export async function createVariant(input: { readonly stock?: number; readonly stockPolicy?: 'track' | 'made_to_order'; readonly status?: 'active' | 'archived'; + /** Option name to value, as the admin stores it: `{ length: '10 mm' }`. */ + readonly optionValues?: Readonly>; + /** Business days, both ends or neither. */ + readonly leadTime?: readonly [number, number]; + readonly sortOrder?: number; }): Promise { await db() .prepare( `INSERT INTO p_shop_variant - (id, product_group, sku, moq, stock, stock_policy, status, + (id, product_group, sku, option_values, moq, lead_time_min, + lead_time_max, stock, stock_policy, status, sort_order, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( input.id, input.productGroup, input.sku ?? `SKU-${input.id}`, + JSON.stringify(input.optionValues ?? {}), input.moq ?? 1, + input.leadTime?.[0] ?? null, + input.leadTime?.[1] ?? null, input.stock ?? 100, input.stockPolicy ?? 'track', input.status ?? 'active', + input.sortOrder ?? 0, NOW, NOW, ) diff --git a/test/shop/scheduled.test.ts b/test/shop/scheduled.test.ts index 3505fc0..c9b6165 100644 --- a/test/shop/scheduled.test.ts +++ b/test/shop/scheduled.test.ts @@ -3,6 +3,7 @@ import { readRates } from '../../src/plugins/shop/lib/rates.js'; import { runScheduledTick } from '../../src/plugins/shop/lib/scheduled.js'; import { clearShopTables, + createVariant, db, ensureSite, priceOf, @@ -39,9 +40,17 @@ function minutesAfter(minutes: number): () => Date { return () => new Date(T0.getTime() + minutes * 60_000); } -function tick(fetchImpl: typeof fetch, now: () => Date) { +function tick( + fetchImpl: typeof fetch, + now: () => Date, + purgeTags?: (tags: readonly string[]) => Promise, +) { return runScheduledTick( - { db: db(), settings: {} }, + { + db: db(), + settings: {}, + ...(purgeTags === undefined ? {} : { purgeTags }), + }, { fetch: fetchImpl, now }, ); } @@ -97,6 +106,175 @@ describe('the scheduled tick', () => { expect(ecb.calls()).toBe(1); }); + describe('the pages a repricing run leaves out of date', () => { + /** Two products with a variant each, and one variant priced by hand. */ + async function catalogue(): Promise { + for (const [id, group] of [ + ['a1', 'group-a'], + ['a2', 'group-a'], + ['b1', 'group-b'], + ['c1', 'group-c'], + ] as const) { + await createVariant({ id, productGroup: group }); + await setPrice({ + variantId: id, + currency: 'USD', + amountMinor: 9900, + source: 'base', + }); + } + // Every other currency of this one is the seller's own figure. + for (const currency of ['EUR', 'GBP']) { + await setPrice({ + variantId: 'c1', + currency, + amountMinor: 8000, + source: 'manual', + }); + } + } + + it('are purged by product, in one call for the whole chunk', async () => { + await catalogue(); + const ecb = feed(ecbXml('2026-09-30', 1.1622, 0.85898)); + const purges: string[][] = []; + const purge = async (tags: readonly string[]) => { + purges.push([...tags]); + }; + + await tick(ecb.impl, minutesAfter(0), purge); + // Storing rates moves no price: nothing to purge yet. + expect(purges).toEqual([]); + + const repriced = await tick(ecb.impl, minutesAfter(1), purge); + + expect(repriced).toMatchObject({ kind: 'repriced', purged: 2 }); + // Each product once, however many of its variants moved; and not the + // product whose prices were entered by hand and did not move. + expect(purges.map((tags) => [...tags].sort())).toEqual([ + ['group-a', 'group-b'], + ]); + }); + + it('are left alone when no price moved', async () => { + await catalogue(); + const purges: string[][] = []; + const purge = async (tags: readonly string[]) => { + purges.push([...tags]); + }; + await tick( + feed(ecbXml('2026-09-30', 1.1622, 0.85898)).impl, + minutesAfter(0), + purge, + ); + await tick(feed().impl, minutesAfter(1), purge); + purges.length = 0; + + // A day later the rate has barely moved: inside the threshold. + await tick( + feed(ecbXml('2026-10-01', 1.163, 0.859)).impl, + minutesAfter(24 * 60), + purge, + ); + const second = await tick(feed().impl, minutesAfter(24 * 60 + 1), purge); + + expect(second).toMatchObject({ kind: 'repriced', updated: 0, purged: 0 }); + expect(purges).toEqual([]); + }); + + it('do not stop the run when the purge fails', async () => { + await catalogue(); + const ecb = feed(ecbXml('2026-09-30', 1.1622, 0.85898)); + const failing = async () => { + throw new Error('The purge service is down.'); + }; + await tick(ecb.impl, minutesAfter(0), failing); + + const repriced = await tick(ecb.impl, minutesAfter(1), failing); + + // The prices are written and the run is over; the pages expire. + expect(repriced).toMatchObject({ + kind: 'repriced', + updated: 6, + purged: 0, + done: true, + }); + expect(await priceOf('a1', 'EUR')).toMatchObject({ source: 'auto' }); + // The run is over: the next tick goes on to something else. + expect((await tick(feed().impl, minutesAfter(2), failing)).kind).not.toBe( + 'repriced', + ); + }); + + it.each([ + ['was not attempted', { attempted: false, ok: false }], + ['was turned down', { attempted: true, ok: false, detail: '429' }], + ])('are not counted as purged when the purge %s', async (_how, answer) => { + // Mallok's purge resolves either way. A tick that only caught a + // rejection would report pages purged that were not. + await catalogue(); + const ecb = feed(ecbXml('2026-09-30', 1.1622, 0.85898)); + const purge = async () => answer; + await tick(ecb.impl, minutesAfter(0), purge); + + expect(await tick(ecb.impl, minutesAfter(1), purge)).toMatchObject({ + kind: 'repriced', + updated: 6, + purged: 0, + done: true, + }); + }); + + it('are counted when the purge says it was done', async () => { + await catalogue(); + const ecb = feed(ecbXml('2026-09-30', 1.1622, 0.85898)); + const purge = async () => ({ attempted: true, ok: true }); + await tick(ecb.impl, minutesAfter(0), purge); + + expect(await tick(ecb.impl, minutesAfter(1), purge)).toMatchObject({ + kind: 'repriced', + purged: 2, + }); + }); + + it('are not asked for where there is nothing to purge with', async () => { + await catalogue(); + const ecb = feed(ecbXml('2026-09-30', 1.1622, 0.85898)); + await tick(ecb.impl, minutesAfter(0)); + + expect(await tick(ecb.impl, minutesAfter(1))).toMatchObject({ + kind: 'repriced', + updated: 6, + purged: 0, + }); + }); + + it('leave out a price whose variant no longer exists', async () => { + // A price row left behind by a variant that is gone names no product. + await setPrice({ + variantId: 'orphan', + currency: 'USD', + amountMinor: 9900, + source: 'base', + }); + const ecb = feed(ecbXml('2026-09-30', 1.1622, 0.85898)); + const purges: string[][] = []; + const purge = async (tags: readonly string[]) => { + purges.push([...tags]); + }; + await tick(ecb.impl, minutesAfter(0), purge); + + const repriced = await tick(ecb.impl, minutesAfter(1), purge); + + expect(repriced).toMatchObject({ + kind: 'repriced', + updated: 2, + purged: 0, + }); + expect(purges).toEqual([]); + }); + }); + it('does not refetch every minute', async () => { const ecb = feed(ecbXml('2026-09-30', 1.1622, 0.85898)); diff --git a/test/shop/storefront.test.ts b/test/shop/storefront.test.ts new file mode 100644 index 0000000..4744fda --- /dev/null +++ b/test/shop/storefront.test.ts @@ -0,0 +1,550 @@ +import { describe, expect, it } from 'vitest'; +import type { Currency } from '../../src/plugins/shop/lib/currency.js'; +import { + currenciesFor, + listView, + offersFor, + productView, + type StorefrontPriceRow, + type StorefrontVariantRow, +} from '../../src/plugins/shop/lib/storefront.js'; + +/** + * What a page shows of the shop, from rows alone. + * + * No database and no Worker: these are the rules a buyer reads the result + * of — which currency a page is in, what "from" means, what an out-of-stock + * size still says — and each is easier to get wrong than to notice. + */ + +function variant( + id: string, + over: Partial = {}, +): StorefrontVariantRow { + return { + id, + product_group: 'group-a', + sku: `SKU-${id}`, + option_values: '{}', + moq: 1, + lead_time_min: null, + lead_time_max: null, + stock: 100, + stock_policy: 'track', + ...over, + }; +} + +function price( + variantId: string, + currency: string, + amountMinor: number, +): StorefrontPriceRow { + return { variant_id: variantId, currency, amount_minor: amountMinor }; +} + +const amounts = ( + entries: [Currency, number][], +): ReadonlyMap => new Map(entries); + +describe('the currency a page is in', () => { + it('is the language’s own when every priced variant has it', () => { + const priced = [ + amounts([ + ['USD', 100], + ['EUR', 95], + ]), + amounts([ + ['USD', 200], + ['EUR', 190], + ]), + ]; + + expect(currenciesFor('en', priced).currency).toBe('USD'); + expect(currenciesFor('de', priced).currency).toBe('EUR'); + expect(currenciesFor('fr-FR', priced).currency).toBe('EUR'); + }); + + it('falls back to the base currency when one variant lacks the language’s own', () => { + // Before the first exchange rates arrive, or for a variant added since, + // there is a dollar price and nothing else. A page with euros in one row + // and dollars in the next cannot be compared. + const priced = [ + amounts([ + ['USD', 100], + ['EUR', 95], + ]), + amounts([['USD', 200]]), + ]; + + expect(currenciesFor('de', priced)).toEqual({ + currency: 'USD', + currencies: ['USD'], + }); + }); + + it('offers only the currencies every priced variant shares, in the shop’s order', () => { + const priced = [ + amounts([ + ['GBP', 80], + ['EUR', 95], + ['USD', 100], + ]), + amounts([ + ['USD', 200], + ['GBP', 160], + ]), + ]; + + expect(currenciesFor('en', priced).currencies).toEqual(['USD', 'GBP']); + }); + + it('is the language’s own when nothing on the page is priced, and no currency is offered', () => { + // Nothing to switch between: a switch that changed nothing would be a + // control that does nothing. + expect(currenciesFor('es', [])).toEqual({ + currency: 'EUR', + currencies: [], + }); + }); +}); + +describe('a product page’s view', () => { + it('is nothing when the product has no variants', () => { + expect(productView([], [], 'en')).toBeUndefined(); + }); + + it('prints each variant’s price in the page’s currency and in every other it offers', () => { + const view = productView( + [variant('a'), variant('b')], + [ + price('a', 'USD', 42), + price('a', 'EUR', 39), + price('b', 'USD', 1250), + price('b', 'EUR', 1199), + ], + 'en', + ); + + expect(view?.currency).toBe('USD'); + expect(view?.currencies).toEqual(['USD', 'EUR']); + expect(view?.variants.map((entry) => entry.price)).toEqual([ + '$0.42', + '$12.50', + ]); + expect(view?.variants[0]?.prices).toEqual([ + { currency: 'USD', display: '$0.42' }, + { currency: 'EUR', display: '€0.39' }, + ]); + }); + + it('formats an amount the way the page’s language writes one', () => { + const rows = [variant('a')]; + const prices = [price('a', 'USD', 123456), price('a', 'EUR', 119999)]; + + // Compared without regard to the kind of space: the German and French + // formats use a no-break one before the sign. + const shown = (locale: string) => + productView(rows, prices, locale)?.variants[0]?.price.replace(/\s/g, ' '); + + expect(shown('en')).toBe('$1,234.56'); + expect(shown('de')).toBe('1.199,99 €'); + expect(shown('fr')).toBe('1 199,99 €'); + expect(shown('es')).toBe('1199,99 €'); + }); + + it('leaves a variant without a price unpriced, and the others as they are', () => { + const view = productView( + [variant('a'), variant('b')], + [price('a', 'USD', 42)], + 'en', + ); + + expect(view?.variants.map((entry) => entry.price)).toEqual(['$0.42', '']); + expect(view?.variants[1]?.prices).toEqual([]); + expect(view?.price_from).toBe('$0.42'); + }); + + it('never passes a currency the shop does not price in, or an amount that is not whole', () => { + const view = productView( + [variant('a'), variant('b'), variant('c')], + [price('a', 'USD', 42), price('b', 'JPY', 4200), price('c', 'USD', 99.5)], + 'en', + ); + + // A row the shop cannot read is no price at all for its variant. It is + // not a price in no currency, which would leave the page with no + // currency every variant shares and take the first variant's with it. + expect(view?.variants.map((entry) => entry.price)).toEqual([ + '$0.42', + '', + '', + ]); + expect(view?.currencies).toEqual(['USD']); + expect(view?.price_from).toBe('$0.42'); + }); + + it('marks as orderable only a variant with a price that can be had, in a quantity a cart can hold', () => { + const view = productView( + [ + variant('ok', { moq: 100 }), + variant('unpriced'), + variant('gone', { stock: 0 }), + variant('custom', { stock: 0, stock_policy: 'made_to_order' }), + // A cart line holds ten thousand at most: this minimum order could + // never be met, and a form for it would be refused every time. + variant('vast', { moq: 20000, stock_policy: 'made_to_order' }), + variant('limit', { moq: 10000, stock_policy: 'made_to_order' }), + ], + [ + price('ok', 'USD', 100), + price('gone', 'USD', 100), + price('custom', 'USD', 100), + price('vast', 'USD', 100), + price('limit', 'USD', 100), + ], + 'en', + ); + + expect( + Object.fromEntries( + (view?.variants ?? []).map((entry) => [entry.id, entry.orderable]), + ), + ).toEqual({ + ok: true, + unpriced: false, + gone: false, + custom: true, + vast: false, + limit: true, + }); + }); + + it('says what state each variant is in, and never how many are left', () => { + const view = productView( + [ + variant('a', { stock: 500, moq: 100 }), + variant('b', { stock: 50, moq: 100 }), + variant('c', { stock: 0, stock_policy: 'made_to_order' }), + ], + [], + 'en', + ); + + expect(view?.variants.map((entry) => entry.availability)).toEqual([ + 'in_stock', + 'out_of_stock', + 'made_to_order', + ]); + expect(JSON.stringify(view)).not.toMatch(/"stock"/); + expect(JSON.stringify(view)).not.toContain('500'); + }); + + it('states a lead time only when both ends are known and in order', () => { + const shown = (min: number | null, max: number | null) => + productView( + [variant('a', { lead_time_min: min, lead_time_max: max })], + [], + 'en', + )?.variants[0]?.lead_time; + + expect(shown(15, 20)).toBe('15–20'); + expect(shown(5, 5)).toBe('5'); + expect(shown(null, 20)).toBe(''); + expect(shown(15, null)).toBe(''); + expect(shown(20, 15)).toBe(''); + expect(shown(-1, 5)).toBe(''); + }); + + it('names a variant by its option values, in the order they were entered', () => { + const labelled = (optionValues: string) => + productView([variant('a', { option_values: optionValues })], [], 'en') + ?.variants[0]?.label; + + expect(labelled('{"length":"10 mm"}')).toBe('10 mm'); + expect(labelled('{"thread":"M5","length":"10 mm"}')).toBe('M5 / 10 mm'); + expect(labelled('{"length":16}')).toBe('16'); + expect(labelled('{"length":"","finish":"Anodised"}')).toBe('Anodised'); + expect(labelled('{}')).toBe(''); + expect(labelled('not json')).toBe(''); + expect(labelled('["10 mm"]')).toBe(''); + }); + + describe('the price a product starts from', () => { + it('is the lowest among the variants that can be ordered', () => { + const view = productView( + [ + variant('a', { stock: 0 }), + variant('b'), + variant('c', { stock: 0, stock_policy: 'made_to_order' }), + ], + [price('a', 'USD', 10), price('b', 'USD', 55), price('c', 'USD', 42)], + 'en', + ); + + // The cheapest variant is out of stock: nobody can buy at that price. + expect(view?.price_from).toBe('$0.42'); + expect(view?.prices_from).toEqual([ + { currency: 'USD', display: '$0.42' }, + ]); + }); + + it('is the lowest in each currency, which need not be the same variant', () => { + // A price entered by hand makes one size the cheapest in dollars and + // the other the cheapest in euros. + const view = productView( + [variant('a'), variant('b')], + [ + price('a', 'USD', 1000), + price('a', 'EUR', 1200), + price('b', 'USD', 1100), + price('b', 'EUR', 1020), + ], + 'en', + ); + + expect(view?.price_from).toBe('$10.00'); + expect(view?.prices_from).toEqual([ + { currency: 'USD', display: '$10.00' }, + { currency: 'EUR', display: '€10.20' }, + ]); + }); + + it('is the lowest of all when none can be ordered', () => { + const view = productView( + [variant('a', { stock: 0 }), variant('b', { stock: 0 })], + [price('a', 'USD', 55), price('b', 'USD', 42)], + 'en', + ); + + expect(view?.price_from).toBe('$0.42'); + }); + }); +}); + +describe('a list page’s view', () => { + const items = [ + { id: 'content-a', translationGroup: 'group-a' }, + { id: 'content-b', translationGroup: 'group-b' }, + { id: 'content-c', translationGroup: 'group-c' }, + ]; + + it('is nothing when no product on the page has a variant', () => { + expect(listView(items, [], [], 'en')).toBeUndefined(); + }); + + it('gives each product that has variants its starting price and its state, by content id', () => { + const view = listView( + items, + [ + variant('a1', { product_group: 'group-a' }), + variant('a2', { product_group: 'group-a' }), + variant('b1', { + product_group: 'group-b', + stock: 0, + stock_policy: 'made_to_order', + }), + ], + [ + price('a1', 'USD', 55), + price('a2', 'USD', 42), + price('b1', 'USD', 1890), + ], + 'en', + ); + + expect(view?.products).toEqual({ + 'content-a': { + price_from: '$0.42', + prices_from: [{ currency: 'USD', display: '$0.42' }], + availability: 'in_stock', + }, + 'content-b': { + price_from: '$18.90', + prices_from: [{ currency: 'USD', display: '$18.90' }], + availability: 'made_to_order', + }, + }); + }); + + it('gives each product its lowest price in each currency', () => { + const view = listView( + [{ id: 'content-a', translationGroup: 'group-a' }], + [ + variant('a1', { product_group: 'group-a' }), + variant('a2', { product_group: 'group-a' }), + ], + [ + price('a1', 'USD', 1000), + price('a1', 'EUR', 1200), + price('a2', 'USD', 1100), + price('a2', 'EUR', 1020), + ], + 'en', + ); + + expect(view?.products['content-a']?.prices_from).toEqual([ + { currency: 'USD', display: '$10.00' }, + { currency: 'EUR', display: '€10.20' }, + ]); + }); + + it('is in one currency for the whole page', () => { + // One product has euros and the other does not yet. + const view = listView( + items, + [ + variant('a1', { product_group: 'group-a' }), + variant('b1', { product_group: 'group-b' }), + ], + [price('a1', 'USD', 42), price('a1', 'EUR', 39), price('b1', 'USD', 55)], + 'de', + ); + + expect(view?.currency).toBe('USD'); + expect( + Object.values(view?.products ?? {}).map((entry) => + entry.price_from.replace(/\s/g, ' '), + ), + ).toEqual(['0,42 $', '0,55 $']); + }); + + it('sums a product’s variants into the best state any of them offers', () => { + const state = (variants: StorefrontVariantRow[]) => + listView([{ id: 'c', translationGroup: 'group-a' }], variants, [], 'en') + ?.products.c?.availability; + + expect(state([variant('a', { stock: 0 }), variant('b')])).toBe('in_stock'); + expect( + state([ + variant('a', { stock: 0 }), + variant('b', { stock: 0, stock_policy: 'made_to_order' }), + ]), + ).toBe('made_to_order'); + expect(state([variant('a', { stock: 0 })])).toBe('out_of_stock'); + }); +}); + +describe('the offers in a product’s structured data', () => { + it('are absent when the page shows no price', () => { + expect(offersFor([variant('a')], [], 'en')).toBeUndefined(); + expect(offersFor([], [], 'en')).toBeUndefined(); + }); + + it('are one Offer for one priced variant, in major units', () => { + expect(offersFor([variant('a')], [price('a', 'USD', 9900)], 'en')).toEqual({ + '@type': 'Offer', + sku: 'SKU-a', + price: '99.00', + priceCurrency: 'USD', + availability: 'https://schema.org/InStock', + }); + }); + + it('are an AggregateOffer over several, with the range the page shows', () => { + const offers = offersFor( + [variant('a'), variant('b'), variant('c')], + [price('a', 'USD', 55), price('b', 'USD', 42), price('c', 'USD', 1250)], + 'en', + ); + + expect(offers).toMatchObject({ + '@type': 'AggregateOffer', + priceCurrency: 'USD', + lowPrice: '0.42', + highPrice: '12.50', + offerCount: 3, + }); + const held = (offers?.offers ?? []) as { sku: string; price: string }[]; + expect(held.map((offer) => [offer.sku, offer.price])).toEqual([ + ['SKU-a', '0.55'], + ['SKU-b', '0.42'], + ['SKU-c', '12.50'], + ]); + }); + + it('are absent when the page has no currency every priced variant shares', () => { + // One variant priced only in dollars, one only in euros: the page + // prints no price at all, and so offers none. + const rows = [variant('a'), variant('b')]; + const prices = [price('a', 'USD', 1000), price('b', 'EUR', 900)]; + + for (const locale of ['en', 'de']) { + expect( + productView(rows, prices, locale)?.variants.map((entry) => entry.price), + ).toEqual(['', '']); + expect(offersFor(rows, prices, locale)).toBeUndefined(); + } + }); + + it('are in the currency the page is in, and leave out a variant the page shows no price for', () => { + const offers = offersFor( + [variant('a'), variant('b')], + [price('a', 'USD', 42), price('a', 'EUR', 39), price('b', 'USD', 55)], + 'de', + ); + + // German page, but one variant has no euro price: the page is in dollars. + expect(offers).toMatchObject({ + '@type': 'AggregateOffer', + priceCurrency: 'USD', + offerCount: 2, + }); + + const single = offersFor( + [variant('a'), variant('b')], + [price('a', 'USD', 42), price('a', 'EUR', 39)], + 'de', + ); + expect(single).toMatchObject({ + '@type': 'Offer', + sku: 'SKU-a', + price: '0.39', + priceCurrency: 'EUR', + }); + }); + + it.each([ + [{ stock: 10, moq: 1 }, 'https://schema.org/InStock'], + [{ stock: 5, moq: 10 }, 'https://schema.org/OutOfStock'], + [ + { stock: 0, stock_policy: 'made_to_order' }, + 'https://schema.org/BackOrder', + ], + ])('say %o is %s', (over, availability) => { + expect( + offersFor([variant('a', over)], [price('a', 'USD', 100)], 'en'), + ).toMatchObject({ availability }); + }); + + it('state a minimum order above one, and a lead time the page states', () => { + const offer = offersFor( + [variant('a', { moq: 100, lead_time_min: 15, lead_time_max: 20 })], + [price('a', 'USD', 100)], + 'en', + ); + + expect(offer).toMatchObject({ + eligibleQuantity: { '@type': 'QuantitativeValue', minValue: 100 }, + deliveryLeadTime: { + '@type': 'QuantitativeValue', + minValue: 15, + maxValue: 20, + // Business days, as the page says: not the standard code for a day, + // which would state something the page does not. + unitText: 'business days', + }, + }); + expect( + (offer as { deliveryLeadTime: object }).deliveryLeadTime, + ).not.toHaveProperty('unitCode'); + + const plain = offersFor( + [variant('a', { lead_time_min: 15, lead_time_max: null })], + [price('a', 'USD', 100)], + 'en', + ); + expect(plain).not.toHaveProperty('eligibleQuantity'); + expect(plain).not.toHaveProperty('deliveryLeadTime'); + }); +}); diff --git a/test/shop/variant-records.test.ts b/test/shop/variant-records.test.ts new file mode 100644 index 0000000..035f64b --- /dev/null +++ b/test/shop/variant-records.test.ts @@ -0,0 +1,1201 @@ +import type { PluginContext, PluginRecordInput } from 'mallok/worker'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + loadVariant, + onProductDeleted, + removeVariant, + saveVariant, +} from '../../src/plugins/shop/lib/variant-records.js'; +import { + api, + clearShopTables, + createContent, + createProduct, + db, + ensureSite, + interceptBatches, + priceOf, + setRate, + stockOf, + type TestProduct, +} from './helpers.js'; + +/** + * Variants, edited the way the admin edits them. + * + * The admin's form talks to Mallok, and Mallok to the shop's three handlers. + * The first half of this file goes the whole way, through Mallok's own API, + * to hold the wiring: the manifest's fields, the panel attached to a product, + * the shapes that cross. The second half calls the handlers directly, for + * what cannot be seen from outside — the purge a save asks for, and a write + * that loses a race. + */ + +const PANEL = '/_mallok/api/plugins/shop/panels/variants'; + +interface Saved { + readonly status: number; + readonly id?: string; + readonly errors?: Readonly>; + readonly error?: string; +} + +async function saved(response: Response): Promise { + const body = (await response.json()) as Omit; + return { status: response.status, ...body }; +} + +function create( + product: TestProduct, + values: Record, +): Promise { + return api('POST', `${PANEL}/records`, { + attachedTo: product.translationGroup, + values, + }).then(saved); +} + +function update( + product: TestProduct, + id: string, + values: Record, +): Promise { + return api('PUT', `${PANEL}/records/${id}`, { + attachedTo: product.translationGroup, + values, + }).then(saved); +} + +async function load(id: string): Promise | null> { + const response = await api('GET', `${PANEL}/records/${id}`); + if (response.status === 404) { + return null; + } + return ((await response.json()) as { values: Record }) + .values; +} + +interface Price { + currency: string; + amount_minor: number; + source: string; + rate_used: number | null; +} + +async function prices(variantId: string): Promise { + const { results } = await db() + .prepare( + `SELECT currency, amount_minor, source, rate_used FROM p_shop_price + WHERE variant_id = ? ORDER BY currency`, + ) + .bind(variantId) + .all(); + return results; +} + +async function ledger( + variantId: string, +): Promise<{ delta: number; reason: string }[]> { + const { results } = await db() + .prepare( + `SELECT delta, reason FROM p_shop_stock_adjustment + WHERE variant_id = ? ORDER BY created_at, rowid`, + ) + .bind(variantId) + .all<{ delta: number; reason: string }>(); + return results; +} + +async function variantRow(id: string): Promise | null> { + return db() + .prepare('SELECT * FROM p_shop_variant WHERE id = ?') + .bind(id) + .first>(); +} + +/** Puts a variant on an order, as a placed order's line does. */ +async function order(variantId: string): Promise { + await db() + .prepare( + `INSERT INTO p_shop_order_line + (id, order_id, variant_id, sku_snapshot, name_snapshot, + unit_price_minor, quantity) + VALUES (?, 'order-1', ?, 'SKU', 'Name', 100, 1)`, + ) + .bind(`order-1:${variantId}`, variantId) + .run(); +} + +/** + * A context for calling a handler directly, with the purges it asks for and + * the work it leaves running behind the answer. + */ +function context(over: Partial = {}): { + ctx: PluginContext; + purged: string[][]; + background: Promise[]; +} { + const purged: string[][] = []; + const background: Promise[] = []; + return { + purged, + background, + ctx: { + db: db(), + settings: {}, + purgeTags: async (tags: readonly string[]) => { + purged.push([...tags]); + }, + waitUntil: (work: Promise) => { + background.push(work); + }, + ...over, + } as unknown as PluginContext, + }; +} + +function record( + product: TestProduct, + id: string | null, + values: Record, +): PluginRecordInput { + return { + id, + values: { + sku: 'D-1', + options: {}, + status: 'active', + moq: 1, + stock_policy: 'track', + stock: null, + lead_time_min: null, + lead_time_max: null, + weight_grams: null, + sort_order: 0, + price: null, + other_prices: [], + ...values, + }, + attachedTo: { translationGroup: product.translationGroup, kind: 'product' }, + }; +} + +describe('variants in the admin', () => { + let screw: TestProduct; + let bolt: TestProduct; + + beforeAll(async () => { + await ensureSite(); + screw = await createProduct({ title: 'Cap screw', slug: 'records-screw' }); + bolt = await createProduct({ title: 'Flange bolt', slug: 'records-bolt' }); + }); + + beforeEach(clearShopTables); + + describe('through Mallok’s API, as the form saves', () => { + it('creates a variant of the product whose editor it was added in', async () => { + const created = await create(screw, { + sku: ' CS-10 ', + options: { length: '10 mm' }, + moq: 100, + stock: 500, + lead_time_min: 5, + lead_time_max: 10, + weight_grams: 4, + sort_order: 2, + price: { amount: 185, currency: 'USD' }, + }); + + expect(created.status).toBe(201); + expect(await variantRow(created.id ?? '')).toMatchObject({ + product_group: screw.translationGroup, + sku: 'CS-10', + option_values: '{"length":"10 mm"}', + moq: 100, + stock: 500, + stock_policy: 'track', + status: 'active', + lead_time_min: 5, + lead_time_max: 10, + weight_grams: 4, + sort_order: 2, + }); + expect(await prices(created.id ?? '')).toEqual([ + { currency: 'USD', amount_minor: 185, source: 'base', rate_used: null }, + ]); + }); + + it('lists a product’s variants under that product and no other', async () => { + await create(screw, { sku: 'CS-10', moq: 1 }); + await create(screw, { sku: 'CS-16', moq: 1 }); + await create(bolt, { sku: 'FB-30', moq: 1 }); + + const list = async (product: TestProduct): Promise => { + const response = await api( + 'GET', + `${PANEL}?attached=${product.translationGroup}&sort=sku`, + ); + const { rows } = (await response.json()) as { rows: { sku: string }[] }; + return rows.map((row) => row.sku); + }; + + expect(await list(screw)).toEqual(['CS-10', 'CS-16']); + expect(await list(bolt)).toEqual(['FB-30']); + }); + + it('hands the form back what was saved, and saving that again changes nothing', async () => { + await setRate('EUR', 0.92); + const values = { + sku: 'CS-10', + options: { length: '10 mm', finish: 'Anodised' }, + status: 'active', + moq: 100, + stock_policy: 'track', + stock: 500, + lead_time_min: 5, + lead_time_max: 10, + weight_grams: null, + sort_order: 0, + price: { amount: 185, currency: 'USD' }, + other_prices: [{ price: { amount: 150, currency: 'GBP' } }], + }; + const { id = '' } = await create(screw, values); + + const loaded = await load(id); + // Everything but the stock: the form's field is for setting a figure, + // and comes empty. + expect(loaded).toEqual({ ...values, stock: null }); + + // The rate moves, sixty are sold, and the form is saved untouched for + // another reason. + await setRate('EUR', 0.99); + await db() + .prepare('UPDATE p_shop_variant SET stock = 440 WHERE id = ?') + .bind(id) + .run(); + const before = { prices: await prices(id), ledger: await ledger(id) }; + const again = await update(screw, id, loaded ?? {}); + + expect(again.status).toBe(200); + // A derived price is not recomputed because someone pressed Save: only + // a new base price, or a rate that has drifted, moves it. + expect(await prices(id)).toEqual(before.prices); + // And the sixty stay sold: the save wrote no stock and no ledger row. + expect(await stockOf(id)).toBe(440); + expect(await ledger(id)).toEqual(before.ledger); + expect(await load(id)).toEqual({ ...values, stock: null }); + }); + + it('refuses what Mallok can see is wrong before the shop is asked', async () => { + const refused = await create(screw, { + sku: 'CS-10', + moq: 0, + stock: -1, + price: { amount: 1.5, currency: 'USD' }, + status: 'sold', + }); + + expect(refused.status).toBe(422); + expect(Object.keys(refused.errors ?? {}).sort()).toEqual([ + 'moq', + 'price', + 'status', + 'stock', + ]); + expect(await variantRow('CS-10')).toBeNull(); + }); + + it('refuses a variant that belongs to no product', async () => { + const orphan = await api('POST', `${PANEL}/records`, { + values: { sku: 'CS-10', moq: 1 }, + }); + const stranger = await api('POST', `${PANEL}/records`, { + attachedTo: 'no-such-group', + values: { sku: 'CS-10', moq: 1 }, + }); + + expect(orphan.status).toBe(400); + expect(stranger.status).toBeGreaterThanOrEqual(400); + const count = await db() + .prepare('SELECT COUNT(*) AS n FROM p_shop_variant') + .first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + + it('refuses a SKU another variant has, on any product', async () => { + await create(screw, { sku: 'CS-10', moq: 1 }); + + const second = await create(bolt, { sku: 'CS-10', moq: 1 }); + + expect(second.status).toBe(422); + expect(second.errors).toEqual({ + sku: 'Another variant already has this SKU.', + }); + }); + + it('lets a variant keep its own SKU when it is saved again', async () => { + const { id = '' } = await create(screw, { sku: 'CS-10', moq: 1 }); + + expect((await update(screw, id, { sku: 'CS-10', moq: 5 })).status).toBe( + 200, + ); + expect(await variantRow(id)).toMatchObject({ sku: 'CS-10', moq: 5 }); + }); + + it.each([ + [{ lead_time_min: 5 }, 'lead_time_max'], + [{ lead_time_max: 10 }, 'lead_time_min'], + [{ lead_time_min: 10, lead_time_max: 5 }, 'lead_time_max'], + [{ lead_time_min: 2.5, lead_time_max: 5 }, 'lead_time_min'], + [{ moq: 2.5 }, 'moq'], + [{ stock: 10.5 }, 'stock'], + [{ weight_grams: 0.5 }, 'weight_grams'], + [{ sort_order: 1.5 }, 'sort_order'], + ])('refuses %o, naming %s', async (values, field) => { + const refused = await create(screw, { sku: 'CS-10', moq: 1, ...values }); + + expect(refused.status).toBe(422); + expect(Object.keys(refused.errors ?? {})).toEqual([field]); + const count = await db() + .prepare('SELECT COUNT(*) AS n FROM p_shop_variant') + .first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + + it('refuses a minimum order no cart line could hold', async () => { + const { ctx } = context(); + + const atLimit = await saveVariant( + record(screw, null, { sku: 'CS-A', moq: 10000 }), + ctx, + ); + const over = await saveVariant( + record(screw, null, { sku: 'CS-B', moq: 10001 }), + ctx, + ); + + expect(atLimit).toHaveProperty('id'); + expect(over).toEqual({ + errors: { moq: 'The minimum order can be at most 10000.' }, + }); + }); + + it('refuses the same currency priced by hand twice, naming the row', async () => { + const refused = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 185, currency: 'USD' }, + other_prices: [ + { price: { amount: 175, currency: 'EUR' } }, + { price: { amount: 170, currency: 'EUR' } }, + ], + }); + + expect(refused.status).toBe(422); + expect(Object.keys(refused.errors ?? {})).toEqual([ + 'other_prices.1.price', + ]); + }); + + it('is not found once it has been deleted', async () => { + const { id = '' } = await create(screw, { sku: 'CS-10', moq: 1 }); + + const removed = await api('DELETE', `${PANEL}/records/${id}`); + + expect(removed.status).toBe(200); + expect(await load(id)).toBeNull(); + expect((await update(screw, id, { sku: 'CS-10', moq: 1 })).status).toBe( + 422, + ); + expect(await variantRow(id)).toBeNull(); + }); + }); + + describe('prices', () => { + it('derives the other currencies from the base price, at the stored rates', async () => { + await setRate('EUR', 0.92); + await setRate('GBP', 0.79); + + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + }); + + // 9900 * 0.92 = 9108; * 1.03 = 9381; up to the next .99 = 9399. + // 9900 * 0.79 = 7821; * 1.03 = 8056; up to the next .99 = 8099. + expect(await prices(id)).toEqual([ + { + currency: 'EUR', + amount_minor: 9399, + source: 'auto', + rate_used: 0.92, + }, + { + currency: 'GBP', + amount_minor: 8099, + source: 'auto', + rate_used: 0.79, + }, + { + currency: 'USD', + amount_minor: 9900, + source: 'base', + rate_used: null, + }, + ]); + }); + + it('derives nothing for a currency it has no rate for', async () => { + await setRate('EUR', 0.92); + + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + }); + + expect((await prices(id)).map((row) => row.currency)).toEqual([ + 'EUR', + 'USD', + ]); + }); + + it('derives them again when the base price changes', async () => { + await setRate('EUR', 0.92); + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + }); + // The rate has moved a little since: the new price is computed at the + // rate of the day it is entered. + await setRate('EUR', 0.93); + + await update(screw, id, { + sku: 'CS-10', + moq: 1, + price: { amount: 20000, currency: 'USD' }, + }); + + // 20000 * 0.93 = 18600; * 1.03 = 19158; up to the next .99 = 19199. + expect(await priceOf(id, 'EUR')).toEqual({ + amount_minor: 19199, + source: 'auto', + rate_used: 0.93, + }); + }); + + it('stores a price entered by hand as manual, in place of the derived one', async () => { + await setRate('EUR', 0.92); + await setRate('GBP', 0.79); + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + }); + + await update(screw, id, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + other_prices: [{ price: { amount: 8500, currency: 'EUR' } }], + }); + + expect(await priceOf(id, 'EUR')).toEqual({ + amount_minor: 8500, + source: 'manual', + rate_used: null, + }); + // The currency not entered by hand is still derived, and untouched. + expect(await priceOf(id, 'GBP')).toMatchObject({ + amount_minor: 8099, + source: 'auto', + }); + }); + + it('goes back to deriving a currency whose hand-entered price is taken off', async () => { + await setRate('EUR', 0.92); + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + other_prices: [{ price: { amount: 8500, currency: 'EUR' } }], + }); + + await update(screw, id, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + other_prices: [], + }); + + expect(await priceOf(id, 'EUR')).toEqual({ + amount_minor: 9399, + source: 'auto', + rate_used: 0.92, + }); + }); + + it('drops a hand-entered price taken off the form when there is no rate to derive one from', async () => { + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + other_prices: [{ price: { amount: 8500, currency: 'EUR' } }], + }); + + await update(screw, id, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + }); + + expect(await priceOf(id, 'EUR')).toBeNull(); + }); + + it('leaves a variant with no price at all when the base price is taken off', async () => { + await setRate('EUR', 0.92); + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 9900, currency: 'USD' }, + }); + expect(await prices(id)).toHaveLength(2); + + await update(screw, id, { sku: 'CS-10', moq: 1, price: null }); + + // Quoted on request: nothing left that was derived from a price that + // is no longer there. + expect(await prices(id)).toEqual([]); + }); + + it('refuses a price in another currency without a base price', async () => { + // A variant priced only in euros would leave its page with no currency + // every size shares, and so with no prices at all. + const refused = await create(screw, { + sku: 'CS-10', + moq: 1, + other_prices: [{ price: { amount: 8500, currency: 'EUR' } }], + }); + + expect(refused.status).toBe(422); + expect(Object.keys(refused.errors ?? {})).toEqual(['price']); + expect(await variantRow('CS-10')).toBeNull(); + }); + + it('refuses a price of nothing', async () => { + const { ctx } = context(); + + const free = await saveVariant( + record(screw, null, { price: { amount: 0, currency: 'USD' } }), + ctx, + ); + const freeAbroad = await saveVariant( + record(screw, null, { + price: { amount: 100, currency: 'USD' }, + other_prices: [{ price: { amount: 0, currency: 'EUR' } }], + }), + ctx, + ); + + expect(Object.keys((free as { errors: object }).errors)).toEqual([ + 'price', + ]); + expect(Object.keys((freeAbroad as { errors: object }).errors)).toEqual([ + 'other_prices.0.price', + ]); + }); + + it('keeps a derived price in step with the base price when two saves cross', async () => { + await setRate('EUR', 0.92); + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 10000, currency: 'USD' }, + }); + // One seller saves the form as it was opened, with only the minimum + // order changed. Between that save's reading and its write, another + // doubles the base price. + const { ctx } = context({ + db: interceptBatches({ + before: async (index) => { + if (index === 1) { + await update(screw, id, { + sku: 'CS-10', + moq: 1, + price: { amount: 20000, currency: 'USD' }, + }); + } + }, + }), + }); + + await saveVariant( + record(screw, id, { + sku: 'CS-10', + moq: 5, + price: { amount: 10000, currency: 'USD' }, + }), + ctx, + ); + + // The later write wins the base price. Whichever does, the euro price + // is the one derived from it: 10000 * 0.92 = 9200; * 1.03 = 9476; up + // to the next .99 = 9499. Not 18999, left over from the price that + // was overwritten. + expect(await priceOf(id, 'USD')).toMatchObject({ amount_minor: 10000 }); + expect(await priceOf(id, 'EUR')).toEqual({ + amount_minor: 9499, + source: 'auto', + rate_used: 0.92, + }); + }); + + it('refuses a base price in another currency, and the base currency among the hand-entered ones', async () => { + const { ctx } = context(); + + expect( + await saveVariant( + record(screw, null, { price: { amount: 100, currency: 'EUR' } }), + ctx, + ), + ).toMatchObject({ errors: { price: expect.stringContaining('in USD') } }); + expect( + await saveVariant( + record(screw, null, { + price: { amount: 100, currency: 'USD' }, + other_prices: [{ price: { amount: 90, currency: 'USD' } }], + }), + ctx, + ), + ).toEqual({ + errors: { 'other_prices.0.price': 'USD is the base price, above.' }, + }); + }); + }); + + describe('stock', () => { + it('records the stock a new variant starts with', async () => { + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + stock: 500, + }); + const none = await create(screw, { sku: 'CS-16', moq: 1, stock: 0 }); + + expect(await ledger(id)).toEqual([{ delta: 500, reason: 'manual' }]); + // Nothing moved: a row with a difference of zero says nothing. + expect(await ledger(none.id ?? '')).toEqual([]); + }); + + it('records the difference when a figure is set, and nothing when it is the same', async () => { + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + stock: 500, + }); + + await update(screw, id, { sku: 'CS-10', moq: 1, stock: 420 }); + await update(screw, id, { sku: 'CS-10', moq: 1, stock: 420 }); + await update(screw, id, { sku: 'CS-10', moq: 1, stock: 450 }); + + expect(await stockOf(id)).toBe(450); + expect(await ledger(id)).toEqual([ + { delta: 500, reason: 'manual' }, + { delta: -80, reason: 'manual' }, + { delta: 30, reason: 'manual' }, + ]); + }); + + it('leaves the stock alone when the field is empty, whatever has happened to it since', async () => { + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + stock: 500, + }); + // Sixty are sold while the seller has the form open to change a lead + // time. The form never held the 500, so it cannot put them back. + const { ctx } = context({ + db: interceptBatches({ + before: async (index) => { + if (index === 1) { + await db() + .prepare('UPDATE p_shop_variant SET stock = 440 WHERE id = ?') + .bind(id) + .run(); + } + }, + }), + }); + + await saveVariant( + record(screw, id, { + sku: 'CS-10', + lead_time_min: 5, + lead_time_max: 10, + }), + ctx, + ); + + expect(await stockOf(id)).toBe(440); + expect(await ledger(id)).toEqual([{ delta: 500, reason: 'manual' }]); + expect(await variantRow(id)).toMatchObject({ lead_time_min: 5 }); + }); + + it('starts a new variant with none when the field is empty, and can set a stock to none', async () => { + const { id = '' } = await create(screw, { sku: 'CS-10', moq: 1 }); + expect(await stockOf(id)).toBe(0); + expect(await ledger(id)).toEqual([]); + + await update(screw, id, { sku: 'CS-10', moq: 1, stock: 30 }); + // Nought is a figure, not an empty field. + await update(screw, id, { sku: 'CS-10', moq: 1, stock: 0 }); + + expect(await stockOf(id)).toBe(0); + expect(await ledger(id)).toEqual([ + { delta: 30, reason: 'manual' }, + { delta: -30, reason: 'manual' }, + ]); + }); + + it('measures the difference from the stock as it is when the save lands, not when the form was opened', async () => { + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + stock: 500, + }); + // A payment takes 60 between the seller opening the form and saving. + const { ctx } = context({ + db: interceptBatches({ + before: async (index) => { + if (index === 1) { + await db() + .prepare('UPDATE p_shop_variant SET stock = 440 WHERE id = ?') + .bind(id) + .run(); + } + }, + }), + }); + + await saveVariant(record(screw, id, { sku: 'CS-10', stock: 520 }), ctx); + + // The figure the seller set stands, and the ledger says what it took + // to get there from what was really in the table: 440, not 500. + expect(await stockOf(id)).toBe(520); + expect((await ledger(id)).at(-1)).toEqual({ + delta: 80, + reason: 'manual', + }); + }); + }); + + describe('a save, seen from inside', () => { + it('purges the pages of the product the variant belongs to, once', async () => { + const { ctx, purged } = context(); + + const created = await saveVariant( + record(screw, null, { sku: 'CS-10' }), + ctx, + ); + + expect(created).toHaveProperty('id'); + expect(purged).toEqual([[screw.translationGroup]]); + }); + + it('purges nothing when it refuses', async () => { + const { ctx, purged } = context(); + + const refused = await saveVariant( + record(screw, null, { sku: ' ' }), + ctx, + ); + + expect(refused).toEqual({ errors: { sku: 'A variant needs a SKU.' } }); + expect(purged).toEqual([]); + }); + + it('does not wait for the purge before it answers', async () => { + // Mallok gathers two seconds of purges into one call; a save that + // waited would take those two seconds every time. + let finish: () => void = () => {}; + const { ctx, background } = context({ + purgeTags: () => + new Promise((resolve) => { + finish = resolve; + }), + }); + + const created = await saveVariant( + record(screw, null, { sku: 'CS-10' }), + ctx, + ); + + // Answered while the purge is still out, and the purge handed to the + // runtime to keep alive. + expect(created).toHaveProperty('id'); + expect(background).toHaveLength(1); + finish(); + await Promise.all(background); + }); + + it.each([ + [ + 'is refused', + async () => { + throw new Error('The purge service is down.'); + }, + ], + [ + 'cannot even be asked for', + () => { + throw new Error('No purge here.'); + }, + ], + ])('is saved when the purge %s', async (_how, purgeTags) => { + const { ctx, background } = context({ + purgeTags: purgeTags as PluginContext['purgeTags'], + }); + + const created = await saveVariant( + record(screw, null, { sku: 'CS-10' }), + ctx, + ); + + expect(created).toHaveProperty('id'); + expect(await variantRow((created as { id: string }).id)).toMatchObject({ + sku: 'CS-10', + }); + // Nothing left behind that rejects: the failure was dealt with. + await expect(Promise.all(background)).resolves.toBeDefined(); + }); + + it('reads once and writes once', async () => { + await setRate('EUR', 0.92); + let batches = 0; + const { ctx } = context({ + db: interceptBatches({ + before: () => { + batches += 1; + }, + }), + }); + + await saveVariant( + record(screw, null, { + sku: 'CS-10', + stock: 5, + price: { amount: 100, currency: 'USD' }, + other_prices: [{ price: { amount: 80, currency: 'GBP' } }], + }), + ctx, + ); + + expect(batches).toBe(2); + }); + + it('says the SKU is taken when another variant took it between the reading and the write', async () => { + const { ctx, purged } = context({ + db: interceptBatches({ + before: async (index) => { + if (index === 1) { + await create(bolt, { sku: 'CS-10', moq: 1 }); + } + }, + }), + }); + + const lost = await saveVariant( + record(screw, null, { sku: 'CS-10' }), + ctx, + ); + + expect(lost).toEqual({ + errors: { sku: 'Another variant already has this SKU.' }, + }); + expect(purged).toEqual([]); + const mine = await db() + .prepare( + 'SELECT COUNT(*) AS n FROM p_shop_variant WHERE product_group = ?', + ) + .bind(screw.translationGroup) + .first<{ n: number }>(); + expect(mine?.n).toBe(0); + }); + + it('refuses to move a variant to another product', async () => { + const { ctx } = context(); + const created = (await saveVariant( + record(screw, null, { sku: 'CS-10' }), + ctx, + )) as { id: string }; + + const moved = await saveVariant( + record(bolt, created.id, { sku: 'CS-10' }), + ctx, + ); + + expect(moved).toEqual({ + errors: { sku: 'This variant belongs to another product.' }, + }); + expect(await variantRow(created.id)).toMatchObject({ + product_group: screw.translationGroup, + }); + }); + + it('loads nothing for a variant that is not there', async () => { + expect(await loadVariant('no-such-variant', context().ctx)).toBeNull(); + }); + }); + + describe('archiving a variant', () => { + it('takes it out of every cart, and leaves the other lines', async () => { + const { id = '' } = await create(screw, { sku: 'CS-10', moq: 1 }); + const other = await create(screw, { sku: 'CS-16', moq: 1 }); + await db().batch([ + db().prepare( + `INSERT INTO p_shop_cart (id, created_at, updated_at, expires_at) + VALUES ('cart-1', 'now', 'now', '2999-01-01T00:00:00.000Z')`, + ), + db() + .prepare( + "INSERT INTO p_shop_cart_line (cart_id, variant_id, quantity) VALUES ('cart-1', ?, 1), ('cart-1', ?, 1)", + ) + .bind(id, other.id ?? ''), + ]); + + await update(screw, id, { sku: 'CS-10', moq: 1, status: 'archived' }); + + const left = await db() + .prepare('SELECT variant_id FROM p_shop_cart_line') + .all<{ variant_id: string }>(); + expect(left.results.map((row) => row.variant_id)).toEqual([other.id]); + expect(await variantRow(id)).toMatchObject({ status: 'archived' }); + }); + + it('leaves carts alone when a variant is saved active', async () => { + const { id = '' } = await create(screw, { sku: 'CS-10', moq: 1 }); + await db().batch([ + db().prepare( + `INSERT INTO p_shop_cart (id, created_at, updated_at, expires_at) + VALUES ('cart-1', 'now', 'now', '2999-01-01T00:00:00.000Z')`, + ), + db() + .prepare( + "INSERT INTO p_shop_cart_line (cart_id, variant_id, quantity) VALUES ('cart-1', ?, 1)", + ) + .bind(id), + ]); + + await update(screw, id, { sku: 'CS-10', moq: 2 }); + + const left = await db() + .prepare('SELECT COUNT(*) AS n FROM p_shop_cart_line') + .first<{ n: number }>(); + expect(left?.n).toBe(1); + }); + }); + + describe('what a purge answers', () => { + // Mallok's `purgeTags` resolves, rather than rejects, when a purge does + // not happen. A handler that only caught a rejection would never know. + async function logged(answer: unknown): Promise { + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const { ctx, background } = context({ + purgeTags: async () => answer, + }); + const created = await saveVariant( + record(screw, null, { sku: `CS-${crypto.randomUUID()}` }), + ctx, + ); + expect(created).toHaveProperty('id'); + await Promise.all(background); + return log.mock.calls.map((call) => String(call[0])); + } finally { + log.mockRestore(); + } + } + + it('is noted when Cloudflare turned the purge down', async () => { + expect( + await logged({ attempted: true, ok: false, detail: '429' }), + ).toEqual(['{"event":"shop_purge_failed","reason":"refused"}']); + }); + + it('is not a failure on a site that has nothing to purge with', async () => { + // Every save on such a site would log one otherwise. + expect(await logged({ attempted: false, ok: false })).toEqual([]); + }); + + it('is taken at its word when it says the purge was done, or says nothing', async () => { + expect(await logged({ attempted: true, ok: true })).toEqual([]); + expect(await logged(undefined)).toEqual([]); + }); + }); + + describe('deleting a variant', () => { + it('takes it, its prices and its place in every cart away, and purges its product', async () => { + const { ctx, purged } = context(); + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 100, currency: 'USD' }, + }); + await db().batch([ + db().prepare( + `INSERT INTO p_shop_cart (id, created_at, updated_at, expires_at) + VALUES ('cart-1', 'now', 'now', '2999-01-01T00:00:00.000Z')`, + ), + db() + .prepare( + "INSERT INTO p_shop_cart_line (cart_id, variant_id, quantity) VALUES ('cart-1', ?, 1)", + ) + .bind(id), + ]); + + await removeVariant(id, ctx); + + expect(await variantRow(id)).toBeNull(); + expect(await prices(id)).toEqual([]); + const inCarts = await db() + .prepare( + 'SELECT COUNT(*) AS n FROM p_shop_cart_line WHERE variant_id = ?', + ) + .bind(id) + .first<{ n: number }>(); + expect(inCarts?.n).toBe(0); + expect(purged).toEqual([[screw.translationGroup]]); + }); + + it('archives one that has been ordered instead, and keeps its prices', async () => { + const { ctx } = context(); + const { id = '' } = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 100, currency: 'USD' }, + }); + await order(id); + + await removeVariant(id, ctx); + + // An order's lines and the stock ledger still name it. + expect(await variantRow(id)).toMatchObject({ + sku: 'CS-10', + status: 'archived', + }); + expect(await prices(id)).toHaveLength(1); + }); + + it('does nothing, and purges nothing, for a variant that is not there', async () => { + const { ctx, purged } = context(); + + await removeVariant('no-such-variant', ctx); + + expect(purged).toEqual([]); + }); + }); + + describe('when a product is deleted', () => { + const ref = (product: TestProduct, lastInGroup: boolean) => ({ + id: product.id, + kind: 'product', + locale: 'en', + translationGroup: product.translationGroup, + lastInGroup, + }); + + it('removes the variants of a product whose last language has gone, and archives the ones on an order', async () => { + const { ctx, purged } = context(); + const fresh = await create(screw, { + sku: 'CS-10', + moq: 1, + price: { amount: 100, currency: 'USD' }, + }); + const sold = await create(screw, { + sku: 'CS-16', + moq: 1, + price: { amount: 120, currency: 'USD' }, + }); + const other = await create(bolt, { sku: 'FB-30', moq: 1 }); + await order(sold.id ?? ''); + + await onProductDeleted(ref(screw, true), ctx); + + expect(await variantRow(fresh.id ?? '')).toBeNull(); + expect(await prices(fresh.id ?? '')).toEqual([]); + // Kept, because an order names it — and nobody can open it any more, + // so it gives its SKU back for another product's variant to take. + expect(await variantRow(sold.id ?? '')).toMatchObject({ + status: 'archived', + sku: `CS-16#${sold.id}`, + }); + expect(await prices(sold.id ?? '')).toHaveLength(1); + expect((await create(bolt, { sku: 'CS-16', moq: 1 })).status).toBe(201); + // Another product's variants are not this one's business. + expect(await variantRow(other.id ?? '')).toMatchObject({ + status: 'active', + }); + expect(purged).toEqual([[screw.translationGroup]]); + }); + + it('can be run again and changes nothing more', async () => { + const { ctx } = context(); + const sold = await create(screw, { sku: 'CS-16', moq: 1 }); + await order(sold.id ?? ''); + await onProductDeleted(ref(screw, true), ctx); + const first = await variantRow(sold.id ?? ''); + + await onProductDeleted(ref(screw, true), ctx, new Date('2030-01-01')); + + expect(await variantRow(sold.id ?? '')).toEqual(first); + }); + + it('leaves the variants alone while another language of the product is left', async () => { + const { ctx, purged } = context(); + const { id = '' } = await create(screw, { sku: 'CS-10', moq: 1 }); + + await onProductDeleted(ref(screw, false), ctx); + + expect(await variantRow(id)).toMatchObject({ status: 'active' }); + expect(purged).toEqual([]); + }); + + it('is nothing to do with the shop when what was deleted is not a product', async () => { + const { ctx, purged } = context(); + const { id = '' } = await create(screw, { sku: 'CS-10', moq: 1 }); + + await onProductDeleted({ ...ref(screw, true), kind: 'collection' }, ctx); + + expect(await variantRow(id)).toMatchObject({ status: 'active' }); + expect(purged).toEqual([]); + }); + + it('is called by Mallok when the content is deleted through its API', async () => { + const doomed = await createContent({ + kind: 'product', + title: 'Doomed part', + slug: 'records-doomed', + }); + const german = await createContent({ + kind: 'product', + title: 'Verlorenes Teil', + slug: 'records-verloren', + locale: 'de', + translationGroup: doomed.translationGroup, + }); + const { id = '' } = await create(doomed, { sku: 'DP-1', moq: 1 }); + + // One language of two: the product is still there. + const first = await api('DELETE', `/_mallok/api/content/${german.id}`); + expect(first.status).toBe(200); + expect(await variantRow(id)).not.toBeNull(); + + const last = await api('DELETE', `/_mallok/api/content/${doomed.id}`); + expect(last.status).toBe(200); + expect(await last.json()).not.toHaveProperty('hookFailed'); + expect(await variantRow(id)).toBeNull(); + }); + }); +}); diff --git a/test/theme-scripts.test.ts b/test/theme-scripts.test.ts index ba63efb..209e1df 100644 --- a/test/theme-scripts.test.ts +++ b/test/theme-scripts.test.ts @@ -573,3 +573,64 @@ describe('the engagement calculator', () => { } }); }); + +interface CurrencySwitch { + KEY: string; + choose(wanted: unknown, offered: string[], own: string): string; + recall(storage: () => { getItem(key: string): string | null }): string | null; + remember( + storage: () => { setItem(key: string, value: string): void }, + code: string, + ): void; +} + +describe('the currency switch', () => { + const offered = ['usd', 'eur']; + + it('shows the currency a visitor chose before, when this page has prices in it', async () => { + const { choose } = await load('currency.js'); + + assert.equal(choose('eur', offered, 'usd'), 'eur'); + assert.equal(choose('usd', offered, 'eur'), 'usd'); + }); + + it('shows the page’s own currency when nothing was chosen, or this page cannot show the choice', async () => { + const { choose } = await load('currency.js'); + + assert.equal(choose(null, offered, 'usd'), 'usd'); + assert.equal(choose(undefined, offered, 'eur'), 'eur'); + // Chosen on a page that offered pounds; this one does not. + assert.equal(choose('gbp', offered, 'eur'), 'eur'); + // Whatever else storage may hold under the key. + assert.equal(choose('', offered, 'usd'), 'usd'); + assert.equal(choose('EUR', offered, 'usd'), 'usd'); + assert.equal(choose(42, offered, 'usd'), 'usd'); + }); + + it('reads and keeps the choice under one key', async () => { + const { KEY, recall, remember } = await load('currency.js'); + const kept = new Map(); + const storage = () => ({ + getItem: (key: string) => kept.get(key) ?? null, + setItem: (key: string, value: string) => { + kept.set(key, value); + }, + }); + + assert.equal(recall(storage), null); + remember(storage, 'eur'); + assert.deepEqual([...kept], [[KEY, 'eur']]); + assert.equal(recall(storage), 'eur'); + }); + + it('carries on when the browser refuses storage', async () => { + // With storage blocked, even reaching for it throws. + const { recall, remember } = await load('currency.js'); + const blocked = (): never => { + throw new Error('The operation is insecure.'); + }; + + assert.equal(recall(blocked), null); + assert.doesNotThrow(() => remember(blocked, 'eur')); + }); +}); diff --git a/test/theme/cart.test.ts b/test/theme/cart.test.ts new file mode 100644 index 0000000..a1e4406 --- /dev/null +++ b/test/theme/cart.test.ts @@ -0,0 +1,1053 @@ +import { SELF } from 'cloudflare:test'; +import { beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { + countD1Calls, + createContent, + createVariant, + db, + ensureSite, + ORIGIN, + setPrice, + type TestContent, +} from '../shop/helpers.js'; + +/** + * From a product page to the cart, with no script. + * + * A size on a product page is a form; the cart is a page of the site, drawn + * by the theme from what the shop plugin says is in it. Everything here goes + * through the real Worker the way a browser would: a page is requested, its + * form is posted with the fields the page gave it, the redirect is followed + * with the cookie that came back. + */ + +const SHOP = '/_mallok/p/shop'; + +interface Page { + readonly status: number; + readonly html: string; + readonly headers: Headers; +} + +async function get(path: string, cookie?: string): Promise { + const response = await SELF.fetch(`${ORIGIN}${path}`, { + headers: cookie === undefined ? {} : { cookie }, + }); + return { + status: response.status, + html: await response.text(), + headers: response.headers, + }; +} + +function between(html: string, start: string, end: string): string { + const from = html.indexOf(start); + const to = html.indexOf(end, from + start.length); + return from === -1 ? '' : html.slice(from, to === -1 ? undefined : to); +} + +/** A form as a browser would submit it: where to, and every field's value. */ +interface Form { + readonly action: string; + readonly method: string; + readonly fields: Readonly>; + /** The attributes of its quantity field, when it has one. */ + readonly quantity: Readonly> | null; + readonly button: string; +} + +function forms(html: string): Form[] { + return [...html.matchAll(/]*)>([\s\S]*?)<\/form>/g)].map( + ([, attributes = '', inside = '']) => { + const fields: Record = {}; + for (const [, tag = ''] of inside.matchAll(/]*)>/g)) { + const name = /\bname="([^"]*)"/.exec(tag)?.[1]; + if (name !== undefined) { + fields[name] = /\bvalue="([^"]*)"/.exec(tag)?.[1] ?? ''; + } + } + const quantity = /]*\bname="quantity"[^>]*)>/.exec( + inside, + )?.[1]; + return { + action: /\baction="([^"]*)"/.exec(attributes)?.[1] ?? '', + method: /\bmethod="([^"]*)"/.exec(attributes)?.[1] ?? '', + fields, + quantity: + quantity === undefined + ? null + : Object.fromEntries( + [...quantity.matchAll(/\b([a-z]+)="([^"]*)"/g)].map( + ([, name = '', value = '']) => [name, value], + ), + ), + button: (/]*>([\s\S]*?)<\/button>/.exec(inside)?.[1] ?? '') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' '), + }; + }, + ); +} + +/** Submits a form as a browser would, and returns the answer unfollowed. */ +function submit( + form: Pick, + cookie?: string, + change: Readonly> = {}, +): Promise { + return SELF.fetch(`${ORIGIN}${form.action}`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + // What a browser says of a form on the same site. + 'sec-fetch-site': 'same-origin', + origin: ORIGIN, + ...(cookie === undefined ? {} : { cookie }), + }, + body: new URLSearchParams({ ...form.fields, ...change }).toString(), + redirect: 'manual', + }); +} + +function cartCookie(response: Response): string { + return response.headers.get('set-cookie')?.split(';')[0] ?? ''; +} + +/** One line of the cart page, as plain facts. */ +interface Line { + readonly name: string; + /** Where the name links to; '' when it is not a link. */ + readonly href: string; + readonly sku: string; + readonly problem: string; + readonly note: string; + readonly terms: readonly (readonly [string, string])[]; + readonly forms: readonly Form[]; +} + +function cartLines(html: string): Line[] { + const list = between(html, '
      ', '
    '); + return [...list.matchAll(/
  • ([\s\S]*?)<\/li>/g)].map( + ([, line = '']) => ({ + name: ( + /

    ([\s\S]*?)<\/p>/.exec(line)?.[1] ?? '' + ).replace(/<[^>]+>/g, ''), + href: /

    /.exec(line)?.[1] ?? '', + sku: /

    ([^<]*)<\/p>/.exec(line)?.[1] ?? '', + problem: /data-problem="([^"]*)"/.exec(line)?.[1] ?? '', + note: ( + /

    ]*>([\s\S]*?)<\/p>/.exec(line)?.[1] ?? '' + ).replace(/\s+/g, ' '), + terms: [...line.matchAll(/

    ([^<]*)<\/dt>
    ([\s\S]*?)<\/dd>/g)].map( + ([, label = '', value = '']) => + [label, value.replace(/<[^>]+>/g, '').replace(/\s/g, ' ')] as const, + ), + forms: forms(line), + }), + ); +} + +function subtotal(html: string): string | null { + const found = /

    ([\s\S]*?)<\/p>/.exec(html)?.[1]; + return found === undefined + ? null + : found + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +describe('the cart', () => { + let screw: TestContent; + let screwDe: TestContent; + let washer: TestContent; + + beforeAll(async () => { + await ensureSite(); + + screw = await createContent({ + kind: 'product', + title: 'Cap screw', + slug: 'cap-screw', + frontmatter: [ + 'sizes:', + ' CS-10: 10 mm', + ' CS-16: 16 mm', + ' CS-25: 25 mm', + ' CS-30: 30 mm', + ' CS-99: 99 mm', + ].join('\n'), + }); + screwDe = await createContent({ + kind: 'product', + title: 'Zylinderschraube', + slug: 'zylinderschraube', + locale: 'de', + translationGroup: screw.translationGroup, + frontmatter: ['sizes:', ' CS-10: 10 mm', ' CS-16: 16 mm'].join('\n'), + }); + washer = await createContent({ + kind: 'product', + title: 'Washer', + slug: 'washer', + frontmatter: ['sizes:', ' WA-5: M5'].join('\n'), + }); + // A page that has nothing to do with the shop. + await createContent({ kind: 'page', title: 'About', slug: 'about' }); + }); + + // The pages are rendered once and cached; the shop's own rows are put back + // as they were before every test, so that one test's cart, or the stock it + // changed, is not the next one's. + beforeEach(async () => { + await db().batch( + ['p_shop_cart_line', 'p_shop_cart', 'p_shop_price', 'p_shop_variant'].map( + (table) => db().prepare(`DELETE FROM ${table}`), + ), + ); + const group = screw.translationGroup; + await createVariant({ + id: 'cs-10', + productGroup: group, + sku: 'CS-10', + moq: 100, + stock: 500, + sortOrder: 1, + }); + await createVariant({ + id: 'cs-16', + productGroup: group, + sku: 'CS-16', + moq: 50, + stock: 0, + stockPolicy: 'made_to_order', + sortOrder: 2, + }); + // 73 left with a minimum order of 100: out of stock. + await createVariant({ + id: 'cs-25', + productGroup: group, + sku: 'CS-25', + moq: 100, + stock: 73, + sortOrder: 3, + }); + // Sold, and not priced. + await createVariant({ + id: 'cs-30', + productGroup: group, + sku: 'CS-30', + sortOrder: 4, + }); + for (const [variantId, usd, eur] of [ + ['cs-10', 42, 39], + ['cs-16', 55, 51], + ['cs-25', 60, 56], + ] as const) { + await setPrice({ + variantId, + currency: 'USD', + amountMinor: usd, + source: 'base', + }); + await setPrice({ + variantId, + currency: 'EUR', + amountMinor: eur, + source: 'manual', + }); + } + }); + + /** The add-to-cart form of one size on a product page. */ + async function offerForm(path: string, sku: string): Promise
    { + const { html } = await get(path); + const row = + [...html.matchAll(/

  • ([\s\S]*?)<\/li>/g)] + .map(([, inside = '']) => inside) + .find((inside) => + inside.includes(`${sku}`), + ) ?? ''; + return forms(row)[0] ?? null; + } + + /** Adds a size from its product page and returns the cart cookie. */ + async function add( + path: string, + sku: string, + quantity?: string, + cookie?: string, + ): Promise { + const form = await offerForm(path, sku); + if (form === null) { + throw new Error(`${path} offers no form for ${sku}`); + } + const response = await submit( + form, + cookie, + quantity === undefined ? {} : { quantity }, + ); + expect(response.status).toBe(303); + return cookie ?? cartCookie(response); + } + + describe('a product page', () => { + it('gives a size that can be ordered a form that posts it to the cart', async () => { + expect(await offerForm(screw.path, 'CS-10')).toEqual({ + action: `${SHOP}/cart/update`, + method: 'post', + fields: { variant: 'cs-10', currency: 'USD', quantity: '100' }, + // The minimum order is where the quantity starts and what it moves + // in steps of. + quantity: { + type: 'number', + name: 'quantity', + min: '100', + step: '100', + value: '100', + inputmode: 'numeric', + }, + button: 'Add to cart CS-10', + }); + }); + + it('posts to the cart in the page’s language, in the page’s currency', async () => { + const form = await offerForm(screwDe.path, 'CS-16'); + + expect(form?.action).toBe(`${SHOP}/de/cart/update`); + expect(form?.fields).toEqual({ + variant: 'cs-16', + currency: 'EUR', + quantity: '50', + }); + expect(form?.button).toBe('In den Warenkorb CS-16'); + }); + + it('offers no form for a size that is out of stock, has no price, or is not sold', async () => { + expect(await offerForm(screw.path, 'CS-25')).toBeNull(); + expect(await offerForm(screw.path, 'CS-30')).toBeNull(); + expect(await offerForm(screw.path, 'CS-99')).toBeNull(); + expect(await offerForm(washer.path, 'WA-5')).toBeNull(); + }); + + it('carries no script for it', async () => { + // The currency switch is the page's one script; the forms need none. + const { html } = await get(screw.path); + + expect(html.match(/]*\bsrc=/g)).toHaveLength(1); + expect(html).not.toMatch(/\son[a-z]+=/); + }); + }); + + describe('the way to the cart', () => { + it('is in the header of every page, in the page’s language', async () => { + const english = between( + (await get(screw.path)).html, + '
    ', + '
    ', + ); + const german = between( + (await get(screwDe.path)).html, + '
    ', + '
    ', + ); + + expect(english).toMatch( + new RegExp( + `
    \\s*Cart`, + ), + ); + expect(german).toMatch( + new RegExp( + `\\s*Warenkorb`, + ), + ); + }); + + it('is there on a page that shows no product at all', async () => { + // A plain page, and a list of a kind the shop has nothing to say of. + for (const path of ['/about', '/news']) { + const { status, html } = await get(path); + + expect(status, path).toBe(200); + expect( + between(html, '
    ', '
    '), + path, + ).toContain(``); + } + }); + + it('is the same link for every visitor: a cached page knows nothing of a cart', async () => { + const cookie = await add(screw.path, 'CS-10'); + const header = (path: string, sent?: string) => + get(path, sent).then(({ html }) => + between(html, '
    ', '
    '), + ); + + // The cookie is scoped to the shop's own routes and is not sent with a + // page; even sent, the page says nothing of what is in the cart. + expect(await header(washer.path, cookie)).toBe(await header(washer.path)); + }); + + it('marks the cart as the page being read, on the cart page', async () => { + const { html } = await get(`${SHOP}/cart`); + + expect(between(html, '
    ', '
    ')).toContain( + `
    `, + ); + }); + }); + + describe('an empty cart', () => { + it('is a page of the site that says so, and leads back to the catalogue', async () => { + const { status, html } = await get(`${SHOP}/cart`); + + expect(status).toBe(200); + expect(html).toMatch(/]/); + expect(html).toContain('

    Your cart

    '); + expect(html).toContain('

    Your cart is empty.

    '); + expect(html).toContain( + '
    Back to the catalogue', + ); + expect(html).not.toContain('class="cart-lines"'); + expect(subtotal(html)).toBeNull(); + // Inside the site's own header and footer. + expect(html).toContain('
    '); + expect(html).toContain('
    '); + }); + + it('is titled in the theme’s words, in the page’s language', async () => { + expect((await get(`${SHOP}/cart`)).html).toMatch( + /Your cart — [^<]+<\/title>/, + ); + const german = (await get(`${SHOP}/de/cart`)).html; + expect(german).toMatch(/<html lang="de"[ >]/); + expect(german).toMatch(/<title>Ihr Warenkorb — [^<]+<\/title>/); + expect(german).toContain('<p class="empty">Ihr Warenkorb ist leer.</p>'); + expect(german).toContain('href="/de/products"'); + }); + + it('is what a cart nobody has touched for a month has become', async () => { + const cookie = await add(screw.path, 'CS-10'); + await db() + .prepare( + "UPDATE p_shop_cart SET expires_at = '2020-01-01T00:00:00.000Z'", + ) + .run(); + + const { status, html } = await get(`${SHOP}/cart`, cookie); + + expect(status).toBe(200); + expect(html).toContain('<p class="empty">Your cart is empty.</p>'); + expect(html).not.toContain('CS-10'); + }); + + it('is what a cookie that names no cart gets', async () => { + for (const cookie of [ + 'nundar_cart=00000000000000000000000000000000', + "nundar_cart=' OR 1=1 --", + ]) { + const { status, html } = await get(`${SHOP}/cart`, cookie); + + expect(status).toBe(200); + expect(html).toContain('<p class="empty">Your cart is empty.</p>'); + } + }); + }); + + describe('a cart with parts in it', () => { + it('lists each line with what it costs now, and the sum', async () => { + const cookie = await add(screw.path, 'CS-10'); + await add(screw.path, 'CS-16', '150', cookie); + + const { status, html } = await get(`${SHOP}/cart`, cookie); + + expect(status).toBe(200); + expect( + cartLines(html).map((line) => [line.name, line.sku, line.terms]), + ).toEqual([ + [ + 'Cap screw', + 'CS-10', + [ + ['Unit price', '$0.42'], + ['Total', '$42.00'], + ], + ], + [ + 'Cap screw', + 'CS-16', + [ + ['Unit price', '$0.55'], + ['Total', '$82.50'], + ], + ], + ]); + expect(subtotal(html)).toBe('Subtotal $124.50'); + expect(html).toContain( + '<p class="cart-note">Shipping and taxes are not included.</p>', + ); + }); + + it('links each line to its product, in the language the cart is read in', async () => { + const cookie = await add(screw.path, 'CS-10'); + + expect( + cartLines((await get(`${SHOP}/cart`, cookie)).html)[0], + ).toMatchObject({ name: 'Cap screw', href: screw.path }); + expect( + cartLines((await get(`${SHOP}/de/cart`, cookie)).html)[0], + ).toMatchObject({ name: 'Zylinderschraube', href: screwDe.path }); + }); + + it('prices from the database on every request, never from the cart', async () => { + const cookie = await add(screw.path, 'CS-10'); + await setPrice({ + variantId: 'cs-10', + currency: 'USD', + amountMinor: 50, + source: 'base', + }); + + const { html } = await get(`${SHOP}/cart`, cookie); + + expect(cartLines(html)[0]?.terms).toEqual([ + ['Unit price', '$0.50'], + ['Total', '$50.00'], + ]); + }); + + it('gives each line a form to set its quantity, in steps of its minimum order, and one to remove it', async () => { + const cookie = await add(screw.path, 'CS-16', '150'); + const [line] = cartLines((await get(`${SHOP}/cart`, cookie)).html); + + expect(line?.forms).toEqual([ + { + action: `${SHOP}/cart/update`, + method: 'post', + fields: { action: 'set', variant: 'cs-16', quantity: '150' }, + quantity: { + type: 'number', + name: 'quantity', + min: '50', + step: '50', + value: '150', + inputmode: 'numeric', + }, + // Named for its line: a page of buttons all called "Update" tells + // someone who cannot see them nothing. + button: 'Update CS-16', + }, + { + action: `${SHOP}/cart/update`, + method: 'post', + fields: { action: 'remove', variant: 'cs-16' }, + quantity: null, + button: 'Remove CS-16', + }, + ]); + }); + + it('changes a quantity and removes a line through those forms', async () => { + const cookie = await add(screw.path, 'CS-10'); + await add(screw.path, 'CS-16', undefined, cookie); + const before = cartLines((await get(`${SHOP}/cart`, cookie)).html); + const [set, remove] = before[0]?.forms ?? []; + + const changed = await submit(set as Form, cookie, { quantity: '300' }); + expect(changed.status).toBe(303); + expect(changed.headers.get('location')).toBe(`${SHOP}/cart`); + expect( + cartLines((await get(`${SHOP}/cart`, cookie)).html)[0]?.terms[1], + ).toEqual(['Total', '$126.00']); + + const removed = await submit(remove as Form, cookie); + expect(removed.status).toBe(303); + expect( + cartLines((await get(`${SHOP}/cart`, cookie)).html).map( + (line) => line.sku, + ), + ).toEqual(['CS-16']); + }); + + it('is the same cart in every language, each in its own words and number format', async () => { + const cookie = await add(screwDe.path, 'CS-10'); + + const { html } = await get(`${SHOP}/de/cart`, cookie); + + expect(html).toMatch(/<html lang="de"[ >]/); + expect(cartLines(html)[0]).toMatchObject({ + name: 'Zylinderschraube', + sku: 'CS-10', + terms: [ + ['Stückpreis', '0,39 €'], + ['Summe', '39,00 €'], + ], + }); + expect(subtotal(html)?.replace(/\s/g, ' ')).toBe('Zwischensumme 39,00 €'); + // The English page shows the same cart, in the currency it was + // started in. + expect( + cartLines((await get(`${SHOP}/cart`, cookie)).html)[0], + ).toMatchObject({ + name: 'Cap screw', + terms: [ + ['Unit price', '€0.39'], + ['Total', '€39.00'], + ], + }); + }); + + it('lets the language be changed without leaving the cart', async () => { + const { html } = await get(`${SHOP}/de/cart`); + const switcher = between(html, '<details class="langs">', '</details>'); + + // Mallok lists the same page of the plugin's under each language. + expect(switcher).toContain(`href="${ORIGIN}${SHOP}/cart" hreflang="en"`); + expect(switcher).toContain( + `href="${ORIGIN}${SHOP}/de/cart" hreflang="de" lang="de" aria-current="true"`, + ); + expect(switcher).toContain( + `href="${ORIGIN}${SHOP}/fr/cart" hreflang="fr"`, + ); + }); + + it('offers the currencies every line has a price in, and shows the one chosen', async () => { + const cookie = await add(screw.path, 'CS-10'); + const page = (await get(`${SHOP}/cart`, cookie)).html; + const chooser = forms( + between(page, '<div class="cart-foot">', '</div>'), + )[0]; + + expect(chooser).toMatchObject({ + action: `${SHOP}/cart/update`, + method: 'post', + fields: { action: 'currency' }, + }); + expect( + [ + ...between(page, '<form class="currency"', '</form>').matchAll( + /<button type="submit" name="currency" value="([A-Z]+)" aria-pressed="(true|false)">([A-Z]+)<\/button>/g, + ), + ].map(([, value, pressed, text]) => [value, pressed, text]), + ).toEqual([ + ['USD', 'true', 'USD'], + ['EUR', 'false', 'EUR'], + ]); + + const chosen = await submit(chooser as Form, cookie, { currency: 'EUR' }); + expect(chosen.status).toBe(303); + const after = (await get(`${SHOP}/cart`, cookie)).html; + expect(cartLines(after)[0]?.terms[0]).toEqual(['Unit price', '€0.39']); + expect(after).toContain( + '<button type="submit" name="currency" value="EUR" aria-pressed="true">EUR</button>', + ); + }); + + it('goes in the currency the buyer was reading when a part was added', async () => { + // The page's currency is in the form; the currency switch changes it + // there along with the prices. + const form = await offerForm(screw.path, 'CS-10'); + const response = await submit(form as Form, undefined, { + currency: 'EUR', + }); + + const { html } = await get(`${SHOP}/cart`, cartCookie(response)); + + expect(cartLines(html)[0]?.terms[0]).toEqual(['Unit price', '€0.39']); + }); + + it('is one visitor’s own page: not cached, not indexed, and no script on it', async () => { + const cookie = await add(screw.path, 'CS-10'); + const { html, headers } = await get(`${SHOP}/cart`, cookie); + + expect(headers.get('cache-control')).toBe('private, no-store'); + expect(headers.get('x-robots-tag')).toBe('noindex'); + expect(headers.get('cache-tag')).toBeNull(); + expect(html).not.toMatch(/<script\b/); + }); + + it('costs two round trips to D1 beyond Mallok’s own, however many lines', async () => { + const count = async (cookie?: string): Promise<number> => { + let status = 0; + const calls = await countD1Calls(async () => { + status = (await get(`${SHOP}/cart`, cookie)).status; + }); + expect(status).toBe(200); + return calls; + }; + const cookie = await add(screw.path, 'CS-10'); + const one = await count(cookie); + await add(screw.path, 'CS-16', undefined, cookie); + const two = await count(cookie); + + // Mallok reads the site and the plugin's state: one. The shop reads + // the cart, then everything its lines depend on: two more. + expect(one).toBe(3); + expect(two).toBe(3); + // An empty cart has no lines to read anything for. + expect(await count()).toBe(2); + }); + }); + + describe('a line that can no longer be ordered as it stands', () => { + it('says the minimum order when it has risen above what is in the cart', async () => { + const cookie = await add(screw.path, 'CS-10'); + await db() + .prepare("UPDATE p_shop_variant SET moq = 200 WHERE id = 'cs-10'") + .run(); + + const { html } = await get(`${SHOP}/cart`, cookie); + const [line] = cartLines(html); + + expect(line?.problem).toBe('below_moq'); + expect(line?.note).toBe('The minimum order is 200'); + // The form already asks for the new minimum. + expect(line?.forms[0]?.quantity).toMatchObject({ + min: '200', + step: '200', + value: '100', + }); + // A sum nobody can pay is not stated. + expect(subtotal(html)).toBeNull(); + }); + + it('says how many are left when the stock has fallen below what is in the cart', async () => { + const cookie = await add(screw.path, 'CS-10', '400'); + await db() + .prepare("UPDATE p_shop_variant SET stock = 250 WHERE id = 'cs-10'") + .run(); + + const { html } = await get(`${SHOP}/cart`, cookie); + + expect(cartLines(html)[0]).toMatchObject({ + problem: 'insufficient_stock', + note: 'Available from stock: 250', + }); + expect(subtotal(html)).toBeNull(); + }); + + it('says a part is gone, and leaves only the way to remove it', async () => { + const cookie = await add(screw.path, 'CS-10'); + await add(screw.path, 'CS-16', undefined, cookie); + await db() + .prepare( + "UPDATE p_shop_variant SET status = 'archived' WHERE id = 'cs-10'", + ) + .run(); + await db().prepare("DELETE FROM p_shop_variant WHERE id = 'cs-16'").run(); + + const { status, html } = await get(`${SHOP}/cart`, cookie); + const [archived, deleted] = cartLines(html); + + expect(status).toBe(200); + expect(archived).toMatchObject({ + name: 'Cap screw', + sku: 'CS-10', + problem: 'unavailable', + note: 'This part can no longer be ordered.', + }); + expect(archived?.forms.map((form) => form.fields.action)).toEqual([ + 'remove', + ]); + // A variant that no longer exists has no name or SKU left to show. + expect(deleted).toMatchObject({ + name: '', + sku: '', + problem: 'unavailable', + }); + expect(deleted?.forms).toHaveLength(1); + expect(deleted?.forms[0]?.button).toBe('Remove'); + expect(deleted?.href).toBe(''); + expect(html).not.toContain('<p class="cart-name"></p>'); + expect(html).not.toContain('<p class="offer-sku"></p>'); + }); + + it('says a part has no price, and states no sum', async () => { + const cookie = await add(screw.path, 'CS-10'); + await db() + .prepare("DELETE FROM p_shop_price WHERE variant_id = 'cs-10'") + .run(); + + const { html } = await get(`${SHOP}/cart`, cookie); + const [line] = cartLines(html); + + expect(line?.problem).toBe('no_price'); + expect(line?.note).toBe( + 'This part has no price here. Please ask for a quote.', + ); + expect(line?.terms).toEqual([]); + expect(subtotal(html)).toBeNull(); + }); + + it('keeps a part whose product is out in another language only', async () => { + // Published in German and nowhere else. It can be added from its + // German page, and is the same part when the cart is read in English. + const german = await createContent({ + kind: 'product', + title: 'Nur auf Deutsch', + slug: 'nur-auf-deutsch', + locale: 'de', + frontmatter: ['sizes:', ' ND-1: 1 mm'].join('\n'), + }); + await createVariant({ + id: 'nd-1', + productGroup: german.translationGroup, + sku: 'ND-1', + }); + await setPrice({ + variantId: 'nd-1', + currency: 'USD', + amountMinor: 100, + source: 'base', + }); + const cookie = await add(german.path, 'ND-1'); + + for (const path of [ + `${SHOP}/cart`, + `${SHOP}/fr/cart`, + `${SHOP}/de/cart`, + ]) { + const [line] = cartLines((await get(path, cookie)).html); + + expect(line, path).toMatchObject({ + name: 'Nur auf Deutsch', + href: german.path, + sku: 'ND-1', + problem: '', + }); + } + }); + + it('says a part is gone when its product is published for later', async () => { + // In a cart already, and then rescheduled: Mallok stops showing the + // page until its time comes, and the cart stops selling the part. + const held = await createContent({ + kind: 'product', + title: 'Held back', + slug: 'held-back', + frontmatter: ['sizes:', ' HB-1: 1 mm'].join('\n'), + }); + await createVariant({ + id: 'hb-1', + productGroup: held.translationGroup, + sku: 'HB-1', + }); + await setPrice({ + variantId: 'hb-1', + currency: 'USD', + amountMinor: 100, + source: 'base', + }); + const cookie = await add(held.path, 'HB-1'); + await db() + .prepare( + "UPDATE content SET published_at = '2999-01-01T00:00:00.000Z' WHERE id = ?", + ) + .bind(held.id) + .run(); + + const [line] = cartLines((await get(`${SHOP}/cart`, cookie)).html); + + expect(line).toMatchObject({ + name: '', + href: '', + sku: 'HB-1', + problem: 'unavailable', + }); + }); + + it('shows a cart in a currency every line has, when the one chosen is not one', async () => { + // A part priced in euros only, added from a German page; then one from + // an English page, which asks for dollars. Dollars would leave the + // first without a price and the buyer with no way back. + await db() + .prepare( + "DELETE FROM p_shop_price WHERE variant_id = 'cs-16' AND currency = 'USD'", + ) + .run(); + const cookie = await add(screwDe.path, 'CS-16'); + await add(screw.path, 'CS-10', undefined, cookie); + + const { html } = await get(`${SHOP}/cart`, cookie); + + expect(cartLines(html).map((line) => [line.sku, line.problem])).toEqual([ + ['CS-10', ''], + ['CS-16', ''], + ]); + expect(cartLines(html).map((line) => line.terms[0]?.[1])).toEqual([ + '€0.39', + '€0.51', + ]); + expect(subtotal(html)).toBe('Subtotal €64.50'); + }); + + it('shows the whole cart in the base currency when one line lacks the one chosen', async () => { + const cookie = await add(screwDe.path, 'CS-10'); + await add(screwDe.path, 'CS-16', undefined, cookie); + await db() + .prepare( + "DELETE FROM p_shop_price WHERE variant_id = 'cs-16' AND currency = 'EUR'", + ) + .run(); + + const { html } = await get(`${SHOP}/de/cart`, cookie); + + // One currency for the whole order: euros for one line and dollars + // for the next would add up to nothing. + expect(cartLines(html).map((line) => line.terms[0]?.[1])).toEqual([ + '0,42 $', + '0,55 $', + ]); + expect(html).not.toContain('<form class="currency"'); + }); + }); + + describe('a change the shop refuses', () => { + /** The refusal box of a cart page: the reason, and its words. */ + function refusal(html: string): { kind: string; text: string } | null { + const box = /<div class="cart-problem"([^>]*)>([\s\S]*?)<\/div>/.exec( + html, + ); + return box === null + ? null + : { + kind: /data-problem="([^"]*)"/.exec(box[1] ?? '')?.[1] ?? '', + text: (box[2] ?? '') + .replace(/<[^>]+>/g, ' ') + .replace(/\s+/g, ' ') + .trim(), + }; + } + + it('sends the buyer to the cart page, which says what was refused in their language', async () => { + const cookie = await add(screwDe.path, 'CS-10'); + const form = await offerForm(screwDe.path, 'CS-16'); + + // The quantity field would not allow it; a request can skip the field. + const response = await submit(form as Form, cookie, { quantity: '7' }); + expect(response.status).toBe(303); + const location = response.headers.get('location') ?? ''; + expect(location).toBe(`${SHOP}/de/cart?refused=below_moq&variant=cs-16`); + + const { status, html, headers } = await get(location, cookie); + expect(status).toBe(200); + expect(html).toMatch(/<html lang="de"[ >]/); + expect(html).toContain('<div class="cart-problem" role="alert"'); + expect(refusal(html)).toEqual({ + kind: 'below_moq', + text: 'Diese Änderung wurde nicht übernommen. CS-16 Die Mindestmenge beträgt 50', + }); + // The cart below it is unchanged. + expect(cartLines(html).map((line) => line.sku)).toEqual(['CS-10']); + expect(headers.get('cache-control')).toBe('private, no-store'); + }); + + it('lands on a page whose language can be changed', async () => { + // The page a POST renders is listed, in every language, at the POST's + // own address — which answers a link with "method not allowed". + const { html } = await get( + `${SHOP}/de/cart?refused=below_moq&variant=cs-16`, + ); + const switcher = between(html, '<details class="langs">', '</details>'); + const links = [...switcher.matchAll(/href="([^"]*)"/g)].map( + ([, href = '']) => href, + ); + + expect(links).toEqual([ + `${ORIGIN}${SHOP}/cart`, + `${ORIGIN}${SHOP}/de/cart`, + `${ORIGIN}${SHOP}/fr/cart`, + `${ORIGIN}${SHOP}/es/cart`, + ]); + for (const href of links) { + expect((await SELF.fetch(href)).status, href).toBe(200); + } + }); + + it.each([ + ['insufficient_stock', 'cs-10', 'CS-10 Available from stock: 500'], + [ + 'quantity_too_large', + 'cs-10', + 'CS-10 The most that can be ordered at once is 10000', + ], + [ + 'cart_full', + 'cs-10', + 'CS-10 The cart cannot hold more different parts.', + ], + ['unavailable', 'cs-10', 'CS-10 This part can no longer be ordered.'], + ])( + 'says %s with the figure the shop has, not one from the address', + async (kind, variantId, words) => { + const { html } = await get( + `${SHOP}/cart?refused=${kind}&variant=${variantId}&moq=1&available=99999&max=7&sku=EVIL`, + ); + + expect(refusal(html)).toEqual({ + kind, + text: `That change was not made. ${words}`, + }); + expect(html).not.toContain('99999'); + expect(html).not.toContain('EVIL'); + }, + ); + + it('puts nothing of a made-up link on the page', async () => { + // Only which reason and which variant are read from the address, and + // both only to look something up. + const unknownVariant = await get( + `${SHOP}/cart?refused=below_moq&variant=${encodeURIComponent('<b>call 555-0100</b>')}`, + ); + expect(refusal(unknownVariant.html)).toEqual({ + kind: 'unavailable', + text: 'That change was not made. This part can no longer be ordered.', + }); + expect(unknownVariant.html).not.toContain('555-0100'); + + for (const reason of ['', 'nonsense', '<script>', 'below_moq ']) { + const { status, html } = await get( + `${SHOP}/cart?refused=${encodeURIComponent(reason)}&variant=cs-10`, + ); + + expect(status, reason).toBe(200); + expect(refusal(html), reason).toBeNull(); + } + }); + + it('explains a refusal without a third round trip to D1', async () => { + const cookie = await add(screw.path, 'CS-10'); + let status = 0; + const calls = await countD1Calls(async () => { + status = ( + await get(`${SHOP}/cart?refused=below_moq&variant=cs-16`, cookie) + ).status; + }); + + expect(status).toBe(200); + // One of Mallok's, two of the shop's: the variant rides with the cart. + expect(calls).toBe(3); + }); + + it('refuses a form posted from another site', async () => { + const form = await offerForm(screw.path, 'CS-10'); + const response = await SELF.fetch(`${ORIGIN}${form?.action}`, { + method: 'POST', + headers: { + 'content-type': 'application/x-www-form-urlencoded', + 'sec-fetch-site': 'cross-site', + origin: 'https://elsewhere.example', + }, + body: new URLSearchParams(form?.fields ?? {}).toString(), + redirect: 'manual', + }); + + expect(response.status).toBe(403); + const rows = await db() + .prepare('SELECT COUNT(*) AS n FROM p_shop_cart') + .first<{ n: number }>(); + expect(rows?.n).toBe(0); + }); + }); +}); diff --git a/test/theme/pages.test.ts b/test/theme/pages.test.ts index d656f68..fc6fe8b 100644 --- a/test/theme/pages.test.ts +++ b/test/theme/pages.test.ts @@ -383,8 +383,17 @@ describe('the commerce theme', () => { it('shows each size with the SKU a buyer quotes', async () => { const { html } = await page(CAP_SCREW); - expect(html).toContain('<tr><td>TI-SHC-M5-10</td><td>10 mm</td></tr>'); - expect(html).toContain('<tr><td>TI-SHC-M5-16</td><td>16 mm</td></tr>'); + expect(html).toContain( + '<p class="offer-what"><span class="offer-sku">TI-SHC-M5-10</span><span class="offer-size">10 mm</span></p>', + ); + expect(html).toContain( + '<p class="offer-what"><span class="offer-sku">TI-SHC-M5-16</span><span class="offer-size">16 mm</span></p>', + ); + // The shop has no variants for this product: a row is the size and no + // more. What the plugin adds to one is in `prices.test.ts`. + expect(between(html, '<ul class="offers">', '</ul>')).not.toContain( + 'offer-terms', + ); }); it('shows the specification as a table', async () => { diff --git a/test/theme/prices.test.ts b/test/theme/prices.test.ts new file mode 100644 index 0000000..f434364 --- /dev/null +++ b/test/theme/prices.test.ts @@ -0,0 +1,717 @@ +import { SELF } from 'cloudflare:test'; +import { beforeAll, describe, expect, it } from 'vitest'; +import manifest from '../../src/theme/theme.json'; +import { + countD1Calls, + createContent, + createVariant, + ensureSite, + ORIGIN, + setPrice, + type TestContent, +} from '../shop/helpers.js'; + +/** + * Prices, minimum orders and availability on the pages a buyer reads. + * + * The shop plugin reads its own tables while Mallok renders a page, and the + * theme prints what it is given. Neither half is worth much alone, so these + * go through the real Worker: content from the management API, variants in + * the plugin's tables, a request for the page. + * + * The stock figures are numbers that appear nowhere else on a page, so that + * a test can say none of them leaked into one. + */ + +interface Page { + readonly status: number; + readonly html: string; + readonly tags: readonly string[]; +} + +async function page(path: string): Promise<Page> { + const response = await SELF.fetch(`${ORIGIN}${path}`); + return { + status: response.status, + html: await response.text(), + tags: (response.headers.get('cache-tag') ?? '').split(','), + }; +} + +function between(html: string, start: string, end: string): string { + const from = html.indexOf(start); + const to = html.indexOf(end, from + start.length); + return from === -1 ? '' : html.slice(from, to === -1 ? undefined : to); +} + +/** The tag that loads the currency switch's script, at this version of the theme. */ +const SWITCH_SCRIPT = `<script src="/theme/${manifest.id}/${manifest.version}/currency.js" defer></script>`; + +/** The currency switch of a page: what it says, as plain facts. */ +function currencySwitches(html: string): { + readonly own: string; + readonly label: string; + readonly hidden: boolean; + readonly buttons: readonly (readonly [string, string, string])[]; +}[] { + return [ + ...html.matchAll(/<div class="currency"([^>]*)>([\s\S]*?)<\/div>/g), + ].map(([, attributes = '', inside = '']) => ({ + own: /data-currency="([^"]*)"/.exec(attributes)?.[1] ?? '', + label: /aria-label="([^"]*)"/.exec(attributes)?.[1] ?? '', + hidden: /\shidden$/.test(attributes), + buttons: [ + ...inside.matchAll( + /<button type="button" value="([^"]*)" aria-pressed="([^"]*)">([^<]*)<\/button>/g, + ), + ].map(([, value = '', pressed = '', text = '']) => [value, pressed, text]), + })); +} + +/** One row of a product page's sizes, as plain facts. */ +interface Row { + readonly sku: string; + readonly size: string; + /** Label to value, in the order printed; empty when the row has no terms. */ + readonly terms: readonly (readonly [string, string])[]; +} + +function rows(html: string): Row[] { + const list = between(html, '<ul class="offers">', '</ul>'); + return [...list.matchAll(/<li class="offer">([\s\S]*?)<\/li>/g)].map( + ([, row = '']) => ({ + sku: /<span class="offer-sku">([^<]*)<\/span>/.exec(row)?.[1] ?? '', + size: /<span class="offer-size">([^<]*)<\/span>/.exec(row)?.[1] ?? '', + terms: [...row.matchAll(/<dt>([^<]*)<\/dt><dd>([\s\S]*?)<\/dd>/g)].map( + ([, label = '', value = '']) => + [label, value.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ')] as const, + ), + }), + ); +} + +/** The structured data of a page: the one node Mallok emits for it. */ +function structuredData(html: string): Record<string, unknown> { + const block = + /<script type="application\/ld\+json">([\s\S]*?)<\/script>/.exec(html)?.[1]; + return block === undefined + ? {} + : (JSON.parse(block) as Record<string, unknown>); +} + +/** What the finder prints under one product's name. */ +function listed(html: string, path: string): string | null { + const cell = between(html, `<a href="${path}">`, '</td>'); + const offer = /<p class="finder-offer">([\s\S]*?)<\/p>/.exec(cell)?.[1]; + return offer === undefined + ? null + : offer + .replace(/<\/span><span/g, '</span> | <span') + .replace(/<[^>]+>/g, '') + .replace(/\s+/g, ' '); +} + +describe('prices on the pages', () => { + let screw: TestContent; + let screwDe: TestContent; + let bolt: TestContent; + let washer: TestContent; + let spacer: TestContent; + let collection: TestContent; + + beforeAll(async () => { + await ensureSite(); + + // Everything exists before any page is requested. + collection = await createContent({ + kind: 'collection', + title: 'Cap screws', + slug: 'cap-screws', + }); + screw = await createContent({ + kind: 'product', + title: 'Cap screw', + slug: 'cap-screw', + frontmatter: [ + 'collection: cap-screws', + 'facets:', + ' Thread: M5 × 0.8', + 'sizes:', + ' CS-10: 10 mm', + ' CS-16: 16 mm', + ' CS-99: 99 mm', + ].join('\n'), + }); + screwDe = await createContent({ + kind: 'product', + title: 'Zylinderschraube', + slug: 'zylinderschraube', + locale: 'de', + translationGroup: screw.translationGroup, + frontmatter: [ + 'facets:', + ' Gewinde: M5 × 0,8', + 'sizes:', + ' CS-10: 10 mm', + ' CS-16: 16 mm', + ].join('\n'), + }); + bolt = await createContent({ + kind: 'product', + title: 'Flange bolt', + slug: 'flange-bolt', + frontmatter: ['facets:', ' Thread: M8 × 1.25'].join('\n'), + }); + washer = await createContent({ + kind: 'product', + title: 'Washer', + slug: 'washer', + frontmatter: ['sizes:', ' WA-5: M5'].join('\n'), + }); + + // Sold, and not priced yet. + spacer = await createContent({ + kind: 'product', + title: 'Spacer', + slug: 'spacer', + }); + await createVariant({ + id: 'sp-1', + productGroup: spacer.translationGroup, + sku: 'SP-1', + }); + + const group = screw.translationGroup; + // Three variants the page does not name. They are entered in an order + // that is neither the one the shop keeps them in nor that of their + // SKUs, so that only the query's own ordering puts them right: by + // `sortOrder`, and by SKU where two share one. + await createVariant({ + id: 'cs-30', + productGroup: group, + sku: 'CS-30', + optionValues: { length: '30 mm' }, + stock: 61873, + sortOrder: 5, + }); + await createVariant({ + id: 'cs-12', + productGroup: group, + sku: 'CS-12', + optionValues: { length: '12 mm' }, + sortOrder: 9, + }); + await createVariant({ + id: 'cs-16', + productGroup: group, + sku: 'CS-16', + moq: 50, + stock: 0, + stockPolicy: 'made_to_order', + leadTime: [15, 20], + sortOrder: 2, + }); + await createVariant({ + id: 'cs-10', + productGroup: group, + sku: 'CS-10', + moq: 100, + stock: 73519, + leadTime: [5, 10], + sortOrder: 1, + }); + await createVariant({ + id: 'cs-old', + productGroup: group, + sku: 'CS-OLD', + status: 'archived', + }); + await createVariant({ + id: 'cs-25', + productGroup: group, + sku: 'CS-25', + optionValues: { length: '25 mm' }, + moq: 100, + stock: 73, + sortOrder: 5, + }); + for (const [variantId, usd, eur] of [ + ['cs-10', 42, 39], + ['cs-16', 55, 51], + ['cs-25', 60, 56], + ['cs-old', 1, 1], + ] as const) { + await setPrice({ + variantId, + currency: 'USD', + amountMinor: usd, + source: 'base', + }); + await setPrice({ + variantId, + currency: 'EUR', + amountMinor: eur, + source: 'auto', + rateUsed: 0.92, + }); + } + + await createVariant({ + id: 'fb-30', + productGroup: bolt.translationGroup, + sku: 'FB-30', + stock: 0, + stockPolicy: 'made_to_order', + }); + await setPrice({ + variantId: 'fb-30', + currency: 'USD', + amountMinor: 1890, + source: 'base', + }); + await setPrice({ + variantId: 'fb-30', + currency: 'EUR', + amountMinor: 1799, + source: 'manual', + }); + }); + + describe('a product page', () => { + it('lists the sizes it names in its own order, then what else the shop sells of it', async () => { + const { status, html } = await page(screw.path); + + expect(status).toBe(200); + expect(rows(html).map((row) => [row.sku, row.size])).toEqual([ + ['CS-10', '10 mm'], + ['CS-16', '16 mm'], + ['CS-99', '99 mm'], + // Not in the page's front matter: named by their option values, + // in the order the shop keeps them in. + ['CS-25', '25 mm'], + ['CS-30', '30 mm'], + ['CS-12', '12 mm'], + ]); + }); + + it('gives each size its price, its minimum order, its state and its lead time', async () => { + const [first, second] = rows((await page(screw.path)).html); + + expect(first?.terms).toEqual([ + ['Unit price', '$0.42'], + ['Minimum order', '100'], + ['Availability', 'In stock'], + ['Lead time', '5–10 business days'], + ]); + expect(second?.terms).toEqual([ + ['Unit price', '$0.55'], + ['Minimum order', '50'], + ['Availability', 'Made to order'], + ['Lead time', '15–20 business days'], + ]); + }); + + it('says a size that cannot fill one minimum order is out of stock, and still what it costs', async () => { + const row = rows((await page(screw.path)).html).find( + (entry) => entry.sku === 'CS-25', + ); + + // 73 left with a minimum order of 100. + expect(row?.terms).toEqual([ + ['Unit price', '$0.60'], + ['Minimum order', '100'], + ['Availability', 'Out of stock'], + ]); + }); + + it('offers a size without a price on request', async () => { + const row = rows((await page(screw.path)).html).find( + (entry) => entry.sku === 'CS-30', + ); + + expect(row?.terms[0]).toEqual(['Unit price', 'On request']); + }); + + it('leaves a size the shop does not sell as its name and no more', async () => { + const row = rows((await page(screw.path)).html).find( + (entry) => entry.sku === 'CS-99', + ); + + expect(row).toEqual({ sku: 'CS-99', size: '99 mm', terms: [] }); + }); + + it('does not show an archived variant, or its price', async () => { + const { html } = await page(screw.path); + + expect(html).not.toContain('CS-OLD'); + expect(html).not.toContain('$0.01'); + }); + + it('never prints how many are left', async () => { + for (const path of [screw.path, '/products', '/']) { + const { html } = await page(path); + + expect(html, path).not.toContain('73519'); + expect(html, path).not.toContain('61873'); + } + }); + + it('speaks the page’s language and its currency', async () => { + const [first, second] = rows((await page(screwDe.path)).html); + const plain = (terms: Row['terms'] | undefined) => + terms?.map(([label, value]) => [label, value.replace(/\s/g, ' ')]); + + expect(plain(first?.terms)).toEqual([ + ['Stückpreis', '0,39 €'], + ['Mindestmenge', '100'], + ['Verfügbarkeit', 'Auf Lager'], + ['Lieferzeit', '5–10 Werktage'], + ]); + expect(plain(second?.terms)?.[2]).toEqual([ + 'Verfügbarkeit', + 'Auftragsfertigung', + ]); + }); + + it('is the sizes alone for a product the shop has no variants of', async () => { + const { status, html } = await page(washer.path); + + expect(status).toBe(200); + expect(rows(html)).toEqual([{ sku: 'WA-5', size: 'M5', terms: [] }]); + }); + + it('has no list of sizes when it names none and the shop sells none', async () => { + const lone = await createContent({ + kind: 'product', + title: 'Lone part', + slug: 'lone-part', + }); + + expect((await page(lone.path)).html).not.toContain('class="size-list"'); + }); + + it('lists what the shop sells of a product that names no sizes itself', async () => { + const found = rows((await page(bolt.path)).html); + + expect(found).toHaveLength(1); + expect(found[0]?.sku).toBe('FB-30'); + expect(found[0]?.terms[0]).toEqual(['Unit price', '$18.90']); + }); + }); + + describe('the structured data', () => { + it('offers exactly the prices the page prints, in the page’s currency', async () => { + for (const [path, currency] of [ + [screw.path, 'USD'], + [screwDe.path, 'EUR'], + ] as const) { + const { html } = await page(path); + const offers = structuredData(html).offers as { + '@type': string; + priceCurrency: string; + lowPrice: string; + highPrice: string; + offerCount: number; + offers: { sku: string; price: string; priceCurrency: string }[]; + }; + + expect(offers['@type']).toBe('AggregateOffer'); + expect(offers.priceCurrency).toBe(currency); + + // Every offer is a row the page prints a price in, and the amount is + // the row's: digits for digits, whatever the language does with + // separators and the sign. + const printed = new Map( + rows(html).flatMap((row) => { + const amount = row.terms[0]?.[1].replace(/[^\d]/g, '') ?? ''; + return amount === '' ? [] : [[row.sku, amount] as const]; + }), + ); + expect(offers.offers.map((offer) => offer.sku).sort()).toEqual( + [...printed.keys()].sort(), + ); + for (const offer of offers.offers) { + expect(offer.priceCurrency).toBe(currency); + expect(offer.price.replace('.', ''), offer.sku).toBe( + `0${printed.get(offer.sku)}`.replace(/^0+(\d{3,})$/, '$1'), + ); + } + expect(offers.offerCount).toBe(printed.size); + const amounts = offers.offers.map((offer) => Number(offer.price)); + expect(Number(offers.lowPrice)).toBe(Math.min(...amounts)); + expect(Number(offers.highPrice)).toBe(Math.max(...amounts)); + } + }); + + it('says of each offer what its row says: the state, the minimum order, the lead time', async () => { + const offers = structuredData((await page(screw.path)).html).offers as { + offers: Record<string, unknown>[]; + }; + const bySku = new Map(offers.offers.map((offer) => [offer.sku, offer])); + + expect(bySku.get('CS-10')).toEqual({ + '@type': 'Offer', + sku: 'CS-10', + price: '0.42', + priceCurrency: 'USD', + availability: 'https://schema.org/InStock', + eligibleQuantity: { '@type': 'QuantitativeValue', minValue: 100 }, + deliveryLeadTime: { + '@type': 'QuantitativeValue', + minValue: 5, + maxValue: 10, + unitText: 'business days', + }, + }); + expect(bySku.get('CS-16')?.availability).toBe( + 'https://schema.org/BackOrder', + ); + expect(bySku.get('CS-25')?.availability).toBe( + 'https://schema.org/OutOfStock', + ); + }); + + it('is one Offer for a product sold in one size', async () => { + expect(structuredData((await page(bolt.path)).html).offers).toEqual({ + '@type': 'Offer', + sku: 'FB-30', + price: '18.90', + priceCurrency: 'USD', + availability: 'https://schema.org/BackOrder', + }); + }); + + it('stays Mallok’s own node, with the offers added to it', async () => { + const node = structuredData((await page(screw.path)).html); + + expect(node['@type']).toBe('Product'); + expect(node.name).toBe('Cap screw'); + expect(node.url).toBe(`${ORIGIN}${screw.path}`); + }); + + it('has no offers for a product with nothing priced', async () => { + const node = structuredData((await page(washer.path)).html); + + expect(node['@type']).toBe('Product'); + expect(node).not.toHaveProperty('offers'); + }); + }); + + describe('the catalogue and the home page', () => { + it.each(['/products', '/'])( + 'print under each product on %s what it starts at and whether it can be had', + async (path) => { + const { status, html } = await page(path); + + expect(status).toBe(200); + expect(listed(html, screw.path)).toBe('from $0.42 | In stock'); + expect(listed(html, bolt.path)).toBe('from $18.90 | Made to order'); + // Sold and not priced: its state, and no "from" with nothing after. + expect(listed(html, spacer.path)).toBe('In stock'); + // No variants: the name and nothing under it. + expect(listed(html, washer.path)).toBeNull(); + }, + ); + + it('do so in the page’s language and currency', async () => { + const { html } = await page('/de/products'); + + expect(listed(html, screwDe.path)?.replace(/\s/g, ' ')).toBe( + 'ab 0,39 € | Auf Lager', + ); + }); + + it('leave the finder’s filters the rows they had', async () => { + // The script reads a product's name from the link, and nothing else + // from the cell it sits in. + const { html } = await page('/products'); + const cell = between(html, '<div class="finder-product">', '</td>'); + + expect(cell).toMatch(/<a href="[^"]+">[^<]+<\/a>/); + expect(html).toContain('<form class="finder-filters" hidden '); + }); + }); + + describe('the currency switch', () => { + it('gives each price its amount in every currency the page offers', async () => { + const english = (await page(screw.path)).html; + const german = (await page(screwDe.path)).html; + + // The amount showing is the page's own currency's; the others wait in + // attributes, formatted as this language writes them. + expect(english).toContain( + '<span class="price" data-price data-usd="$0.42" data-eur="€0.39">$0.42</span>', + ); + expect(german.replace(/\u00a0/g, ' ')).toContain( + '<span class="price" data-price data-usd="0,42 $" data-eur="0,39 €">0,39 €</span>', + ); + }); + + it('is in the page once, hidden, with the page’s own currency pressed', async () => { + expect(currencySwitches((await page(screw.path)).html)).toEqual([ + { + own: 'usd', + label: 'Currency', + hidden: true, + buttons: [ + ['usd', 'true', 'USD'], + ['eur', 'false', 'EUR'], + ], + }, + ]); + expect(currencySwitches((await page(screwDe.path)).html)).toEqual([ + { + own: 'eur', + label: 'Währung', + hidden: true, + buttons: [ + ['usd', 'false', 'USD'], + ['eur', 'true', 'EUR'], + ], + }, + ]); + }); + + it.each(['/products', '/'])( + 'is above the finder on %s, whose starting prices it can change', + async (path) => { + const { html } = await page(path); + + expect(currencySwitches(html)).toHaveLength(1); + expect(html).toContain( + '<span class="price">from <span data-price data-usd="$0.42" data-eur="€0.39">$0.42</span></span>', + ); + expect(html).toContain(SWITCH_SCRIPT); + }, + ); + + it('comes with its script, and only where there is a switch', async () => { + expect((await page(screw.path)).html).toContain(SWITCH_SCRIPT); + + // Nothing priced, so nothing to switch: no control, no script. + for (const path of [washer.path, spacer.path, collection.path]) { + const { html } = await page(path); + + expect(currencySwitches(html), path).toEqual([]); + expect(html, path).not.toContain('currency.js'); + expect(html, path).not.toContain('data-price'); + } + }); + + it('is absent from a page priced in one currency only', async () => { + const single = await createContent({ + kind: 'product', + title: 'Dollar part', + slug: 'dollar-part', + }); + await createVariant({ + id: 'dp-1', + productGroup: single.translationGroup, + sku: 'DP-1', + }); + await setPrice({ + variantId: 'dp-1', + currency: 'USD', + amountMinor: 100, + source: 'base', + }); + const { html } = await page(single.path); + + expect(html).toContain('<span class="price">$1.00</span>'); + expect(currencySwitches(html)).toEqual([]); + expect(html).not.toContain('currency.js'); + }); + }); + + describe('a collection page', () => { + it('lists its products without prices, because Mallok does not tell the plugin which products a content page lists', async () => { + // Not a choice: `renderData` is given the items of a list or the home + // page, and nothing of what a content page shows through a reference. + // When Mallok passes those, this test fails, and the table here should + // then be priced like the catalogue's. + const { status, html } = await page(collection.path); + + expect(status).toBe(200); + expect(html).toContain(`<a href="${screw.path}">Cap screw</a>`); + expect(html).not.toContain('finder-offer'); + }); + }); + + describe('the cache', () => { + const tag = (content: TestContent) => `p:shop:${content.translationGroup}`; + + it('tags a product page with its product, in every language', async () => { + expect((await page(screw.path)).tags).toContain(tag(screw)); + expect((await page(screwDe.path)).tags).toContain(tag(screw)); + }); + + it('tags a product page that has no variants yet, so that the first one reaches it', async () => { + expect((await page(washer.path)).tags).toContain(tag(washer)); + }); + + it('tags a list and the home page with every product they show', async () => { + for (const path of ['/products', '/']) { + const { tags } = await page(path); + + for (const product of [screw, bolt, washer, spacer]) { + expect(tags, path).toContain(tag(product)); + } + } + }); + + it('tags the home page with products and nothing else it shows', async () => { + // The home page also shows the newest collections; a price change + // would purge it for those too if they were tagged. + const { tags } = await page('/'); + + expect( + tags.filter((entry) => entry.startsWith('p:shop:')).sort(), + ).toEqual([screw, bolt, washer, spacer].map(tag).sort()); + }); + + it('gives a page that shows no product no tag of the shop’s', async () => { + const { tags } = await page(collection.path); + + expect(tags.filter((entry) => entry.startsWith('p:shop:'))).toEqual([]); + }); + + it('stores a priced page like any other', async () => { + const response = await SELF.fetch(`${ORIGIN}${screw.path}`); + + expect(response.headers.get('cache-control')).toBe( + 'public, max-age=0, s-maxage=3600', + ); + }); + }); + + describe('the cost of a cold render', () => { + it('is three round trips to D1 for a product page: two of Mallok’s and one of the shop’s', async () => { + // Created here so that no earlier test has put the page in the cache. + const cold = await createContent({ + kind: 'product', + title: 'Cold screw', + slug: 'cold-screw', + frontmatter: ['sizes:', ' CO-10: 10 mm'].join('\n'), + }); + await createVariant({ + id: 'co-10', + productGroup: cold.translationGroup, + sku: 'CO-10', + }); + await setPrice({ + variantId: 'co-10', + currency: 'USD', + amountMinor: 100, + source: 'base', + }); + + let html = ''; + const calls = await countD1Calls(async () => { + html = await (await SELF.fetch(`${ORIGIN}${cold.path}`)).text(); + }); + + expect(rows(html)[0]?.terms[0]).toEqual(['Unit price', '$1.00']); + expect(calls).toBe(3); + }); + }); +});
  • + {% assign offer = plugins.shop.products[item.id] %}
    {% if item.cover != blank %}{% endif %} - {{ item.title }} +
    + {{ item.title }} + {% if offer %}{% assign state = "avail_" | append: offer.availability %}

    {% if offer.price_from != blank %}{{ t.price_from }} 1 %} data-price{% for price in offer.prices_from %} data-{{ price.currency | downcase }}="{{ price.display }}"{% endfor %}{% endif %}>{{ offer.price_from }}{% endif %}{{ t[state] }}

    {% endif %} +
    {{ value }}