From 2b755851ff9c44fea7248eacb651ff87a347be98 Mon Sep 17 00:00:00 2001 From: JasonYv Date: Fri, 9 Oct 2026 11:58:55 +0800 Subject: [PATCH] feat: let a cart line hold 500 at most, and judge that with the minimum order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ceiling was 10,000, a guard against scripted abuse carried over from the previous implementation. The owner set it to 500 (design §11, decision 9), which makes it a rule of the shop that a buyer will meet. So it is judged where the minimum order is, in quantityIssue: a line above it that is already in a cart cannot be ordered either, and says the figure. A quantity field carries it as max, on a product page and in the cart, from max_quantity in both views. The admin refuses a minimum order above it. --- CLAUDE.md | 4 +- SECURITY.md | 6 ++- .../2026-09-30-nundar-on-mallok-design.md | 10 ++++- scripts/smoke-shop.mjs | 4 +- src/plugins/shop/lib/cart-pricing.ts | 22 ++++++++++- src/plugins/shop/lib/cart-view.ts | 10 +++-- src/plugins/shop/lib/cart.ts | 9 ++++- src/plugins/shop/lib/storefront.ts | 3 ++ src/plugins/shop/plugin.json | 4 +- src/plugins/shop/routes/cart.ts | 12 +++--- src/theme/layouts/shop-cart.liquid | 7 ++-- src/theme/partials/offer.liquid | 11 +++--- test/shop/cart-pricing.test.ts | 37 +++++++++++++++++++ test/shop/cart-route.test.ts | 32 ++++++++++++++-- test/shop/storefront.test.ts | 15 +++++--- test/shop/variant-records.test.ts | 6 +-- test/theme/cart.test.ts | 27 +++++++++++++- 17 files changed, 177 insertions(+), 42 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f93a67a..f275f57 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Mallok decides the contracts on both sides. Its documentation is the reference: - 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. +- **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 most a line may hold in `max` (`plugin_page.max_quantity`; on a product page `plugins.shop.max_quantity`), 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[]`. @@ -118,7 +118,7 @@ When one is built, take it off this list and prove the behaviour with a test or - 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. 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. +- MOQ, stock and the most a cart line may hold — 500, the owner's figure (`MAX_LINE_QUANTITY`) — 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, steps by it and stops at the ceiling (`min`, `step`, `max`); that is a convenience, and the server is the check. A minimum order above the ceiling is refused in the admin, and a size that has one is offered no form. - 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. diff --git a/SECURITY.md b/SECURITY.md index 6b6cbab..9c621e8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -90,8 +90,10 @@ These are deliberate and should not be "simplified" away: 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. +- **Cart size is bounded**: at most 100 lines and 500 units per line, so one + cart cannot be grown without limit. The 500 is a rule of the shop as well + as a bound: it is judged with the minimum order, so a line above it cannot + be ordered either, however it came to be in a cart. - **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. 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 b084139..61f55bc 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 @@ -371,6 +371,14 @@ The phases follow Mallok's own roadmap (`mallok: docs/PRODUCT_VISION.md §9`: 0. - Decision: Mallok is published (`0.1.0-rc.7` on npm, `latest`). - The owner implements the Mallok extension points in the Mallok project from a separate task list; Nundar work starts now against that plan. +Decided later: + +9. **The most a cart line may hold (2026-10-09).** + - Decision: 500 of one variant. It was 10,000, carried over from the previous implementation as a guard against scripted abuse (§17, left open, item 7). + - It is a rule of the shop now, so it is judged where the minimum order is — `quantityIssue`, which add-to-cart and cart pricing share — and a quantity field carries it as `max`. A line above it that is already in a cart cannot be ordered and says so. + - A minimum order above 500 is refused in the admin, and a variant that has one from a seed or by hand is offered no form. + - The sample's largest minimum order is 200. + ## 12. What phase 1A changed or found Recorded here rather than edited away, so a later reader can see what the design said, what the build found, and why they differ. @@ -618,7 +626,7 @@ Along the way the review's reading of the cart turned up a column: Mallok's `con 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. +7. *Ten thousand to a line.* Decided on 2026-10-09: five hundred (§11, decision 9). 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 2b55e94..5b9cd31 100644 --- a/scripts/smoke-shop.mjs +++ b/scripts/smoke-shop.mjs @@ -490,10 +490,10 @@ try { `the product page offers no usable form for its 20 mm size: ${JSON.stringify(form)}`, ); expect( - / MAX_LINE_QUANTITY) { + return { + kind: 'quantity_too_large', + variantId: variant.id, + max: MAX_LINE_QUANTITY, + requested: quantity, + }; + } // A made-to-order variant is produced on demand, so stock does not limit it. if (variant.stock_policy === 'track' && quantity > variant.stock) { return { diff --git a/src/plugins/shop/lib/cart-view.ts b/src/plugins/shop/lib/cart-view.ts index 98583a3..05c789f 100644 --- a/src/plugins/shop/lib/cart-view.ts +++ b/src/plugins/shop/lib/cart-view.ts @@ -10,19 +10,21 @@ * empty nothing in it * orderable every line can be ordered as it stands * subtotal the sum of the lines; '' unless the cart is orderable + * max_quantity the most one line may hold * lines[] variant_id, sku, name, path, quantity, moq, unit_price, * line_total, problem, available * * A line's `problem` is '' or the reason it cannot be ordered: `unavailable`, - * `below_moq`, `insufficient_stock`, `no_price`. A theme prints its own - * words for each. `available` is how many can be had when the reason is - * stock, and null otherwise. + * `below_moq`, `quantity_too_large`, `insufficient_stock`, `no_price`. A + * theme prints its own words for each. `available` is how many can be had + * when the reason is stock, and null otherwise. * * The route adds where its forms post (`action`), the cart's own address * (`cart_path`) and, after a change that was refused, what was refused * (`problem`). */ +import { MAX_LINE_QUANTITY } from './cart.js'; import type { CartFacts, CartIssue } from './cart-pricing.js'; import type { Currency } from './currency.js'; import { formatMoney, sumMinor } from './money.js'; @@ -49,6 +51,7 @@ export interface CartView { readonly empty: boolean; readonly orderable: boolean; readonly subtotal: string; + readonly max_quantity: number; readonly lines: readonly CartLineView[]; } @@ -96,6 +99,7 @@ export function cartView(facts: CartFacts, locale: string): CartView { locale, ) : '', + max_quantity: MAX_LINE_QUANTITY, lines, }; } diff --git a/src/plugins/shop/lib/cart.ts b/src/plugins/shop/lib/cart.ts index a134a02..0f0ad58 100644 --- a/src/plugins/shop/lib/cart.ts +++ b/src/plugins/shop/lib/cart.ts @@ -17,8 +17,13 @@ export interface CartLine { /** A cart that nobody touches for this long is deleted. */ export const CART_TTL_SECONDS = 60 * 60 * 24 * 30; -/** Real orders never reach this; anything above it is scripted abuse. */ -export const MAX_LINE_QUANTITY = 10_000; +/** + * The most of one variant a cart line may hold: the owner's figure (design + * §11, 2026-10-09). It is a rule of the shop and not only a guard against a + * script, so it is judged where the minimum order is, in `quantityIssue`, + * and a page says it in the quantity field's `max`. + */ +export const MAX_LINE_QUANTITY = 500; /** A ceiling on lines, so one cart cannot be grown without bound. */ export const MAX_CART_LINES = 100; diff --git a/src/plugins/shop/lib/storefront.ts b/src/plugins/shop/lib/storefront.ts index 64cb209..7c03f6d 100644 --- a/src/plugins/shop/lib/storefront.ts +++ b/src/plugins/shop/lib/storefront.ts @@ -13,6 +13,7 @@ * currencies the currencies every priced variant has a price in * price_from the lowest unit price that can be ordered; '' when none * prices_from the same, once per currency + * max_quantity the most of one variant a cart line may hold * variants[] id, sku, label, moq, availability, lead_time, price, prices, * orderable * @@ -89,6 +90,7 @@ export interface ProductView { readonly currencies: readonly Currency[]; readonly price_from: string; readonly prices_from: readonly DisplayPrice[]; + readonly max_quantity: number; readonly variants: readonly VariantView[]; } @@ -280,6 +282,7 @@ export function productView( price_from: from.find((price) => price.currency === currency)?.display ?? '', prices_from: from, + max_quantity: MAX_LINE_QUANTITY, variants: variants.map((variant) => { const own = display(amounts.get(variant.id), currencies, locale); const price = diff --git a/src/plugins/shop/plugin.json b/src/plugins/shop/plugin.json index b13500f..0857b2f 100644 --- a/src/plugins/shop/plugin.json +++ b/src/plugins/shop/plugin.json @@ -136,8 +136,8 @@ "required": true, "default": 1, "min": 1, - "help": "The least that can be ordered. On a page the quantity starts here and goes up in steps of it. At most 10,000, which is the most one cart line may hold.", - "max": 10000 + "help": "The least that can be ordered. On a page the quantity starts here and goes up in steps of it. At most 500, which is the most one cart line may hold.", + "max": 500 }, "stock_policy": { "type": "select", diff --git a/src/plugins/shop/routes/cart.ts b/src/plugins/shop/routes/cart.ts index 4fda24b..166ef73 100644 --- a/src/plugins/shop/routes/cart.ts +++ b/src/plugins/shop/routes/cart.ts @@ -17,8 +17,9 @@ * be switched: a page rendered by the POST itself would be listed, in every * language, at an address that only takes a POST. * - * A quantity field's `min` and `step` enforce the minimum order in the - * browser. This enforces it again, because a form can be bypassed. + * A quantity field's `min`, `step` and `max` enforce the minimum order and + * the most a line may hold in the browser. This enforces both again, because + * a form can be bypassed. * * The cart page is one visitor's own: Mallok serves it uncached and * unindexed, whatever is returned here. @@ -364,13 +365,12 @@ export async function cartUpdate( if (variant === undefined || variant.product_published !== 1) { return refuse('unavailable'); } - if (target > MAX_LINE_QUANTITY) { - return refuse('quantity_too_large'); - } const issue = quantityIssue(variant, target); if (issue !== null) { return refuse( - issue.kind === 'below_moq' || issue.kind === 'insufficient_stock' + issue.kind === 'below_moq' || + issue.kind === 'quantity_too_large' || + issue.kind === 'insufficient_stock' ? issue.kind : 'unavailable', ); diff --git a/src/theme/layouts/shop-cart.liquid b/src/theme/layouts/shop-cart.liquid index 303665b..46e6ad3 100644 --- a/src/theme/layouts/shop-cart.liquid +++ b/src/theme/layouts/shop-cart.liquid @@ -7,7 +7,8 @@ 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. + `min` and `step` and the most a line may hold in `max` — which the server + checks again. {%- endcomment -%} {% block title %}{{ t.cart_title }} — {{ site.name }}{% endblock %} {% block content %} @@ -47,7 +48,7 @@ {%- 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 %}

+

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

{% endif %} {% if line.unit_price != blank %} @@ -61,7 +62,7 @@
- +
{% endif %} diff --git a/src/theme/partials/offer.liquid b/src/theme/partials/offer.liquid index 7598558..48ba3cb 100644 --- a/src/theme/partials/offer.liquid +++ b/src/theme/partials/offer.liquid @@ -25,16 +25,17 @@ {%- 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. + quantity starts at the minimum order, moves in steps of it and stops at + the most a cart line may hold — all three checked again by the server. + A size without a price, or that cannot be had, is offered no form. 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 %} diff --git a/test/shop/cart-pricing.test.ts b/test/shop/cart-pricing.test.ts index 2d75081..49e047e 100644 --- a/test/shop/cart-pricing.test.ts +++ b/test/shop/cart-pricing.test.ts @@ -143,6 +143,43 @@ describe('priceCart', () => { }); }); + it('rejects more of one variant than a cart line may hold', async () => { + // Made to order, so nothing but the ceiling stands in the way. + await createVariant({ + id: 'bulk', + productGroup: valve.translationGroup, + stockPolicy: 'made_to_order', + }); + await setPrice({ + variantId: 'bulk', + currency: 'USD', + amountMinor: 100, + source: 'base', + }); + + expect(await price([{ variantId: 'bulk', quantity: 501 }])).toEqual({ + ok: false, + issues: [ + { + kind: 'quantity_too_large', + variantId: 'bulk', + max: 500, + requested: 501, + }, + ], + }); + expect((await price([{ variantId: 'bulk', quantity: 500 }])).ok).toBe(true); + }); + + it('says the ceiling rather than the stock when a quantity is above both', async () => { + // "At most 500" is still true after the next delivery. + const result = await price([{ variantId: 'dn50', quantity: 600 }]); + + expect(!result.ok && result.issues.map((issue) => issue.kind)).toEqual([ + 'quantity_too_large', + ]); + }); + it('rejects a quantity above the stock of a tracked variant', async () => { const result = await price([{ variantId: 'dn50', quantity: 101 }]); diff --git a/test/shop/cart-route.test.ts b/test/shop/cart-route.test.ts index 9b4f1c5..b99806c 100644 --- a/test/shop/cart-route.test.ts +++ b/test/shop/cart-route.test.ts @@ -278,20 +278,46 @@ describe('POST /_mallok/p/shop/cart/update', () => { expect(await cartCount()).toBe(0); }); - it('refuses an absurd quantity', async () => { + it('refuses more of one part than a cart line may hold', async () => { await createVariant({ id: 'bulk', productGroup: valve.translationGroup, stockPolicy: 'made_to_order', }); - const response = await post({ variant: 'bulk', quantity: '10001' }); + const over = await post({ variant: 'bulk', quantity: '501' }); - expect(refusal(response)).toMatchObject({ + expect(refusal(over)).toMatchObject({ status: 303, kind: 'quantity_too_large', + variant: 'bulk', }); expect(await lines()).toEqual([]); + expect(await cartCount()).toBe(0); + + // Five hundred is the most, and can be had. + const atMost = await post({ variant: 'bulk', quantity: '500' }); + expect(refusal(atMost).kind).toBeNull(); + expect(await lines()).toMatchObject([ + { variant_id: 'bulk', quantity: 500 }, + ]); + }); + + it('counts what is already in the cart towards the most a line may hold', async () => { + await createVariant({ + id: 'bulk', + productGroup: valve.translationGroup, + stockPolicy: 'made_to_order', + }); + const first = await post({ variant: 'bulk', quantity: '300' }); + const cookie = cartCookie(first); + + const more = await post({ variant: 'bulk', quantity: '201' }, cookie); + + expect(refusal(more)).toMatchObject({ kind: 'quantity_too_large' }); + expect(await lines()).toMatchObject([ + { variant_id: 'bulk', quantity: 300 }, + ]); }); it('refuses one more kind of part than a cart may hold', async () => { diff --git a/test/shop/storefront.test.ts b/test/shop/storefront.test.ts index 4744fda..06c20ab 100644 --- a/test/shop/storefront.test.ts +++ b/test/shop/storefront.test.ts @@ -191,10 +191,10 @@ describe('a product page’s view', () => { 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 + // A cart line holds five hundred 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' }), + variant('vast', { moq: 501, stock_policy: 'made_to_order' }), + variant('limit', { moq: 500, stock_policy: 'made_to_order' }), ], [ price('ok', 'USD', 100), @@ -223,7 +223,7 @@ describe('a product page’s view', () => { it('says what state each variant is in, and never how many are left', () => { const view = productView( [ - variant('a', { stock: 500, moq: 100 }), + variant('a', { stock: 7321, moq: 100 }), variant('b', { stock: 50, moq: 100 }), variant('c', { stock: 0, stock_policy: 'made_to_order' }), ], @@ -237,7 +237,12 @@ describe('a product page’s view', () => { 'made_to_order', ]); expect(JSON.stringify(view)).not.toMatch(/"stock"/); - expect(JSON.stringify(view)).not.toContain('500'); + expect(JSON.stringify(view)).not.toContain('7321'); + }); + + it('tells a page the most of one variant a cart line may hold', () => { + // The owner's figure. A quantity field carries it as `max`. + expect(productView([variant('a')], [], 'en')?.max_quantity).toBe(500); }); it('states a lead time only when both ends are known and in order', () => { diff --git a/test/shop/variant-records.test.ts b/test/shop/variant-records.test.ts index 035f64b..2db0eeb 100644 --- a/test/shop/variant-records.test.ts +++ b/test/shop/variant-records.test.ts @@ -366,17 +366,17 @@ describe('variants in the admin', () => { const { ctx } = context(); const atLimit = await saveVariant( - record(screw, null, { sku: 'CS-A', moq: 10000 }), + record(screw, null, { sku: 'CS-A', moq: 500 }), ctx, ); const over = await saveVariant( - record(screw, null, { sku: 'CS-B', moq: 10001 }), + record(screw, null, { sku: 'CS-B', moq: 501 }), ctx, ); expect(atLimit).toHaveProperty('id'); expect(over).toEqual({ - errors: { moq: 'The minimum order can be at most 10000.' }, + errors: { moq: 'The minimum order can be at most 500.' }, }); }); diff --git a/test/theme/cart.test.ts b/test/theme/cart.test.ts index a1e4406..76caf94 100644 --- a/test/theme/cart.test.ts +++ b/test/theme/cart.test.ts @@ -304,6 +304,8 @@ describe('the cart', () => { name: 'quantity', min: '100', step: '100', + // The most a cart line may hold. + max: '500', value: '100', inputmode: 'numeric', }, @@ -531,6 +533,7 @@ describe('the cart', () => { name: 'quantity', min: '50', step: '50', + max: '500', value: '150', inputmode: 'numeric', }, @@ -712,6 +715,28 @@ describe('the cart', () => { expect(subtotal(html)).toBeNull(); }); + it('says the most a line may hold when the cart has more than that in it', async () => { + // No form puts this in a cart: it is a line from before the ceiling + // was what it is, or a row written by hand. + const cookie = await add(screw.path, 'CS-10', '400'); + await db() + .prepare( + "UPDATE p_shop_cart_line SET quantity = 600 WHERE variant_id = 'cs-10'", + ) + .run(); + + const { html } = await get(`${SHOP}/cart`, cookie); + const [line] = cartLines(html); + + expect(line?.problem).toBe('quantity_too_large'); + expect(line?.note).toBe('The most that can be ordered at once is 500'); + expect(line?.forms[0]?.quantity).toMatchObject({ + max: '500', + value: '600', + }); + 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() @@ -970,7 +995,7 @@ describe('the cart', () => { [ 'quantity_too_large', 'cs-10', - 'CS-10 The most that can be ordered at once is 10000', + 'CS-10 The most that can be ordered at once is 500', ], [ 'cart_full',