Appearance
Catalog Case Matrix
One row per behavioral case for the catalog use cases built so far, named in CATALOG_USE_CASES.md. Each case names the test that proves it, quoted verbatim as a spec file path, then the separator "›", then the exact test title, exactly like ORGANIZATION_CASE_MATRIX.md. A database script is cited by a literal string it prints or records. A case with no proving test answers — and is listed under Gaps, with the slice that will prove it.
Slices K2 and K3 are proven: C3, C4, C5 and C7, the items and their default variant (migration 0076), and C1, C2, C8, C9 and C10, the workspace, the item list and search, the classification of items and the categories (migration 0077, baseline revision 87), over HTTP against PostgreSQL 18. Slice K5 is proven too: C12 and C13, the modifier groups, their options and the groups an item offers (no migration, baseline revision 88). Slice K4 is proven too: C6, the variants and the size by colour matrix, C11, the barcodes, and C18, the barcode lookup at an outlet and in the back office (migration 0079, baseline revision 89). No row is open: K8 is proven too (below) and the gap count is 0. The K3 leftovers (category ids in the item create, the accent-insensitive search of migration 0078, baseline revision 88) are proven too. The hardening round is proven too (migration 0080, baseline revision 90): the preselected options belong to each item offer (defaultOptionIds, C13) and leave every offer when the option, its group or its target variant is retired (C12), and an organization holds at most 500 categories (C9) and 500 modifier groups (C12), archived ones included. Slice K6 is proven too (no migration, baseline revision 90): C14, C15 and C16, the assortment of a business unit, with the authorization anchored at the unit, the listed filter of the item list, the curate action and the OD5 warning of the workspace, and the till lookup following the listings. Slice K7 is proven too (no migration, baseline revision 90): C17, what an outlet sells, with its category tree, its available modifier options and the defaults of each offer, and C19, the paged search of what an outlet sells, both anchored at the outlet and carrying the catalog version they were read at. Slice K8 is proven too (no migration, baseline revision 90): C20, the in-process CatalogSellableContract that Sales and Pricing will consume, with resolve and the locked admit, the same sellable rule as the till lists and the scan, and no row left open. The idempotent creates are proven too (migration 0081, baseline revision 91): the category, modifier group and option creates take an Idempotency-Key like the item create, every create replays the resource as it is now even after an edit (C4, C9, C12), and one category is read and guarded with its own ETag (C9). The contract step and the where-used read are proven too (migration 0082, baseline revision 92): catalog.modifier_options.is_default is gone, and GET /catalog/modifier-groups/{modifierGroupId}/offers answers which items offer a group, with their rules and defaults, keyset paged (C12). The admission gate before Sales is measured too (2026-10-06, no migration): catalog commands keep succeeding while tills admit, as long as a sale holds its transaction 50 ms or less after admit ("K8 as built", the gate row). It is a measurement, not a proving test, so it adds no row to the tables: npm run perf:admission, results in docs/operations/PERFORMANCE_BASELINE.md §15. Slice K9a is proven too (migration 0084, baseline revision 94): an offer can be retired, kept for the references of its neighbours and ignored by every read, while a suspended (inactive) required offer still hides the item (C13, CATALOG_OPTIONS_V2.md §4.1); no command writes retired yet, so the rows set it in SQL. Slice K9b is proven too (no migration, baseline revision 95): the offer command keeps every offer id, retires the groups it leaves out instead of deleting them, revives the same id when a retired group is offered again, and keeps the rows of Pricing and Sales that reference an offer valid (C13). Slice K9c is proven too (migration 0091, baseline revision 101): the runtime role holds no DELETE on the offers, so an offer can only be retired, never deleted (C13). Slice K9d is proven too (no migration, baseline revision 95): admit takes each line's quantity and its selections { optionId, quantity }, judges the line on the variant's sales quantity policy, each option on its maximum and each group on the sum of its units, and agrees with the real Sales modifier trigger and group check (C20, CATALOG_OPTIONS_V2.md §4.4). Slice K9e is proven too (no migration, baseline revision 96): resolve and admit answer the effective offer identity of every line, and the Sales rows built from the admitted answer alone pass the real Sales modifier trigger and group check (C20, CATALOG_OPTIONS_V2.md §5.1). Slice K9f is proven too (no migration): one effective-offer rule per variant (an active variant offer replaces the item offer, an inactive one opts the variant out, a missing or retired one inherits) decides the scan, the till, the search, resolve and admit, which agree on one table of cases (C17 to C20, CATALOG_OPTIONS_V2.md §4.2). The F1 fix is proven too (no migration): a group's capacity is the units its available options can reach, the sum of their maxQuantity, so one Cheddar chosen up to twice can be offered with a minimum of two and stays on sale after another option leaves, and the offer command, the scan, the till, the search, resolve and admit agree (C13, C17, C20, CATALOG_OPTIONS_V2.md §4.2). Slice K9g-0 is proven too (migration 0090, baseline revision 100): the Sales modifier trigger and group check follow the same rule, so an opted-out variant needs no selection in Sales either (X13 closed). Slice K9g is proven too (no migration): PUT /catalog/items/{itemId}/variants/{variantId}/modifier-groups writes the overrides and opt-outs of one variant in place, the item route suspends and resumes an offer, the item read answers both, the where-used read names the variant of an override, and the till list and search answer modifierGroupOverrides on each variant that has one (C3, C12, C13, C17, C19, CATALOG_OPTIONS_V2.md §4.3); since API v7 PR 5 those overrides travel in variantOverrides of PUT /catalog/items/{itemId}/modifier-groups. Slice B1 is proven too (migration 0100, baseline revision 110): the composition of a combo, kit or package, GET and PUT /catalog/items/{itemId}/bundle-groups, with stable ids, retirement instead of deletion, nested combos and the cycle guard (C22, Combos). Slice B2 is proven too (no migration): admit judges and answers the components of a combo, nested combos included (C28). Slice M1 is proven too (migration 0101, baseline revision 111): the structure of a menu, /catalog/menus with its sections, subsections and entries, stable ids, retirement instead of deletion, activation refused for an empty menu or one with nothing available, and archival (C21, Menus). API v7 PR 7 is proven too (migration 0104, baseline revision 114): one listing-changes route for listing and delisting (C15), the item list by several statuses, a category tree and a primary image with its thumbnail on every row (C1, C2), and a category tree tag that only category writes move (C10). Slice M2a is proven too (migration 0105, baseline revision 115): which menus an outlet shows and in what order, read and replaced as one list under a required If-Match, anchored at the outlet, and never limiting what it sells (C29, Menus). Slice K16 is proven too (migration 0106, baseline revision 116): the items an outlet does not sell, hide-only over the assortment of its unit, in the till list, the search, the scan, resolve and admit, in the components of a combo and in the menus of that outlet (C30, Outlet hidden items). Slice S1 is proven too (migration 0107, baseline revision 117): the hours in which an outlet shows each of its menus, written with the menus and judged by the server on the till read in the time zone of the site (C31, Menu time windows). Slice M2b is proven too (no migration, baseline revision 115): the till read carries the active menus of the outlet, in its order, by ids only, with the entries the same answer sells, in the same statements (C17). Slice B2c is proven too (no migration, baseline revision 114): the till read and the till search carry the composition of each combo, only the choices admit accepts, judged by the same SQL text as the sellable rule (C17, C19). Slice K14a is proven too (migration 0108, baseline revision 118): a preselected option starts with a quantity of its own, it counts against maxSelections, and reducedDefaults names a default chosen with less (C13, C17, C20). Slice K14b is proven too (no migration, baseline revision 118): an offer lowers or withholds an option for itself (optionLimits), judged against the option and feeding the minimum, the defaults, the till, resolve and admit (C13, C17, C19, C20). Slice K14c is proven too (migration 0110, baseline revision 120): text options, prefixes and print hints on the options of a group, judged by the domain and backed by CHECKs (C12). Slice K14d is proven too (no migration, baseline revision 120): the till read, resolve and admit answer the presentation of an option only when it differs from the default, and admit judges the text of a selection (C17, C20). Slice K12a is proven too (migration 0114, baseline revision 124): every catalog write leaves typed events with their tombstones in the outbox, in the transaction of the write, under the lock of the catalog version (C40). Slice K12b is proven too (no migration, baseline revision 124): an outlet is bootstrapped for offline work in unlimited pages pinned to one cut, with the SKU and the barcodes of what it sells, and the bootstrap plus the change feed after its cut equals a fresh bootstrap (C41, C42).
Totals
| Use cases | 42 |
| Per-case scenario rows | 746 |
| Per-case scenario rows proven | 746 |
| Cross-tenant and concurrency invariants | 37 (37 proven) |
| Gaps | 0 |
Item reads
C1 — Open the catalog workspace
GET /catalog/workspace with an optional businessUnitId; needs catalog.items.view organization-wide or at that business unit, never the plan. Not tagged: it embeds the first page of the item list.
| Case | Expected answer | Proving test |
|---|---|---|
| A catalog nobody wrote yet | 200, the default catalog that the first business unit created at provisioning (D1, revision 109; catalog: null remains only for an organization without one), zero counts, no categories, an empty page, allowedActions edit and retire; the catalog version does not move | test/e2e/catalog-workspace.e2e-spec.ts › opens a catalog nobody wrote yet with the default catalog its first business unit created, zero counts, no categories and an empty page, and writes nothing |
| Counts by status and the first page of active items | 200; the page is the one GET /catalog/items answers with limit 50 and its cursor continues there | test/e2e/catalog-workspace.e2e-spec.ts › answers the default catalog, the items counted by status and the first page of active items, whose cursor GET /catalog/items continues |
A manager of one unit follows itemsPageInfo.endCursor into GET /catalog/items at the same unit | 200 with the next page: the cursor binds the unit | test/e2e/catalog-workspace.e2e-spec.ts › gives a manager of one business unit a first-page cursor that GET /catalog/items continues at the same unit |
| The counts in the strict response, even when the facts carry more; no catalog and no assortment | exactly active, inactive, archived; null | src/modules/catalog/presentation/dtos/reads/workspace-response.spec.ts › answers only the three counts even when the facts carry more, so the strict schema still holds; src/modules/catalog/presentation/dtos/reads/workspace-response.spec.ts › answers no catalog and no assortment as null |
The assortment of a unit without a row, with an active all or selected row, with an inactive row | all, all, selected, none (the default catalog is always organization mode) | src/modules/catalog/infrastructure/reads/catalog-read-rows.spec.ts › answers the assortment of a unit with $label as $expected |
| A tree over 500 categories (only reachable by writing rows in SQL: the creation cap is 500) | the first 500 and no truncation flag (categoriesTruncated was removed in OpenAPI 12.0.0, API v7 PR 6) | test/e2e/catalog-workspace.e2e-spec.ts › caps the category tree at 500 nodes, the most a catalog can hold, without a truncation flag |
| The assortment of a named unit and the allowed actions | assortment.mode all without a row; edit, retire and curate (at the named unit) for an organization-wide manager, only curate for a manager of that unit, only retire on a lapsed plan; no refusal is recorded | test/e2e/catalog-workspace.e2e-spec.ts › answers the assortment of a named unit and what the caller may do: everything for the owner and an organization-wide manager, only curating that unit for a manager of one unit, and only retiring on a lapsed plan; src/modules/catalog/application/reads/open-workspace.use-case.spec.ts › offers only retiring when the plan lacks the catalog, and nothing to a member without the manage permission, without recording a refusal |
| A manager of one unit with its unit, without a unit, with another unit | 200, 403 · catalog.action_forbidden, 403; an unknown unit 404 · organization.structure_not_found only for an organization-wide reader | test/e2e/catalog-reads-access.e2e-spec.ts › lets a manager of one business unit read at that unit, never without it nor at an unknown unit or a unit of another organization |
| Cashier, waiter, kitchen, bar and a member without a role, with any unit | the same 403 · catalog.action_forbidden, nothing recorded | test/e2e/catalog-reads-access.e2e-spec.ts › answers a cashier, a waiter, a kitchen and a bar member and a member without a role the same plain denial on every read, with an existing, an unknown or no unit and with any filter, and records nothing |
| An organization whose plan lost the catalog | 200 | test/e2e/catalog-reads-access.e2e-spec.ts › keeps every read open to the owner of an organization whose plan lost the catalog |
| The categories of the workspace, under an inactive parent | each with its own status, isEffectivelyActive and blockedByAncestorId, derived from the tree it read, with no other statement | test/e2e/catalog-category-visibility.e2e-spec.ts › hides the active children of an inactive parent without changing their status, names the nearest blocking ancestor, and shows them again once the parent is reactivated while an inactive child stays inactive; src/modules/catalog/application/reads/open-workspace.use-case.spec.ts › answers each category with its effective visibility derived from the tree it read, with no other statement |
| The primary images of the first page | the same primaryImage as the list, from one Assets call | src/modules/catalog/application/reads/open-workspace.use-case.spec.ts › carries the primary image thumbnail of each item of its first page exactly as the item list does, in one call to Assets |
| The order of the checks | view permission, allowed actions, facts, tree, first page; nothing written | src/modules/catalog/application/reads/open-workspace.use-case.spec.ts › checks the view permission, then what the caller may do, then reads the facts, the tree and the first page of active items, writing nothing |
C2 — List and search items
GET /catalog/items with q, status (one or several, comma separated), categoryId with includeDescendants, hasImage, variantValue (K13), businessUnitId, listed, limit, after and before; same authorization as C1. Every row carries primaryImage (API v7 PR 7). Keyset order on the stored lowercase name (name_search_key, collation C) and the id; prefix search on the stored lowercase name, code and SKU keys (migration 0077).
| Case | Expected answer | Proving test |
|---|---|---|
| Active items by lowercase name with the default variant and the variant count, forward and backward pages | 200, meta.pageInfo only, Cache-Control: private, no-cache, no ETag | test/e2e/catalog-item-list.e2e-spec.ts › lists the active items by name, ignoring case, with the default variant and the variant count, private and never tagged, and pages forward and back with keyset cursors; src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts › orders by the lowercase name and the id, counts every variant and names the default variant and the primary category |
Prefix on name, code and SKU; case of accented letters; a substring; %, _, backslash and quotes; an empty, blank or 81-character q | prefix only, case-insensitive, every character literal; 400 · foundation.validation_failed for the malformed q | test/e2e/catalog-item-list.e2e-spec.ts › finds items by a case-insensitive prefix of their name, code or SKU, never by a substring, and takes %, _, backslash and quotes literally; src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts › pages backward from a position and filters by a category, by status and by a literal prefix of the name, the code or any SKU |
Accents: cafe, CAFE, café, CAFÉ, cafè find Café, CAFÉ helado, Cafè latte; nandu finds Ñandú; d finds no Ñ; an accented SKU; %, _, backslash after the prefix; paging those names one by one | the same items in key order, nothing for the literal wildcards, no gap or duplicate across pages | test/e2e/catalog-item-list.e2e-spec.ts › finds items by a prefix that ignores accents and case, in the name, the code and the SKU, and pages through accented names without a gap or a duplicate; src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts › matches a prefix of the name, the code or a SKU ignoring accents and case, and keeps %, _ and backslash literal; database/tests/contracts/catalog-search-key-contracts.ts › catalog.search_key lowercases and folds the Latin accents, and keeps every other character |
The statement of the list: one more row than the limit, the filters and the cursor before the limit, the same catalog.search_key on q, never LIKE or OFFSET | as described | src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › numbers the text, the category and the keyset position after the fixed parameters, and filters before the limit; src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › reads one more row than the limit, ascending, with no filter and no keyset position; src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › reads a backward page descending from its position |
| Paging by a category over several pages | full pages from each cursor, no gap, no duplicate, the last page says so | src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts › pages forward by a category through full pages from each cursor position, with no gap, no duplicate and the last page saying so |
| The prefix and the keyset position under row level security | index conditions on catalog_items_name_search_idx over a seeded catalog | database/tests/contracts/catalog-search-key-contracts.ts › is an index condition on catalog_items_name_search_idx under row level security |
| Status filter | active by default; inactive and archived only when asked | test/e2e/catalog-item-list.e2e-spec.ts › answers the active items by default and the inactive or archived ones only when asked |
| Several statuses, in any order | one order by name across them, page after page; the same cursor for inactive,active and active,inactive; 400 · collection.cursor_incompatible for another set | test/e2e/catalog-item-list-filters.e2e-spec.ts › lists several statuses in one order by name, its cursor bound to the set of statuses whatever their order; src/modules/catalog/infrastructure/reads/item-list-filters.integration-spec.ts › merges several statuses into one order by name, page after page, with the next page known exactly; src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts › reads one or several statuses separated by commas in the order of the lifecycle, active when absent |
| The statement for several statuses | one ordered branch per status with its own limit, merged and limited again, the prefix judged per branch; one status keeps its single scan | src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › reads each status of several as its own ordered branch with its own limit, merged in the same order and limited again, the text prefix matched once for all of them; src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › keeps a single status as the one ordered scan it always was, without a union |
| A category with its descendants | the category alone without includeDescendants; with it the two levels below too, each item once, nothing from a sibling tree; a cursor without it refused | test/e2e/catalog-item-list-filters.e2e-spec.ts › lists the items of a category and of the categories below it only when includeDescendants is true; src/modules/catalog/infrastructure/reads/item-list-filters.integration-spec.ts › keeps the category alone, or the category and the two levels below it, each item once and nothing from a sibling tree; src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › walks the category and at most two levels below it by the parent index when descendants are included |
| The primary image of each row and the image filter | primaryImage with the thumbnail URL, null without a primary image of the item (a variant image does not count); hasImage keeps either side; one Assets call per page, none for a page without images | test/e2e/catalog-item-list-filters.e2e-spec.ts › answers the primary image thumbnail of each item, null without one, filters by it, and the workspace first page carries the same rows; src/modules/catalog/infrastructure/reads/item-list-filters.integration-spec.ts › answers the primary image of the item itself and filters by it, never counting the image of a variant; src/modules/catalog/application/reads/list-items.use-case.spec.ts › describes the primary images of a page in one call for their thumbnails, and never calls Assets for a page without images; src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › filters by the presence of the item primary image with one keyed probe, and reads the primary image of every row |
An unknown or repeated status, includeDescendants without a category, hasImage other than true or false | 400 · foundation.validation_failed pointing at status[1], includeDescendants or hasImage | test/e2e/catalog-item-list-filters.e2e-spec.ts › refuses an unknown or repeated status, includeDescendants without a category and a hasImage that is not true or false, pointing at the parameter; src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts › points at an unknown, empty or repeated status by its position; src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts › includes the descendants of a category only when asked, and only with a category |
| The new filters right after a bulk import, before the statistics know it, in the good and in the bad plan | no sequential scan of the large tables, no looped wide scan, no scan of categories repeated per row, at most 4 rows and 4 buffers per imported item (8 with a text prefix), three statuses at most three single-status pages, a one-letter prefix within the same ceiling | src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts › reads a first page of 50 by its keys for several statuses, a category tree, an image filter and all of them at once; src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts › reads a category or a text prefix once whatever the number of statuses, so three statuses cost at most three single-status pages; src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts › finds a one-letter prefix that every imported item starts with in bounded rows and buffers |
| The statements of a page | 7 whatever the filters, 8 with listed or with primary images | test/e2e/catalog-reads-statement-budget.e2e-spec.ts › lists the items in 7 statements whatever the filters and 8 when the page holds primary images, opens the workspace in 12 and 13, and reads the category tree in 7 |
| Category filter and the primary category | the items classified in the category; primaryCategory names the primary one | test/e2e/catalog-item-classification.e2e-spec.ts › classifies an item with one primary category, shows it in the item, its tag and the lists, replaces and empties the set, and records only real changes |
limit=101, an unknown status or field, a forged organizationId; a tampered cursor, one from another filter or organization, an expired one | 400 · foundation.validation_failed; 400 · collection.cursor_tampered (or cursor_invalid), cursor_incompatible, cursor_expired | test/e2e/catalog-item-list.e2e-spec.ts › refuses a limit over 100, an unknown status or field, and tampered, foreign, cross-filter and expired cursors with their own codes |
A member without catalog.items.view with a tampered cursor; an authorized reader with the same cursor | the plain 403 · catalog.action_forbidden; 400 · collection.cursor_tampered (or cursor_invalid) | test/e2e/catalog-reads-access.e2e-spec.ts › answers a member without the view permission the plain denial even with a tampered cursor, and an authorized reader the cursor failure |
| Items created between pages | no gap, no duplicate, still ordered | test/e2e/catalog-item-list.e2e-spec.ts › pages without a gap or a duplicate while items are created between the pages |
| The same names and codes in two organizations | each lists only its own | test/e2e/catalog-item-list.e2e-spec.ts › lists only the items of the caller organization even when another one holds the same names and codes |
A member without catalog.items.view, with any unit or filter | the same 403 · catalog.action_forbidden, the unit never looked up | test/e2e/catalog-reads-access.e2e-spec.ts › answers a cashier, a waiter, a kitchen and a bar member and a member without a role the same plain denial on every read, with an existing, an unknown or no unit and with any filter, and records nothing; src/modules/catalog/application/reads/list-items.use-case.spec.ts › refuses a member without the view permission plainly, for any unit, before decoding the cursor or looking anything up |
| A manager of one unit; an unknown unit | 200 at its unit only; 404 · organization.structure_not_found only for an organization-wide reader | test/e2e/catalog-reads-access.e2e-spec.ts › lets a manager of one business unit read at that unit, never without it nor at an unknown unit or a unit of another organization; src/modules/catalog/application/reads/list-items.use-case.spec.ts › tells only an organization-wide reader that a unit does not exist |
| A lapsed plan | 200: reads are never gated | test/e2e/catalog-reads-access.e2e-spec.ts › keeps every read open to the owner of an organization whose plan lost the catalog |
variantValue=<dimension code>:<value code> | only the items that have an active variant carrying the value, the codes compared without case; a code that does not exist lists nothing and is no error | test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts › lists the items that have an active variant carrying the value, by the codes of the dimension and of the value ignoring case, and lists nothing for a code that does not exist |
variantValue repeated, up to three times, for different dimensions | one active variant must carry every value: a color of one variant and a size of another do not match; a variant that is deactivated stops matching | test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts › asks one active variant for every value of several, so a color of one variant and a size of another do not match |
| The only active variant that carries a value is deactivated, then reactivated | the item leaves the filtered list and comes back | test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts › ignores the variants that are not active: the only active carrier of a value leaves, and the item with it |
variantValue with q, status | the intersection, in the usual order | test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts › combines with the status and the text filters |
A cursor under another variantValue, or under none | the cursor is bound to the filter: 400 · collection.cursor_incompatible; the same filter pages on | test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts › pages with a cursor that belongs to its filter, and refuses it under another |
| A value without its two codes, more than three values, one dimension twice | 400 · foundation.validation_failed pointing at variantValue or variantValue[n] with a message that shows variantValue=talla:m and where the codes come from | test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts › refuses a malformed value, more than three and one dimension twice with a 400 that says how to send it |
| The schema and the statement of the filter | one or several values normalized to lower-case codes; one more candidate with the value found by the code of its dimension and its value, the same variant joined by key for each further value | src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts › takes one value or a repeated one, up to three, as lower-case codes of a dimension and of a value; src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts › asks the same active variant for every value, joining its links by its key, and numbers the parameters after the others; src/modules/catalog/presentation/controllers/reads/item-list-cursor.spec.ts › changes with the variant values, so a cursor of one filter is refused by another |
| The plan with a variant value, alone and with the other filters, among 10,000 linked items | every probe is a keyed lookup and no table is scanned whole, inside a ceiling of its own (5 rows and buffers per imported item, the other filters keep 4) | src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts › reads a first page of 50 by its keys for a value of a variant dimension, alone, twice, among the other filters and unknown |
C3 — Read one item
GET /catalog/items/{itemId} with an optional businessUnitId; needs catalog.items.view organization-wide or at that business unit, never the plan. The strong ETag is a hash of the item and its variants as answered (D24 "hash del contenido"), so two equal representations share a tag.
| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: the item with its variants, the same body and ETag the create answered | 200, Cache-Control: private, no-cache, strong ETag | test/e2e/catalog-items.e2e-spec.ts › creates an item with its active default variant and answers the same body and strong ETag the read answers |
If-None-Match with the current tag, then with a tag older than an edit | 304 with no body, then 200 with the new tag | test/e2e/catalog-items.e2e-spec.ts › answers 304 to an unchanged item, and a new tag after an edit; an identical edit writes and records nothing |
| An archived item | 200, status archived | test/e2e/catalog-items.e2e-spec.ts › keeps an archived item read-only until it is restored, refuses reactivating it, and answers repeats without a record; src/modules/catalog/application/items/read-item.use-case.spec.ts › reads an item, archived ones included, after the view permission, without locking or recording anything |
| An item of another organization, or an unknown id | 404 · catalog.not_found, the same body for both | test/e2e/catalog-items-isolation.e2e-spec.ts › answers an item of another organization exactly like an unknown id on the read, the edit and every transition, and changes nothing |
A manager assigned to one business unit, with and without businessUnitId, and with a unit of another organization | 200 at its unit, 403 · catalog.action_forbidden without it and at the foreign unit; only an organization-wide reader gets 404 · organization.structure_not_found for the foreign unit | test/e2e/catalog-items-isolation.e2e-spec.ts › lets a manager of one business unit read an item at that unit, never without it nor at a unit of another organization, and tells only an organization-wide reader that such a unit does not exist; src/modules/catalog/application/items/read-item.use-case.spec.ts › anchors the view permission at a named unit first and never looks the unit up for a granted caller |
| A cashier or a member with no role sends an existing unit id, an unknown unit id or none | the same 403 · catalog.action_forbidden for all three: the permission is checked before the unit is looked up, so unit ids cannot be probed | test/e2e/catalog-items-access.e2e-spec.ts › answers a member without the view permission the same plain denial for an existing unit, an unknown unit and no unit, and only an authorized caller learns that a unit is unknown; src/modules/catalog/application/items/read-item.use-case.spec.ts › tells only an organization-wide reader that a refused unit does not exist, and answers everyone else the plain denial without looking the unit up |
Kitchen, bar, cashier and waiter, whose roles hold no catalog.items.view | 403 · catalog.action_forbidden | test/e2e/catalog-items-access.e2e-spec.ts › keeps the item master from members whose roles hold no catalog view: kitchen, bar, cashier and waiter |
| The ETag changes after the item is reclassified | categories[] joins the representation and its hash; the old If-None-Match answers 200 | test/e2e/catalog-item-classification.e2e-spec.ts › classifies an item with one primary category, shows it in the item, its tag and the lists, replaces and empties the set, and records only real changes |
| The ETag changes after an offered modifier group is renamed or one of its options is edited or deactivated | modifierGroups[] joins the representation and its hash; the old If-None-Match answers 200 and the old If-Match 412 | test/e2e/catalog-modifier-offers.e2e-spec.ts › changes the item ETag when an offered group is renamed or one of its options is edited or deactivated, so the old tag stops matching; test/e2e/catalog-modifiers-journey.e2e-spec.ts › creates the cooking point and the extras, offers them on a burger, reads the burger with its groups, sees its tag change after an option edit and a deactivation, and the old tag stop matching |
| The ETag changes after a variant is added or the barcode set changes | variants[].barcodes[] joins the representation and its hash; the old If-None-Match answers 200 | test/e2e/catalog-variants.e2e-spec.ts › adds a named variant with If-Match, answers the item with both variants and a new tag the read repeats, records it once, and refuses the same code in any case with catalog.code_taken; test/e2e/catalog-barcodes.e2e-spec.ts › sets the barcodes of a variant, answers the set with the item tag, records one record with the live set before and after, refuses the same value on another item and lets another organization use it |
| A variant linked to dimension values by a matrix; one never linked | variants[].dimensionValues lists { dimension: { code, name }, value: { code, name } } in the order of the dimensions of the item; the field is absent for the default variant and for any variant without links, so those payloads and tags are byte-identical to before (the rich item golden file is unchanged) | src/modules/catalog/infrastructure/items/item-reads.spec.ts › carries the dimension values of a linked variant, in the order of the dimensions of the item, and nothing for a variant without links; src/modules/catalog/presentation/dtos/items/item-response.spec.ts › answers dimensionValues only for a linked variant, as the dimension and the value it holds in each dimension, in order; src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts › reads 100 imported items that each hold two dimension values by their keys, never scanning a whole table |
| An organization the staff suspended | 403 · tenant.context_rejected, before any catalog question | test/e2e/catalog-items-access.e2e-spec.ts › refuses every member of an organization the staff suspended at the door, before any catalog question |
| A variant that overrides one group and opts out of another; a suspended item offer (K9g) | each variant answers its modifierGroupOverrides in order (optedOut false with its own rule, true for a drop), a variant without one an empty list; the item group answers suspended: true; a retired variant row never appears | src/modules/catalog/presentation/dtos/items/item-response.spec.ts › answers a suspended item offer and the overrides and opt-outs of each variant; src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts › reads the live offers of each variant beside the item-level ones, an opt-out as inactive and never a retired one |
C10 — Read the category tree
GET /catalog/categories with optional status and businessUnitId; same authorization as C1. Strong ETag organization:category-tree:version:status from the category tree version (catalog_versions.categories_version, API v7 PR 7): only a category write of the organization changes it.
| Case | Expected answer | Proving test |
|---|---|---|
| The whole tree depth first, siblings by order then name, each with its depth | 200, strong ETag, Cache-Control: private, no-cache, meta: {}; 304 to the same tag | test/e2e/catalog-category-tree.e2e-spec.ts › answers the whole tree depth first, siblings by sort order and then name, with the depth of each node, a strong tag and private no-cache, and 304 to that tag |
| An organization that never wrote its catalog | 200, no node, one stable strong tag, 304 to it strong or weak, nothing written | test/e2e/catalog-category-tree.e2e-spec.ts › answers an organization that never wrote its catalog an empty tree under one stable strong tag, and 304 to that tag, strong or weak, writing nothing; src/modules/catalog/infrastructure/reads/catalog-read-rows.spec.ts › answers the version and no node for the one row of an organization without categories |
| A status filter | only that status, a kept child still names its excluded parent, another tag; an unknown status 400 | test/e2e/catalog-category-tree.e2e-spec.ts › keeps only the nodes of one status when asked, a child of an excluded parent still naming its parent, under another tag |
| The effective visibility of each node: an inactive parent over active and inactive children, a grandchild under two inactive ancestors, the parent reactivated | own status unchanged; isEffectivelyActive true only when the node and every ancestor are active; blockedByAncestorId the nearest ancestor that is not active, null when the node itself is the only cause; after the reactivation the active children are visible again and an inactive child stays inactive | test/e2e/catalog-category-visibility.e2e-spec.ts › hides the active children of an inactive parent without changing their status, names the nearest blocking ancestor, and shows them again once the parent is reactivated while an inactive child stays inactive; src/modules/catalog/application/reads/read-category-tree.use-case.spec.ts › derives the effective visibility of every node from the tree itself, with no other statement, when no status is asked; src/modules/catalog/domain/categories/category-visibility.spec.ts › derives $label; src/modules/catalog/domain/categories/category-visibility.spec.ts › derives every node of a tree in one pass whatever the order of the nodes; src/modules/catalog/domain/categories/category-visibility.spec.ts › treats a parent missing from the hierarchy as a root, and stops at a loop instead of walking forever |
| A status filter that leaves the ancestors out | the kept nodes still name their blocking ancestor, from one more hierarchy statement | test/e2e/catalog-category-visibility.e2e-spec.ts › hides the active children of an inactive parent without changing their status, names the nearest blocking ancestor, and shows them again once the parent is reactivated while an inactive child stays inactive; src/modules/catalog/application/reads/read-category-tree.use-case.spec.ts › reads the whole hierarchy once to name the blocking ancestor of a node whose ancestors the status filter left out |
| A move of the tree committed between the filtered tree and its hierarchy | the nodes, depths, visibility and tag come from one read-only snapshot: the tree before the move or the tree after it, never the nodes of one with the blocking ancestors of the other | test/e2e/catalog-category-read-race.e2e-spec.ts › answers the nodes, the depths and the blocking ancestors of one version of the tree when the move commits between the filtered tree and its hierarchy |
| An item write, a classification, a category write, a write of another organization | the tag stays for item writes and classifications; it changes for a category write; never for another organization; the old tag stops answering 304 | test/e2e/catalog-category-tree.e2e-spec.ts › keeps the tag while only items change, classifications included, changes it on a category write of the organization, never on a write of another organization, and an old tag stops answering 304 |
| The tree version in the database | one step per category statement, none for an update that leaves the shown columns as they were, none for item writes, guarded against forging | database/tests/contracts/catalog-category-tree-version-contracts.ts › an UPDATE that leaves every column the tree shows as it was moves only the catalog version; database/tests/contracts/catalog-category-tree-version-contracts.ts › an item and its default variant move the catalog twice and never the tree; database/tests/contracts/catalog-category-tree-version-contracts.ts › categories bump both versions once per INSERT, UPDATE and DELETE statement through one function |
| A category rename | a new tag, the old one answers 200 | test/e2e/catalog-categories-journey.e2e-spec.ts › builds three categories, creates and classifies dishes, lists them by category and by text, reads the tree with its tag, renames a category and sees the tag change and the old one stop matching |
A member without catalog.items.view, a manager of one unit, a lapsed plan | as C1 | test/e2e/catalog-reads-access.e2e-spec.ts › answers a cashier, a waiter, a kitchen and a bar member and a member without a role the same plain denial on every read, with an existing, an unknown or no unit and with any filter, and records nothing; test/e2e/catalog-reads-access.e2e-spec.ts › lets a manager of one business unit read at that unit, never without it nor at an unknown unit or a unit of another organization; test/e2e/catalog-reads-access.e2e-spec.ts › keeps every read open to the owner of an organization whose plan lost the catalog |
Item commands
C4 — Create an item
POST /catalog/items with a required Idempotency-Key; needs catalog.items.manage organization-wide and the capability catalog.items. The item and its default variant are written in one transaction after the catalog version row is locked.
| Case | Expected answer | Proving test |
|---|---|---|
Happy path: a simple item, its default variant default, a code generated from the name | 201, the C3 shape, its ETag and Cache-Control: private, no-store, one catalog.item.created record | test/e2e/catalog-items.e2e-spec.ts › creates an item with its active default variant and answers the same body and strong ETag the read answers; src/modules/catalog/application/items/create-item.use-case.spec.ts › locks the catalog version before any write, generates the code from the name, writes the item and its default variant and records the creation; src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts › stores an item with its default variant in the default catalog and reads quantities back in their shortest form |
| The generated code is taken, or the client sends one | -2, -3 on collision; an explicit code is kept as sent | src/modules/catalog/domain/items/item-code.spec.ts › numbers from 2 and takes the lowest free suffix, ignoring case; src/modules/catalog/application/items/create-item.use-case.spec.ts › takes the next free suffix when the generated code is taken and keeps an explicit code as sent; test/e2e/catalog-items-isolation.e2e-spec.ts › lets two organizations use the same code while one organization refuses it twice, in any case |
| A code already taken in the catalog of the organization, differing only in case | 409 · catalog.code_taken, no constraint name in the body | test/e2e/catalog-items-isolation.e2e-spec.ts › lets two organizations use the same code while one organization refuses it twice, in any case; src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts › refuses a code taken in any case, a taken SKU, an unknown unit and an impossible quantity policy with their own failures |
| A taken SKU, an unknown sales unit, a zero step or a maximum below the minimum | 409 · catalog.sku_taken, 422 · catalog.unit_unknown, 422 · catalog.quantity_policy_invalid | test/e2e/catalog-items.e2e-spec.ts › refuses a taken SKU, an unknown sales unit and an impossible quantity policy with their own codes and stores nothing, its audit record included |
| The audit record of a failed create | rolls back with it: no catalog.item.created record survives | test/e2e/catalog-items.e2e-spec.ts › refuses a taken SKU, an unknown sales unit and an impossible quantity policy with their own codes and stores nothing, its audit record included |
No Idempotency-Key; the same key and request again; the same key with another request | 400 · foundation.validation_failed; 201 with Idempotent-Replayed: true and the same body; 409 · idempotency.key_reused | test/e2e/catalog-items.e2e-spec.ts › requires an Idempotency-Key, replays the same key and request, and refuses the same key with another request; src/modules/catalog/application/items/create-item.use-case.spec.ts › replays the same key and the same request with the stored item and writes or records nothing; src/modules/catalog/application/items/create-item.use-case.spec.ts › refuses the same key with another request and writes nothing more |
| A retry with the same key and body before and after the item was edited; the same key with the item's new name | before: 201 with the first body and Idempotent-Replayed: true; after a rename: 201, Idempotent-Replayed: true and the item as it is now, because the replay compares the SHA-256 of the original request stored in creation_request_hash (revision 91); 409 · idempotency.key_reused | test/e2e/catalog-items.e2e-spec.ts › replays the first answer for a retry of an unedited item and the item as it is now once it was edited, because the replay compares the hash of the original request; src/modules/catalog/application/items/create-item.use-case.spec.ts › replays the same key and request after the item was renamed, because the replay compares the hash of the original request, not the current item |
| Two concurrent creates with the same explicit code | one 201, one 409 · catalog.code_taken, one item | test/e2e/catalog-items-concurrency.e2e-spec.ts › lets exactly one of two parallel creates with the same explicit code win and refuses the other with catalog.code_taken |
| Parallel creates that generate the same code | every one 201, codes base, base-2, base-3…, no deadlock | test/e2e/catalog-items-concurrency.e2e-spec.ts › gives parallel creates of the same name the base code and the next suffixes, with no conflict, no deadlock and no 500; database/tests/concurrency/catalog-item-concurrency.ts › NINAKU_CATALOG_ITEM_CONCURRENCY_OK: two creates that generate the same code wait on the catalog version row, and the second takes the next free suffix instead of colliding |
Parallel retries of one Idempotency-Key | one item, one audit record, every other answer replayed | test/e2e/catalog-items-concurrency.e2e-spec.ts › creates one item for parallel retries of one Idempotency-Key and replays it to every other caller |
| An item whose key was stored before revision 91, without a request hash | compared with the item as it is now, as before: 201 replayed while unedited, 409 · idempotency.key_reused once edited | src/modules/catalog/application/items/create-item.use-case.spec.ts › compares an item whose key was stored before the request hash existed with the item as it is now; src/foundation/idempotency/resolve-idempotent-creation.spec.ts › compares a stored creation without a hash with the current row, the compatibility path for rows created before the hash existed |
The same request with the categories in another order, or quantities written 1.0; a request that differs by one character, by type or by an explicit null | 201 replayed (the hash is taken over the canonical request: keys sorted, categories sorted by id, quantities in their shortest form); 409 · idempotency.key_reused | src/modules/catalog/application/items/create-item.use-case.spec.ts › replays the same categories sent in another order, because the classification is a set; src/modules/catalog/application/items/create-item.use-case.spec.ts › replays the same key and the same request with the stored item and writes or records nothing; src/foundation/idempotency/request-fingerprint.spec.ts › ignores the order of the keys at every depth; src/foundation/idempotency/request-fingerprint.spec.ts › tells apart values that differ only in type or in one character; src/foundation/idempotency/request-fingerprint.spec.ts › omits absent fields but keeps a null, so an omitted field and an explicit null differ; src/foundation/idempotency/resolve-idempotent-creation.spec.ts › replays the stored resource as it is now when the stored hash equals the request hash, without creating; src/foundation/idempotency/resolve-idempotent-creation.spec.ts › refuses the key with another request when the stored hash differs, whatever the current row looks like |
A request holding a Date, a Map, a Set, NaN, an infinite number or undefined inside an array reaches the fingerprint; nested arrays | refused with a TypeError instead of being hashed as something else; the order of nested arrays counts | src/foundation/idempotency/request-fingerprint.spec.ts › refuses $label instead of hashing it as something else; src/foundation/idempotency/request-fingerprint.spec.ts › keeps the order of nested arrays at every depth |
| A malformed hash (upper case, 63 characters, not hexadecimal) or a hash without a key written in SQL; a key with a well-formed hash; a key without a hash | refused by <table>_creation_request_hash_chk on the four tables; accepted; accepted (items created before revision 91) | database/tests/contracts/catalog-creation-request-contracts.ts › NINAKU_CATALOG_CREATION_REQUEST_OK |
| A retry after the member lost the permission or the plan lost the catalog, on every create | the plain 403 · catalog.action_forbidden or 403 · platform.capability_not_entitled, never the replay, nothing of the first answer | test/e2e/catalog-idempotent-creates-access.e2e-spec.ts › answers a retry of every catalog create by a member who lost the permission with the plain denial, never the replay; test/e2e/catalog-idempotent-creates-access.e2e-spec.ts › answers a retry of every catalog create of an organization whose plan lost the catalog with the entitlement code, never the replay |
The documented 201 of the four creates | Cache-Control: private, no-store and Idempotent-Replayed: true (only on a replay) in OpenAPI; the ETag stays in the operation text | src/foundation/openapi/api-tagged-create-headers.spec.ts › documents the private, no-store Cache-Control and the Idempotent-Replayed header of a 201 that a replay answers, and no ETag header, which would make the semantic check read the create as a conditional read |
| A stored creation without a hash on a table that never had keys before revision 91 | 409 · idempotency.key_reused: no comparator, never a replay | src/foundation/idempotency/resolve-idempotent-creation.spec.ts › refuses a stored creation without a hash as another request when the caller has no rows from before the hash existed |
| A cashier, a waiter, a kitchen member, a manager assigned to one business unit | 403 · catalog.action_forbidden, one denied record, nothing written, nothing about the plan | test/e2e/catalog-items-access.e2e-spec.ts › refuses a cashier, a waiter, a kitchen member and a manager of one business unit every item command with the plain denial, one denied record each and nothing written |
The owner of an organization whose plan lacks catalog.items | 403 · platform.capability_not_entitled, audited as denied | test/e2e/catalog-items-access.e2e-spec.ts › refuses the owner of an organization whose plan lacks the catalog to create or edit with the entitlement code, but still lets it read, deactivate and archive |
| A NUL byte, a blank name, a forged organization, status or variant list | 400 · foundation.validation_failed, nothing written | src/modules/catalog/presentation/dtos/items/create-item-request.schema.spec.ts › refuses %s; test/e2e/catalog-items-isolation.e2e-spec.ts › refuses a forged organization, status or variant list in the body as unknown fields and writes nothing |
| An item with three sizes | the create carries no named variants (an unknown variants field answers 400); the sizes are added with POST .../variants or POST .../variant-matrix | test/e2e/catalog-variant-matrix.e2e-spec.ts › creates the six variants of three sizes by two colours in one command with generated codes, names and SKUs, records it once, and answers the same matrix again with 200, the same item and tag and nothing written |
Classified in the create with categoryIds and primaryCategoryId; the replay; the same key with another primary | 201 with categories[], primary first, in the transaction and the record of the creation; 201 replayed; 409 · idempotency.key_reused | test/e2e/catalog-item-classification.e2e-spec.ts › creates an item already classified, its primary category first, in the transaction and the record of the creation, and replays the same key and categories; src/modules/catalog/application/items/create-item.use-case.spec.ts › locks the requested categories in id order after the item and its default variant, links them with the primary one and records them with the creation; src/modules/catalog/application/items/create-item.use-case.spec.ts › refuses the same key with other categories, because the replay compares the classification too |
| A category archived, unknown or foreign in the create; a repeated id, 21 ids, a primary outside them or without them | 422 · catalog.category_archived, 404 · catalog.not_found, 400; nothing created or recorded | test/e2e/catalog-item-classification.e2e-spec.ts › refuses to create an item in a category that is archived, unknown or foreign, a repeated id, more than twenty ids or a primary outside them, creating nothing; src/modules/catalog/application/items/create-item.use-case.spec.ts › refuses to create an item in a category that is archived or does not exist, before linking or recording anything; src/modules/catalog/presentation/dtos/items/create-item-request.schema.spec.ts › links the category ids sent, marking only the primary one, and links none without them |
| An item created with regulatory flags, and one without (K15a) | 201; the flags answer once each in the order of the vocabulary and the key is absent on an item without them; the same request again replays, another set under the same key 409 | test/e2e/catalog-regulatory-flags.e2e-spec.ts › creates an item with flags, answers them in the order of the vocabulary and answers none for an item without them; src/modules/catalog/application/items/create-item.use-case.spec.ts › writes the regulatory flags after the categories, records them in the creation and writes nothing for an item without flags; src/modules/catalog/application/items/item-creation-intent.spec.ts › fingerprints the regulatory flags in the order of the vocabulary, and a draft without them as before |
| An item created whose default variant holds a net content (K15a) | 201 with netContentQuantity in its shortest form and netContentUnitCode; the same request again replays, a different net content under the same key 409 | test/e2e/catalog-net-content.e2e-spec.ts › creates an item whose default variant holds a net content, reads it back in its shortest form and replays the same request without writing again; src/modules/catalog/application/items/item-creation-intent.spec.ts › matches a stored item whose default variant holds the same net content and no other |
C5 — Update an item's profile
PATCH /catalog/items/{itemId} with any of code, name, description and an optional If-Match; gated like C4. The catalog version row is locked first, then the item row.
| Case | Expected answer | Proving test |
|---|---|---|
| Rename, set and clear the description | 200 with the new ETag; one catalog.item.updated record with each change before and after | src/modules/catalog/application/items/update-item.use-case.spec.ts › locks the catalog version before the item, writes the real changes and records them with before and after; test/e2e/catalog-items.e2e-spec.ts › answers 304 to an unchanged item, and a new tag after an edit; an identical edit writes and records nothing |
| The values already stored | 200, nothing written or audited | src/modules/catalog/application/items/update-item.use-case.spec.ts › answers the stored item without a write or an audit record when nothing really changes |
| A code taken by another item | 409 · catalog.code_taken | src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts › refuses renaming the code onto a code another item holds |
A stale If-Match | 412 · foundation.precondition_failed, nothing written | src/modules/catalog/application/items/update-item.use-case.spec.ts › refuses a stale If-Match before writing anything; test/e2e/catalog-items.e2e-spec.ts › answers 304 to an unchanged item, and a new tag after an edit; an identical edit writes and records nothing |
Two concurrent edits with the same If-Match | one 200, one 412, one audit record | test/e2e/catalog-items-concurrency.e2e-spec.ts › accepts one of two edits sent with the same If-Match and refuses the other with 412, writing it nowhere |
| An archived item | 422 · catalog.item_archived until it is restored | test/e2e/catalog-items.e2e-spec.ts › keeps an archived item read-only until it is restored, refuses reactivating it, and answers repeats without a record |
An empty body, a null name or code, the status, the organization, the default variant as an object, a formerDefaultName without defaultVariantId, a defaultVariantId that is not a UUID | 400 · foundation.validation_failed | src/modules/catalog/presentation/dtos/items/update-item-request.schema.spec.ts › refuses %s |
A plan without catalog.items | 403 · platform.capability_not_entitled | test/e2e/catalog-items-access.e2e-spec.ts › refuses the owner of an organization whose plan lacks the catalog to create or edit with the entitlement code, but still lets it read, deactivate and archive |
| An item of another organization | 404 · catalog.not_found | test/e2e/catalog-items-isolation.e2e-spec.ts › answers an item of another organization exactly like an unknown id on the read, the edit and every transition, and changes nothing |
| The UPDATE statements | never name item_id, organization_id, catalog_id or created_at in their SET list | src/modules/catalog/infrastructure/items/item.queries.spec.ts › %s never names an identity column in its SET list |
| The regulatory flags replaced, sent again in another order, and cleared with an empty list (K15a) | 200; the entity tag moves with the set, the same set in another order is already in effect (same tag, nothing recorded), an empty list answers no key and the first tag; each real change is one catalog.item.updated record with catalog.item.regulatory_flags before and after | test/e2e/catalog-regulatory-flags.e2e-spec.ts › replaces the flags with an edit, moves the entity tag, writes and records nothing for the same set, and clears them with an empty list; src/modules/catalog/application/items/update-item.use-case.spec.ts › replaces the regulatory flags under the item lock and records them as one change of the set, in the order of the vocabulary |
| A profile edit and a flag edit in one request | one write of each under the item lock and one audit record holding both changes | src/modules/catalog/application/items/update-item.use-case.spec.ts › records the profile and the flags of one edit in a single record |
A flag outside the five, a flag twice, null instead of a list | 400 · foundation.validation_failed at regulatoryFlags[n] | test/e2e/catalog-regulatory-flags.e2e-spec.ts › refuses an unknown flag, a repeated one and null with 400 at the field; src/modules/catalog/presentation/dtos/items/update-item-request.schema.spec.ts › takes the regulatory flags apart from the profile, in the order of the vocabulary, and an empty list to clear them |
| Two edits of the flags sent at the same version; another organization's edit; a flagged item on the till | one 200 and one 412; 404 · catalog.not_found; the flagged item is still sold (Catalog declares and enforces nothing) | test/e2e/catalog-regulatory-flags.e2e-spec.ts › lets one of two edits of the flags at the same version win and answers the other 412; test/e2e/catalog-regulatory-flags.e2e-spec.ts › never lets another organization edit the flags of an item and declares without enforcing: a flagged item is still sold |
C6 — Manage variants
POST /catalog/items/{itemId}/variants, PATCH .../variants/{variantId}, PATCH /catalog/items/{itemId} with defaultVariantId (API v7 PR 2: it replaced POST .../variants/{variantId}/default-selection), POST .../variants/{variantId}/transitions with { transition } (API v7 PR 2: it replaced the per-verb routes and added restore) and POST /catalog/items/{itemId}/variant-matrix; catalog.items.manage organization-wide, gated by catalog.items except deactivate and archive. Every command locks the catalog version row, then the item row, and answers the whole item with its strong ETag, except the transitions route, which answers { variantId, status } with the item ETag.
| Case | Expected answer | Proving test |
|---|---|---|
Add a named variant with If-Match; the same code in any case; a stale tag | 201, Cache-Control: private, no-store, new tag, one catalog.item.variant_added; 409 · catalog.code_taken; 412 · foundation.precondition_failed | test/e2e/catalog-variants.e2e-spec.ts › adds a named variant with If-Match, answers the item with both variants and a new tag the read repeats, records it once, and refuses the same code in any case with catalog.code_taken; src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts › adds a variant after the permission and the plan, locking the catalog version before the item, then writes and records it |
| Edit a variant; the same values again; a SKU of another item; an unknown unit; an impossible policy; clear SKU and maximum | 200 with one catalog.item.variant_updated per real change; 409 · catalog.sku_taken; 422 · catalog.unit_unknown; 422 · catalog.quantity_policy_invalid | test/e2e/catalog-variants.e2e-spec.ts › edits a variant, writes and records only real changes, answers an identical edit without a record, and refuses a SKU another item holds, an unknown unit and an impossible quantity policy; src/modules/catalog/domain/items/item-variant.spec.ts › reports only the fields that really change, comparing quantities by their shortest decimal form |
| Make another variant the default; the former default has no name; a repeat | exactly one default; 422 · catalog.variant_name_required without formerDefaultName; a repeat answers 200 and records nothing | test/e2e/catalog-variants.e2e-spec.ts › makes another variant the default, naming the former default that had no name, keeps exactly one default, records it once and answers a repeat without a record; src/modules/catalog/application/items/update-item.use-case.spec.ts › makes another active variant the default, naming the former one, in one locked command with its own record; src/modules/catalog/application/items/update-item.use-case.spec.ts › answers the default already selected without a write or a record |
Rename the item and choose its default in one PATCH; a variant of another item; the former default-selection route | 200, one catalog.item.updated then one catalog.item.default_variant_changed; 404 · catalog.not_found; the old route 404 | test/e2e/catalog-variants.e2e-spec.ts › renames an item and chooses its default in one PATCH, one record each, and no longer answers the former default-selection route; src/modules/catalog/application/items/update-item.use-case.spec.ts › edits the profile and the default in the same transaction, one record each; src/modules/catalog/application/items/update-item.use-case.spec.ts › refuses a variant that is not active, a former default left without a name and a variant of another item, writing nothing; src/modules/catalog/application/items/update-item.use-case.spec.ts › audits a refused default selection under the action it asked for |
A PATCH with a new name and an inactive defaultVariantId | 422 · catalog.variant_not_active; one transaction, so the name is not kept, the ETag is unchanged and no catalog.item.updated record is written | test/e2e/catalog-variants.e2e-spec.ts › rolls back the whole PATCH when its default variant is refused: the new name is not kept and no profile record is written |
| Deactivate or archive the default variant; make an inactive or archived variant the default | 422 · catalog.default_variant_required; 422 · catalog.variant_not_active; the former default can be archived once another is the default | test/e2e/catalog-variants.e2e-spec.ts › never retires the default variant, never makes a variant that is not active the default, and retires the former default once another one is the default; src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts › never retires the default variant and writes nothing when asked to |
| Lifecycle of a named variant, restore included; repeats | 200 with { variantId, status } each; reactivate of an archived one 422 · catalog.transition_invalid whose detail names the status and restore, an edit 422 · catalog.variant_archived; restore brings it back inactive with its code and SKU, editable again; restore of an active one 422; repeats record nothing; the item status never moves | test/e2e/catalog-variants.e2e-spec.ts › deactivates, reactivates, archives and restores a named variant without touching the item; an archived variant refuses edits and reactivation until it is restored, and repeats record nothing |
| Restore an archived variant; restore a live one, the default included | locks the version then the item, gated by the plan, catalog.item.variant_restored (seeded by revision 112; catalog.item.variant_updated before it) with the status archived → inactive, no offer default touched; 422 · catalog.transition_invalid naming active and deactivate or archive, never catalog.default_variant_required | src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts › restores an archived variant to inactive after the plan, locking the catalog version before the item, and records its status before and after; src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts › refuses to restore a live variant, the default one included, naming its status and the transitions it allows; src/modules/catalog/domain/items/item-variant.spec.ts › refuses retiring the default variant, never the others, and never on a reactivation or a restore |
| Any variant command on an archived item | 422 · catalog.item_archived until the item is restored | test/e2e/catalog-variants.e2e-spec.ts › keeps the variants of an archived item read-only until the item is restored |
| A size by colour matrix; the same matrix again | 201 with six variants m-azul / M / Azul / CAM-OX-M-AZUL, one record; again: already in effect, 200 with the same body and tag, no Idempotent-Replayed header, nothing written | test/e2e/catalog-variant-matrix.e2e-spec.ts › creates the six variants of three sizes by two colours in one command with generated codes, names and SKUs, records it once, and answers the same matrix again with 200, the same item and tag and nothing written; src/modules/catalog/domain/items/variant-matrix.spec.ts › crosses the dimensions in the order sent, the first one outermost, joining codes with a hyphen and names with a slash |
| The first matrix of an item: its dimensions, values, links and audit record | 201; each dimension and value that does not exist in the catalog is created, one that exists is reused by its code ignoring case; every created variant answers dimensionValues in the order of the dimensions; one catalog.item.variant_added names the dimensions (catalog.item.variant_dimensions) and the counts (catalog.item.variants_created, catalog.item.variants_linked) | test/e2e/catalog-variant-dimensions.e2e-spec.ts › persists the dimensions, the values and the links of a matrix, answers each variant with its values in the order of the dimensions, and records the dimensions and the counts |
| Two items share a dimension, whatever the case, accents or punctuation of its name | the dimension is created once; a value that exists keeps its stored code and name, a new one is appended | test/e2e/catalog-variant-dimensions.e2e-spec.ts › shares a dimension and its values between items of the catalog, ignoring the case and the accents of its name, and never creates one twice |
| The owner applies again a matrix whose variants exist from before dimensions | 200 when only links are written: the variants keep their ids, barcodes and images and gain their values; missing cells are created (201); the same matrix again is already in effect | test/e2e/catalog-variant-dimensions.e2e-spec.ts › links the variants a matrix created before dimensions existed when the owner applies it again, touching neither their ids, barcodes nor images, and creates only the missing cells |
| A cell whose code exists with another name, or with other values than the cell asks | 409 · catalog.code_taken, nothing linked | test/e2e/catalog-variant-dimensions.e2e-spec.ts › keeps refusing a cell whose code exists with another name, or with other values than the cell asks |
| A later matrix with the same dimensions in the same order, and with any other set | the same dimensions may add values (201); any other set, order or count 422 · catalog.variant_dimensions_locked whose detail names the dimensions of the item and what to send | test/e2e/catalog-variant-dimensions.e2e-spec.ts › fixes the dimensions of an item with its first matrix: the same dimensions in the same order may add values, any other set is refused with 422 and what to send |
| A matrix whose new variant would repeat the combination of an active variant; reactivating a variant that would repeat one | 409 · catalog.variant_combination_taken whose detail says to deactivate or archive the other variant, nothing written; the database refuses the same state at commit | test/e2e/catalog-variant-dimensions.e2e-spec.ts › refuses a matrix that would give a new variant the combination another active variant holds, and the reactivation of a variant that would repeat one, writing nothing |
| Two matrices of one item sent at once | the second waits for the version row and sees what the first committed: the same matrix answers 200 and 201, a matrix of other dimensions answers 422 · catalog.variant_dimensions_locked | test/e2e/catalog-variant-dimensions.e2e-spec.ts › orders two matrices of the same item sent at once: the second sees what the first committed |
| Two dimension names of one matrix that reduce to the same code | 400 · foundation.validation_failed pointing at dimensions[n].name | src/modules/catalog/presentation/dtos/items/variant-matrix-request.schema.spec.ts › points at each dimension whose name reduces to the code of an earlier one, ignoring case, accents and punctuation |
| The statements of a matrix | 18 for the first matrix of an item, 16 for a matrix that adds values and 16 for one that only links, for 2 variants as for 100 | test/e2e/catalog-variant-matrix-statement-budget.e2e-spec.ts › takes the first matrix of an item in the same number of statements for 2 variants as for 100, creating dimensions, values, variants and links one statement each; test/e2e/catalog-variant-matrix-statement-budget.e2e-spec.ts › takes a later matrix that adds values in the same number of statements for 1 new variant as for 90; test/e2e/catalog-variant-matrix-statement-budget.e2e-spec.ts › links 2 old variants and 40 old variants in the same number of statements |
| A matrix that collides with a variant, takes a SKU, or passes 500 variants | 409 · catalog.code_taken, 409 · catalog.sku_taken, 422 · catalog.item_variants_full, nothing written | test/e2e/catalog-variant-matrix.e2e-spec.ts › refuses the whole matrix when a cell exists with other values or a SKU is taken elsewhere, and when the item would pass 500 variants, writing nothing |
| A matrix whose combination exists only as an archived variant | 409 · catalog.code_taken, nothing created: an archived variant is never the same matrix | test/e2e/catalog-variant-matrix.e2e-spec.ts › treats an archived variant with the code of a combination as a clash, never as the same matrix, and creates nothing |
| More than 100 cells, more than 3 dimensions, a repeated value code, a malformed code, an unknown field, no dimension | 400 · foundation.validation_failed | test/e2e/catalog-variant-matrix.e2e-spec.ts › refuses more than one hundred cells, more than three dimensions, a repeated value code, a code that cannot name a variant and an unknown field with 400 |
| A cashier, a waiter, kitchen, bar and a manager of one unit, on existing and unknown ids | the same 403 · catalog.action_forbidden, one denied record each, nothing written, the item tag unchanged | test/e2e/catalog-variants-access.e2e-spec.ts › refuses a cashier, a waiter, a kitchen and a bar member and a manager of one business unit every variant, barcode and matrix command with the same plain denial for existing and unknown ids, one denied record each and nothing written |
| A lapsed plan | add, edit, default selection, matrix, reactivate and restore 403 · platform.capability_not_entitled; deactivate and archive 200 | test/e2e/catalog-variants-access.e2e-spec.ts › refuses the owner whose plan lacks the catalog to add or edit variants and barcodes, select a default, apply a matrix, reactivate and restore with the entitlement code, while it still reads, finds barcodes, deactivates and archives |
An item or variant of another organization, a variant of another item, a forged organizationId | 404 · catalog.not_found like an unknown id, nothing changed; 400 for the forged field | test/e2e/catalog-variants-access.e2e-spec.ts › answers an item, a variant and a barcode of another organization, and a variant or barcode of another item of the same organization, exactly like unknown ids, and changes nothing |
The variant UPDATE statements | never name item_id, organization_id, catalog_id, created_at, item_variant_id or the barcode value in a SET list | src/modules/catalog/infrastructure/items/item-variant.queries.spec.ts › %s never names an identity, namespace, parent or barcode value column in its SET list |
| A restaurant adds a second portion size | 201; the unnamed default stays the usual portion | test/e2e/catalog-variants-journey.e2e-spec.ts › a restaurant owner adds a second portion size to a dish, which keeps its unnamed default for the usual portion |
| A variant added with a net content, changed, sent again with the same value and cleared (K15a) | 201 then 200; the answers carry the pair only while it is set; the same value is already in effect (same tag); the audit holds the added, updated and updated records | test/e2e/catalog-net-content.e2e-spec.ts › adds a variant with a net content, changes it, clears it, and records only the real changes; src/modules/catalog/domain/items/item-variant.spec.ts › reports the net content as two fields in the shortest decimal form and clears both with null |
| A net content unit the reference data does not know; the quantity without the unit or the unit without the quantity; a quantity of zero | 422 · catalog.unit_unknown, nothing written; 400 · foundation.validation_failed at the missing field or the quantity | test/e2e/catalog-net-content.e2e-spec.ts › refuses a unit the reference data does not know with 422, writing nothing; test/e2e/catalog-net-content.e2e-spec.ts › asks for the quantity and the unit together, and greater than zero, with 400 at the field; src/modules/catalog/presentation/dtos/items/variant-request.schemas.spec.ts › changes or clears the net content of a variant, always the two fields together |
| A minimum or a maximum sales quantity that is not a multiple of the step, on create and on edit | 422 · catalog.quantity_policy_invalid; a policy on the step is accepted | test/e2e/catalog-net-content.e2e-spec.ts › refuses a minimum or a maximum that is not a multiple of the step with 422 catalog.quantity_policy_invalid, on create and on update; src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts › refuses a net content unit the reference data does not know, a policy off its step and a net content with one half |
| A variant that never had a net content | answers none of the two keys, byte-identical to before; the database holds no half of a net content | test/e2e/catalog-net-content.e2e-spec.ts › answers a variant that never had a net content with none of its keys, and the database holds no half of it; src/modules/catalog/presentation/dtos/items/item-response.spec.ts › answers the net content of a variant only when it has one, and parses it as the wire contract |
| The database holds the bounds and the flags table (K15a) | a description over 2000 characters, a minimum or maximum off the step, a half of a net content and a sixth flag are refused; the flags table is tenant scoped, moves the catalog version and is granted SELECT, INSERT, DELETE only | database/tests/contracts/catalog-master-data-hardening-contracts.ts › accepts a description of 2000 characters and refuses one of 2001; database/tests/contracts/catalog-master-data-hardening-contracts.ts › a sixth flag is refused; database/tests/contracts/catalog-master-data-hardening-contracts.ts › a maximum off the step is refused |
C7 — Deactivate, reactivate, archive, restore an item
POST /catalog/items/{itemId}/transitions with { "transition": "deactivate" | "reactivate" | "archive" | "restore" } (API v7 PR 2: it replaced the four per-verb routes). deactivate and archive are never gated by the plan; reactivate and restore are (§7.3).
| Case | Expected answer | Proving test |
|---|---|---|
| Each legal transition | 200 with the item: active → inactive → archived → inactive → active, one record per change | src/modules/catalog/domain/items/item-lifecycle.spec.ts › %s of an item that is %s is %s and leaves it %s; test/e2e/catalog-items-journey.e2e-spec.ts › creates a dish without a code, reads it with its ETag, renames it with If-Match, deactivates, archives, restores and reactivates it, and leaves one audit record per real change |
| Each illegal transition (reactivating an archived item, restoring an active one) | 422 · catalog.transition_invalid | test/e2e/catalog-items.e2e-spec.ts › keeps an archived item read-only until it is restored, refuses reactivating it, and answers repeats without a record; src/modules/catalog/application/items/change-item-status.use-case.spec.ts › refuses an illegal transition such as reactivating an archived item |
The answer of a transition; a stale If-Match; a transition outside the closed list or another field; the former per-verb route | 200 with exactly { itemId, status } and the ETag a read answers; 412 · foundation.precondition_failed; 400 · foundation.validation_failed; 404 | test/e2e/catalog-items.e2e-spec.ts › answers a transition leanly with the id, the status and the strong ETag of the whole item, and refuses a body outside the closed list of transitions; src/modules/catalog/presentation/dtos/shared/transition-request.schema.spec.ts › refuses %j; src/modules/catalog/presentation/dtos/shared/transition-request.schema.spec.ts › accepts %s and takes the %s step |
| A refused transition teaches | the detail names the current status and the transitions it allows, from the lifecycle table | src/modules/catalog/presentation/failures/to-public-catalog-failure.spec.ts › teaches where a requested transition stands and which transitions it allows; src/modules/catalog/application/items/admit-item-transition.spec.ts › refuses with the current status and the transitions it allows; src/modules/catalog/domain/items/item-lifecycle.spec.ts › allows from %s exactly %j |
| A transition already in effect | 200, nothing written or audited | src/modules/catalog/application/items/change-item-status.use-case.spec.ts › answers a transition already in effect with the stored item and writes or records nothing |
| Restoration | back to inactive, never straight to active | src/modules/catalog/application/items/change-item-status.use-case.spec.ts › archives and restores back to inactive, never straight to active |
A plan without catalog.items | deactivation and archival 200; reactivation and restoration 403 · platform.capability_not_entitled | test/e2e/catalog-items-access.e2e-spec.ts › refuses the owner of an organization whose plan lacks the catalog to create or edit with the entitlement code, but still lets it read, deactivate and archive; src/modules/catalog/application/items/change-item-status.use-case.spec.ts › lets a lapsed plan deactivate and archive but not reactivate or restore |
A member without catalog.items.manage | 403 · catalog.action_forbidden on every transition, audited | test/e2e/catalog-items-access.e2e-spec.ts › refuses a cashier, a waiter, a kitchen member and a manager of one business unit every item command with the plain denial, one denied record each and nothing written |
| An item of another organization | 404 · catalog.not_found on every transition | test/e2e/catalog-items-isolation.e2e-spec.ts › answers an item of another organization exactly like an unknown id on the read, the edit and every transition, and changes nothing |
| An archived item is absent from the point-of-sale reads (C17, C19) | not in the sellables read, not found by the search | test/e2e/catalog-till-sellables.e2e-spec.ts › leaves out an inactive item, an archived item and an inactive variant, and keeps an item while one variant stays active |
| An archived item is refused by admission (C20) | CatalogNotSellableError with item_archived for its line | test/e2e/catalog-sellable-contract.e2e-spec.ts › refuses the whole admission, line by line, for an option of another item offer, an unavailable option, a count below the minimum or above the maximum, and an archived item |
C8 — Classify an item
PUT /catalog/items/{itemId}/categories with categories: [{categoryId, isPrimary}] (at most 20, each once, at most one primary) and an optional If-Match; gated like C4. Version row, then the item, then the requested categories FOR SHARE in id order.
| Case | Expected answer | Proving test |
|---|---|---|
| Classify, switch the primary, empty the set; repeat the same set | 200 with the item and its tag, one catalog.item.categories_set record per real change, none for a repeat | test/e2e/catalog-item-classification.e2e-spec.ts › classifies an item with one primary category, shows it in the item, its tag and the lists, replaces and empties the set, and records only real changes; src/modules/catalog/domain/items/item-classification.spec.ts › compares two classifications ignoring their order but not which link is primary |
Two primaries, an archived category, an unknown or foreign category, a repeated id, 21 links, a stale If-Match, an archived item | 422 · catalog.primary_category_conflict, 422 · catalog.category_archived, 404 · catalog.not_found, 400, 400, 412 · foundation.precondition_failed, 422 · catalog.item_archived; nothing written | test/e2e/catalog-item-classification.e2e-spec.ts › refuses two primaries, an archived category, an unknown or foreign category, a repeated id, more than twenty links, a stale If-Match and an archived item, writing nothing; src/modules/catalog/domain/items/item-classification.spec.ts › accepts no primary or one, never two |
| An inactive category in the create and in the classification; a link kept after its category was archived; an archived category added | 201, 200, 200; 422 · catalog.category_archived, nothing written or recorded | test/e2e/catalog-category-visibility.e2e-spec.ts › classifies an item into an inactive category in the create and in the classification, keeps a link to a category archived since, and refuses adding an archived one with catalog.category_archived, writing nothing; src/modules/catalog/application/items/create-item.use-case.spec.ts › creates an item in an inactive category, which only hides it from navigation |
| A link to an unknown or foreign category, or two primaries, reaching the repository | 404 · catalog.not_found or 422 · catalog.primary_category_conflict, never a constraint name | src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts › refuses linking an unknown category, a category of another organization and two primaries with catalog failures that name no constraint |
| 20 links, two primaries left to the domain; 21 links, a repeated id, a malformed id or link, an unknown field | accepted; 400 | src/modules/catalog/presentation/dtos/items/classify-item-request.schema.spec.ts › accepts no link, and up to twenty distinct links, leaving the primary rule to the domain; src/modules/catalog/presentation/dtos/items/classify-item-request.schema.spec.ts › refuses %s |
| A waiter; a lapsed plan | 403 · catalog.action_forbidden; 403 · platform.capability_not_entitled | test/e2e/catalog-item-classification.e2e-spec.ts › refuses a waiter with the plain denial and an organization whose plan lost the catalog with the entitlement code |
Categories
C9 — Create, rename, move and retire a category
POST /catalog/categories, PATCH /catalog/categories/{categoryId} (with parentCategoryId it also moves the category: API v7 PR 3 replaced POST .../re-parenting) and POST /catalog/categories/{categoryId}/transitions with { transition } (API v7 PR 3: it replaced the per-verb routes and added restore); catalog.items.manage organization-wide; create, edit, move, reactivate and restore gated by the plan, deactivate and archive never.
| Case | Expected answer | Proving test |
|---|---|---|
| Create a root and a child; a code taken in any case | 201 with the category and its depth, one catalog.category.created each; 409 · catalog.code_taken, no constraint name | test/e2e/catalog-categories.e2e-spec.ts › creates a root and a child category, active, answers them as the tree reads them, records each creation and refuses a taken code in any case |
| The 500th category with two archived ones among them; the 501st; a reactivation at the limit | 201; 422 · catalog.category_limit_reached, nothing written or recorded, the tree still answered whole (meta: {}); 200, a reactivation creates nothing | test/e2e/catalog-limits.e2e-spec.ts › creates the 500th category, archived ones counted, refuses the 501st with nothing written and still reactivates one; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › refuse a category beyond the organization limit, counted under the version lock, before judging the parent or writing |
| Edit code, name, description and order; the stored values again | 200, one catalog.category.updated per real change, none for a repeat | test/e2e/catalog-categories.e2e-spec.ts › edits the code, the name, the description and the order writing only real changes, and answers an identical edit without a record; src/modules/catalog/domain/categories/category-profile.spec.ts › lists only the fields that really change, with the order as text, and keeps the rest |
| Move under another and back to the root; under itself or a descendant; a fourth level; an archived parent; an unknown or foreign parent | 200; 422 · catalog.category_cycle; 422 · catalog.category_depth_exceeded; 422 · catalog.category_archived; 404 · catalog.not_found; nothing written | test/e2e/catalog-categories.e2e-spec.ts › moves a category under another and back to the root, and refuses a cycle, a fourth level, an archived parent and an unknown or foreign parent, writing nothing; src/modules/catalog/domain/categories/category-hierarchy.spec.ts › judges $label |
| Deactivate, reactivate, archive; repeats; reactivate or edit an archived category | 200, one record per real change; 422 · catalog.transition_invalid; 422 · catalog.category_archived | test/e2e/catalog-categories.e2e-spec.ts › deactivates, reactivates and archives a category, answers repeats without a record, refuses reactivating an archived one, and keeps it read-only |
| A move to the root and a deactivation; reactivating an inactive root and a child of an active parent | each written and recorded once after the version row and the category | src/modules/catalog/application/categories/edit-category.use-case.spec.ts › move a category to the root and deactivate another, after locking the version and the category, writing and recording each once; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › reactivate an inactive root and an inactive category whose parent is active |
Restore an archived category under an archived parent, then the parent, then the category; a repeat; reactivate of an archived one; the former noun as a transition | 422 · catalog.category_archived, nothing recorded; 200 with exactly { categoryId, status: inactive } and the tag the read answers, one catalog.category.restored each; 200, no record; 422 · catalog.transition_invalid whose detail names archived and restore; 400 | test/e2e/catalog-categories.e2e-spec.ts › restores an archived category as inactive under its parent once the parent is restored, answering lean with the tag the read answers, and teaches the transitions a status allows; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › restore an archived category to inactive under its live parent, judging the placement again, and record it; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › refuse to restore a category whose parent is archived, before writing anything; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › refuse a transition its status does not allow, naming the status and the transitions it allows |
| Restore the third level of a chain under an archived parent, then under its restored parent; move the root of that chain, its archived third level included, under another root | 422 · catalog.category_archived; 200, inactive at depth 3; 422 · catalog.category_depth_exceeded, because an archived category keeps counting toward the depth, so a restore can never land deeper than three levels; the depth refusal of a restore stays as a backstop | test/e2e/catalog-categories.e2e-spec.ts › restores the third level of a chain only once its parent is live, at the same depth, and never lets an archived category end up deeper than three levels; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › refuse to restore a category that would nest deeper than three levels, before writing anything |
Rename and move in one PATCH; a move that closes a cycle sent with a rename; a move to the root | 200 with the new place and depth, one catalog.category.updated then one catalog.category.moved; 422 · catalog.category_cycle, neither the name nor the place written; 200 | test/e2e/catalog-categories.e2e-spec.ts › renames and moves a category in one PATCH, records the edit and the move, and writes neither when the move is refused; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › rename and move a category in one locked transaction, judging the placement before any write and recording each fact once; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › refuse an edit that moves a category under its own subcategory or an unknown parent before writing anything |
A PATCH holding only parentCategoryId, by a member without the permission; a parentCategoryId that is not a UUID; the status | the refusal is audited as catalog.category.moved; 400; 400 | src/modules/catalog/application/categories/edit-category.use-case.spec.ts › admit an edit that only moves the category under the move action; src/modules/catalog/presentation/dtos/categories/category-request.schemas.spec.ts › refuses %j |
| A branch that loops back on itself or runs thousands of levels down | 422 · catalog.category_depth_exceeded, no stack overflow | src/modules/catalog/domain/categories/category-hierarchy.spec.ts › refuses as too deep, without overflowing the stack, a branch that loops back on itself or runs thousands of levels down |
| Create, edit, move and reactivate under an inactive parent; archive a category under it | 201 and 200, each answer carrying isEffectivelyActive and blockedByAncestorId = that parent | test/e2e/catalog-category-visibility.e2e-spec.ts › creates, edits, moves and reactivates categories under an inactive parent, each answer naming the parent as the blocking ancestor; src/modules/catalog/domain/categories/category-hierarchy.spec.ts › judges $label |
| Create a child under an archived parent or move a category under it; reactivate its inactive child | 422 · catalog.category_archived, nothing written; 200, the child active but hidden by the archived parent, whose status is never judged | test/e2e/catalog-category-visibility.e2e-spec.ts › refuses creating a child under an archived parent and moving a category under it with catalog.category_archived, writing nothing, but reactivates its inactive child, which the archived parent keeps hidden; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › reactivate a category under an inactive parent without judging its placement, and answer it hidden by that parent |
| Deactivate, reactivate and archive the categories of an item and their ancestors | the item and its variants keep status active; the item list still filters by the category | test/e2e/catalog-category-visibility.e2e-spec.ts › never changes the status of an item or of its variants when its categories or their ancestors are deactivated, reactivated or archived, and the item list still filters by the category |
| Unknown or forged fields, a NUL byte, a blank or long name, a bad code, a negative order, an empty edit | 400 · foundation.validation_failed, nothing written | test/e2e/catalog-categories.e2e-spec.ts › refuses unknown or forged fields, a NUL byte, a blank or long name, a bad code, a negative order and an empty edit with 400 and writes nothing |
| A cashier, a waiter, a manager of one unit | 403 · catalog.action_forbidden, one denied record each, nothing written | test/e2e/catalog-categories-access.e2e-spec.ts › refuses a cashier, a waiter and a manager of one business unit every category command with the plain denial, one denied record each and nothing written |
| A lapsed plan | create, edit, move, reactivate and restore 403 · platform.capability_not_entitled, the archived category left archived; deactivate and archive 200 | test/e2e/catalog-categories-access.e2e-spec.ts › refuses an organization whose plan lost the catalog to create, edit, move, reactivate or restore a category with the entitlement code, but lets it deactivate and archive |
| A category of another organization | 404 · catalog.not_found on every command, identical to an unknown id | test/e2e/catalog-categories-access.e2e-spec.ts › answers a category of another organization exactly like an unknown id on every command and changes nothing |
No or a malformed Idempotency-Key on the create | 400 · foundation.validation_failed, nothing written | test/e2e/catalog-idempotent-creates.e2e-spec.ts › requires a UUID Idempotency-Key on every catalog create and writes nothing without one |
| The same key and request again, also after the category was renamed | 201, Idempotent-Replayed: true, the category as it is now with its current ETag, nothing written or recorded | test/e2e/catalog-idempotent-creates.e2e-spec.ts › replays the same key and request on every catalog create with 201, Idempotent-Replayed: true and the resource as it is now, even after a rename, writing and recording nothing; src/modules/catalog/application/categories/create-category.use-case.spec.ts › replays the same key and request with the category as it is now, also after a rename, writing and recording nothing; src/modules/catalog/application/categories/create-category.use-case.spec.ts › looks the key up under the version lock and creates the category with the key and the hash of the request |
| The same key at the 500-category limit | 201 replayed: the key is looked up right after the version row, before the limit, the parent and the code | src/modules/catalog/application/categories/create-category.use-case.spec.ts › replays a category created with the key even when the organization already holds as many categories as allowed |
| The same key with another request; a taken code under another key | 409 · idempotency.key_reused, whatever the category looks like now; 409 · catalog.code_taken; nothing written | test/e2e/catalog-idempotent-creates.e2e-spec.ts › refuses a reused key with another request with idempotency.key_reused and a taken code under a new key with catalog.code_taken on every catalog create; src/modules/catalog/application/categories/create-category.use-case.spec.ts › refuses the same key with another request, whatever the category looks like now, and writes nothing |
| The same key in another organization; the key in the answer or the audit trail | an independent 201 there; never answered, never recorded | test/e2e/catalog-idempotent-creates.e2e-spec.ts › keeps the creation keys of each organization apart and never answers or records a key on every catalog create |
| Parallel retries of one key; parallel creates that share a key with different requests | one category, one record, every other answer 201 replayed; one 201, the others 409 · idempotency.key_reused; no 500 | test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts › creates one resource for parallel retries of one key on every catalog create and replays it to every other caller, with no 500 and one record; test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts › lets one of parallel creates that share a key with different requests win on every catalog create and refuses the others with idempotency.key_reused |
GET /catalog/categories/{categoryId}; If-None-Match weak or strong; the same tag after a rename | 200, the category with its depth and effective visibility, a strong ETag and Cache-Control: private, no-cache; 304 with no body; 200 with a new tag (D24) | test/e2e/catalog-category-preconditions.e2e-spec.ts › reads one category with its strong ETag and private, no-cache, answers 304 to a matching If-None-Match, weak or strong, and a new tag once the category was renamed; src/modules/catalog/application/categories/category-entity-tag.spec.ts › changes when any field of the category itself changes |
| Every category write: create, edit, move, deactivate, reactivate, archive | the category (a transition answers { categoryId, status }) with the strong ETag the read answers and Cache-Control: private, no-store; no Idempotent-Replayed header outside a replay | test/e2e/catalog-category-preconditions.e2e-spec.ts › answers every category write with the ETag of the category it returns, the tag the read answers, and private, no-store |
A stale or weak If-Match on the edit, the move and every lifecycle route; the current tag | 412 · foundation.precondition_failed, checked right after the version row and the category, nothing written; 200 with a new tag; without the header the write is not conditional | test/e2e/catalog-category-preconditions.e2e-spec.ts › refuses a stale If-Match on the edit, the move and every lifecycle route with 412 foundation.precondition_failed, writing nothing, and accepts the current tag; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › refuse a stale If-Match right after locking the version and the category, before any write or record, and accept the current tag; src/modules/catalog/application/categories/category-entity-tag.spec.ts › matches its own strong tag in If-Match, never a weak one or another category |
| The parent of a category is deactivated | the category's tag changes, because isEffectivelyActive, blockedByAncestorId and depth are part of what it answers; its old tag answers 412 | test/e2e/catalog-category-preconditions.e2e-spec.ts › changes the tag of a subcategory when its parent is deactivated, because its effective visibility is part of what it answers, and refuses the old tag with 412; src/modules/catalog/application/categories/category-entity-tag.spec.ts › changes when the visibility or the depth derived from the ancestors changes, because they are part of what a reader caches |
| A move of the tree committed between the statement that reads a category and the one that reads its hierarchy | GET /catalog/categories/{categoryId} answers its depth, isEffectivelyActive, blockedByAncestorId and ETag from one read-only snapshot: the state before the move or the state after it, never the depth of one with the blocking ancestor of the other | test/e2e/catalog-category-read-race.e2e-spec.ts › answers the depth and the blocking ancestor of one version of the tree when the move commits between the category and its hierarchy |
| The read by a manager at its unit; by a cashier, with a known or unknown id, or by the manager without its unit; an unknown or foreign category; a malformed id | 200; the same plain 403 · catalog.action_forbidden before any lookup; the same 404 · catalog.not_found; 400 | test/e2e/catalog-category-preconditions.e2e-spec.ts › lets a manager of one unit read a category at that unit, answers a member without catalog.items.view the same plain 403 for a known or unknown id, and an unknown or foreign category the same 404 |
| One key used for a category and for a modifier group of the same organization | both 201, independent; each replay answers its own resource | test/e2e/catalog-idempotent-creates-access.e2e-spec.ts › keeps one key used for a category and for a modifier group of the same organization apart, and replays each its own resource |
The current tag sent weak in If-Match | 412 · foundation.precondition_failed, nothing written, the tag unchanged | test/e2e/catalog-category-preconditions.e2e-spec.ts › refuses the current tag sent weak in If-Match with 412 foundation.precondition_failed and writes nothing, because a write compares strongly |
| A new field in the category answer | the tag hashes exactly the answered fields in their order, so it must change with it | src/modules/catalog/presentation/dtos/categories/category-response.spec.ts › tags exactly the fields the category answer carries, in the order it answers them, so a new answer field forces a new tag |
Modifiers
C12 — Define a modifier group and its options
POST /catalog/modifier-groups, GET /catalog/modifier-groups, GET /catalog/modifier-groups/{modifierGroupId}, GET /catalog/modifier-groups/{modifierGroupId}/offers (which items offer the group), PUT of the group with its whole option set (API v7 PR 5; it replaced the PATCH of the group and the POST and PATCH of an option), and the lifecycle pairs of the group and of an option. Writes need catalog.items.manage organization-wide (create, a set that does more than withdraw options, and reactivation gated by catalog.items; deactivation and archival never); reads need catalog.items.view as the other catalog reads. The group read and every group write carry a strong ETag that is a hash of the group and its options; the PUT requires it in If-Match.
| Case | Expected answer | Proving test |
|---|---|---|
| Create a group with instruction and variant options; read it | 201, Cache-Control: private, no-store, options in the order sent with derived codes, one catalog.modifier_group.created; the read answers the same body and tag, 304 on If-None-Match | test/e2e/catalog-modifier-groups.e2e-spec.ts › creates a group with instruction and variant options in order, records one creation, and answers the same body and strong ETag the read answers; src/modules/catalog/domain/modifiers/modifier-option.spec.ts › derives the missing codes from the names, never repeating a code sent or already taken, ignoring case |
isDefault on an option of the create; the group answer | 400 · foundation.validation_failed; no option carries a default, which belongs to each item offer (C13) | test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › keeps no default on the reusable option: the field is refused on the group and absent from its answer |
| The 500th group with an archived one among them; the 501st; a reactivation at the limit | 201; 422 · catalog.modifier_group_limit_reached, nothing written, the list still answered whole (meta: {}); 200 | test/e2e/catalog-limits.e2e-spec.ts › refuses a modifier group once the organization holds 500 of any status, and still reactivates one; src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts › refuses a group at the limit before any variant lock or write, recording nothing; src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts › counts the groups of the organization under the version lock and creates one below the limit |
| A group code taken in any case; an option on a variant of another organization, an unknown one, an inactive or an archived one | 409 · catalog.code_taken with no constraint name; 404 · catalog.not_found; 422 · catalog.variant_not_active; nothing written | test/e2e/catalog-modifier-groups.e2e-spec.ts › refuses a taken group code in any case with catalog.code_taken, and an unknown, foreign, inactive or archived variant, writing nothing |
Unknown or forged fields (organizationId, status, a price), a NUL byte, a blank or long name, a bad or missing group code, a kind without its target, a quantity out of 1 to 999, repeated option codes, 51 options | 400 · foundation.validation_failed, nothing written | test/e2e/catalog-modifier-groups.e2e-spec.ts › refuses unknown or forged fields, a NUL byte, a blank or long name, a bad code, a kind without its target, a quantity out of range, repeated option codes and more than 50 options with 400 |
Set the code, the name, the description and the order of the options with If-Match; no If-Match; a stale tag; the same set again; description absent; a taken group code; an empty body, a forged field, the old optionOrder, no code, or an option repeated | 200 with a new tag equal to the read's; 428 · precondition.required; 412 · foundation.precondition_failed; 200 without a record; 200 with no description; 409 · catalog.code_taken; 400 | test/e2e/catalog-modifier-groups.e2e-spec.ts › sets the code, the name, the description and the order of the options in one PUT, requiring If-Match, writing only real changes and answering an identical set without a record; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › keeps every id sent back, numbers the order sent and archives an option left out after the sent ones; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › summarizes the live options in their order with every compared field and ignores archived ones and their position |
| Deactivate, reactivate, archive and restore a group; repeats; reactivate or deactivate an archived group; edit it | 200 with { modifierGroupId, status } and the group tag, one record per real change, the options keep their status; 422 · catalog.transition_invalid; 422 · catalog.modifier_group_archived; still readable | test/e2e/catalog-modifier-groups.e2e-spec.ts › deactivates, reactivates, archives and restores a group, answers lean with the tag the read answers and repeats without a record, and keeps an archived group read-only but readable until it is restored |
| Restore an archived group; restore an archived option, of a live group or of an archived one; a transition the status does not allow (API v7 PR 3) | inactive, one catalog.modifier_group.restored, no offer default touched; inactive, one catalog.modifier_group.option_restored; 422 · catalog.modifier_group_archived, nothing written; 422 · catalog.transition_invalid naming the group or option, its status and the transitions it allows | src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts › restore an archived group to inactive after the version and the group locks, without touching any offer default, and record it; src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts › restore an archived option of a live group to inactive without touching any offer default, and record it on the group; src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts › refuse to restore an option of an archived group, writing nothing; src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts › refuse a transition the status does not allow, naming the group or the option, its status and the transitions it allows |
| List the groups; filter by status; an unknown status | 200 ordered by name with optionCount and activeOptionCount, meta: {}; 400 | test/e2e/catalog-modifier-groups.e2e-spec.ts › lists the groups by name with their option counts, filters by status and refuses an unknown status |
Add an instruction and a variant option to the set; a code repeated in the set; a code held by an archived option of the group; a stale If-Match | 200 in the order sent, existing ids kept, one catalog.modifier_group.updated with catalog.modifier_group.option_set before and after; 400 pointing at options[3].code; 409 · catalog.code_taken pointing at the option; 412 | test/e2e/catalog-modifier-options.e2e-spec.ts › adds an instruction and a variant option to the set in the order sent, refuses a code repeated in the set or held by another option in any case, and honours If-Match; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › locks the version then the group, asks about the plan, share-locks the new variants, then writes the profile, the changed and the new options and one record; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › points at the id of the group never had, at a code another option holds and at a target an option never changes; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › refuses a code without an id whose option another entry keeps by its id; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › keeps the stored code of an option sent back without one and derives a free code for a new one, never one taken by an archived option |
| A new option on a variant of another organization, an unknown one or an inactive one | 404 · catalog.not_found, 404, 422 · catalog.variant_not_active, each pointing at options[i].variantId; nothing written | test/e2e/catalog-modifier-options.e2e-spec.ts › refuses a new option pointing to a variant of another organization or an unknown one with 404, and to an inactive variant with 422, at the option, writing nothing; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › points at a new option whose variant is not active after share-locking it, writing nothing; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › judges the variant of new options only: unknown is not found, anything but active is not active |
Edit name and quantity in the set; the same values again; another kind or variant for a kept option; an option id of another group; a status, an isDefault, a zero quantity or a kind without its target | 200, one catalog.modifier_group.updated; 200 without a record; 422 · catalog.modifier_option_target_fixed pointing at options[i].kind or .variantId; 404 · catalog.not_found pointing at options[i].optionId; 400 | test/e2e/catalog-modifier-options.e2e-spec.ts › edits the name and the quantity of an option writing only real changes, and refuses a target change, an option of another group and malformed entries; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › points at the variant of an option that keeps its kind but names another variant; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › writes only the rows whose stored values differ, new ones included |
| Deactivate, reactivate, archive and restore an option; repeats; reactivate an archived option; an option left out of the set, then its code sent again; a suspended option | 200 with { optionId, status } and the group tag, the group and the other options keep their status; 422 · catalog.transition_invalid; left out it is archived with its id, sent again it returns active with the same id; suspended: true makes it inactive | test/e2e/catalog-modifier-options.e2e-spec.ts › deactivates, reactivates, archives and restores an option without touching its group or the other options, answers lean with the tag of the group and repeats without a record, and lets the set edit an option the transition restored; test/e2e/catalog-modifier-options.e2e-spec.ts › archives an option left out of the set with its id, brings it back with the same id when its code is sent again, and suspends one without the plan; test/e2e/catalog-modifier-options.e2e-spec.ts › brings an archived option back by its id with the same id and the status sent, and asks a lapsed plan for it because it is more than a withdrawal; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › takes back an option by its code ignoring case when no id is sent, archived ones included, and makes a suspended one inactive; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › names the options that stop being sellable, never one that was archived or stays active; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › lets a lapsed plan suspend and archive options, removing the defaults of the options leaving sale, never asking about the plan |
Deactivate or archive an option two items preselect; deactivate a group, or the variant a catalog_variant option sells; reactivate the option | the option leaves defaultOptionIds of every offer in the same transaction, both item tags change, the record names how many offers lost it (catalog.modifier_group.offer_defaults_removed, catalog.item.variant.offer_defaults_removed); reactivation never adds it back | test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › removes a deactivated option from the defaults of every offer, changes their tags, records how many offers lost it and never adds it back on reactivation; test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › removes the defaults of a deactivated group and of an option whose target variant is deactivated; src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts › an option %s removes it from every offer default right after its status and records how many offers lost it; src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts › reactivating an option never touches the defaults, and an option no offer preselected records no removal; src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts › a group deactivation removes the defaults of its options and records it; src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts › a variant deactivation removes the defaults of the options that sell it and records it on the item |
| A 51st option, archived options counted | 422 · catalog.modifier_group_full, no row, no record, the group tag unchanged | test/e2e/catalog-modifier-options.e2e-spec.ts › refuses a 51st option, archived options included, with catalog.modifier_group_full and writes nothing, not even a change record; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › refuses a 51st option, archived ones counted, with catalog.modifier_group_full before any variant lock or write |
| A set that adds or edits an option, or a transition of an option, of an archived group | 422 · catalog.modifier_group_archived, the group tag unchanged | test/e2e/catalog-modifier-options.e2e-spec.ts › refuses every option command on an archived group with catalog.modifier_group_archived and writes nothing; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › refuses an archived group whatever the set, writing nothing |
| Cashier, waiter, kitchen, bar, a member without a role and a manager of one unit, with existing and unknown ids, on every command | the same 403 · catalog.action_forbidden, one denied record each, nothing written | test/e2e/catalog-modifiers-access.e2e-spec.ts › refuses a cashier, a waiter, a kitchen and a bar member, a member without a role and a manager of one business unit every modifier command with the plain denial, with existing and unknown ids, one denied record each and nothing written; src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts › refuse a member without the manage permission with one denied record and no lock, on a group and on an option |
| The same members on the two reads, with an existing, unknown or no group and any unit; a manager of one unit with and without its unit | 403 · catalog.action_forbidden, nothing recorded; 200 at its unit, 403 without it | test/e2e/catalog-modifiers-access.e2e-spec.ts › answers the members without catalog.items.view the same plain denial on every modifier read, with an existing, an unknown or no group and any unit, records nothing, and lets a manager of one unit read at that unit only |
| A lapsed plan | create, a set that renames, adds or edits an option, reactivations and an offer that adds a group 403 · platform.capability_not_entitled; reads, a set that only suspends options, deactivations and archivals 200 | test/e2e/catalog-modifiers-access.e2e-spec.ts › refuses an organization whose plan lost the catalog to create, edit, add, reactivate and offer with the entitlement code, but lets it read, suspend options in the set, and deactivate and archive groups and options; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › asks about the plan when the set reorders or adds, and records the entitlement refusal without writing; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › asks about the plan when only the name changes, even while options are only withdrawn; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › calls a set that only suspends or archives options, keeping the order of the rest, a withdrawal |
| A group, an option or an item of another organization, alone or mixed with the caller's own ids | 404 · catalog.not_found, identical to an unknown id; the foreign group unchanged | test/e2e/catalog-modifiers-access.e2e-spec.ts › answers a group, an option and an item of another organization exactly like unknown ids on every command and read, and changes nothing there |
X-Organization-Id of an organization the caller does not belong to | 403 · tenant.context_rejected before any catalog lookup | test/e2e/catalog-modifiers-access.e2e-spec.ts › refuses a caller naming an organization it does not belong to before any catalog lookup |
No or a malformed Idempotency-Key on the group create (the option add is gone since API v7 PR 5); the same key and request again, also after a rename; another request; a taken code under another key; another organization | 400; 201, Idempotent-Replayed: true and the group as it is now, nothing written or recorded; 409 · idempotency.key_reused; 409 · catalog.code_taken; an independent 201, the key never answered or recorded | test/e2e/catalog-idempotent-creates.e2e-spec.ts › requires a UUID Idempotency-Key on every catalog create and writes nothing without one; test/e2e/catalog-idempotent-creates.e2e-spec.ts › replays the same key and request on every catalog create with 201, Idempotent-Replayed: true and the resource as it is now, even after a rename, writing and recording nothing; test/e2e/catalog-idempotent-creates.e2e-spec.ts › refuses a reused key with another request with idempotency.key_reused and a taken code under a new key with catalog.code_taken on every catalog create; test/e2e/catalog-idempotent-creates.e2e-spec.ts › keeps the creation keys of each organization apart and never answers or records a key on every catalog create; src/modules/catalog/application/modifiers/modifier-creations.use-case.spec.ts › replays a group created with the same key and request before counting the groups or writing, and records nothing; src/modules/catalog/application/modifiers/modifier-creations.use-case.spec.ts › refuses a group create that reuses a key with another request |
| Parallel retries of one key on the group create and on the option add; parallel requests that share a key | one group or one option, one record, the others 201 replayed; one 201, the others 409 · idempotency.key_reused; no 500 | test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts › creates one resource for parallel retries of one key on every catalog create and replays it to every other caller, with no 500 and one record; test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts › lets one of parallel creates that share a key with different requests win on every catalog create and refuses the others with idempotency.key_reused |
| A retry of the group create or the option add after the member lost the permission or the plan lost the catalog | the plain denial or the entitlement code, never the replay | test/e2e/catalog-idempotent-creates-access.e2e-spec.ts › answers a retry of every catalog create by a member who lost the permission with the plain denial, never the replay; test/e2e/catalog-idempotent-creates-access.e2e-spec.ts › answers a retry of every catalog create of an organization whose plan lost the catalog with the entitlement code, never the replay |
Which items offer a group: two items with their rules, position and defaults, one inactive; an item offering only another group; status; businessUnitId | 200, the items by name with itemId, code, name, status, minSelections, maxSelections, sortOrder and defaultOptionIds in the order of the group, Cache-Control: private, no-cache, no ETag, meta.pageInfo; the other item absent; only the items in that status; the same answer at the unit | test/e2e/catalog-modifier-group-offers.e2e-spec.ts › answers every item that offers the group by name with its code, status, rules, position and preselected options, private, never tagged, and nothing of the items that do not offer it; src/modules/catalog/infrastructure/modifiers/modifier-group-offers.queries.spec.ts › reads the first page of every live offer of the group, item-level or variant override but never an opt-out, by item name key and offer id, one row beyond the limit |
| A group no item offers; a deactivated, then archived group that items offer | 200 with an empty page and null cursors; 200 with its items, history kept | test/e2e/catalog-modifier-group-offers.e2e-spec.ts › answers an empty page for a group no item offers, and still answers the offers of a deactivated or archived group |
| Pages of three while other items start offering the group between the pages; a page back | no gap and no duplicate among the items that offered it first, in name order; the page before the second equals the first | test/e2e/catalog-modifier-group-offers.e2e-spec.ts › pages forward and back by name without a gap or a duplicate while other items start offering the group between the pages; src/modules/catalog/infrastructure/modifiers/modifier-group-offers.queries.spec.ts › continues after a position and walks back before one with the inverse order, keeping the status filter |
| A malformed limit, status, unit or group id, an unknown parameter, both cursors; a tampered cursor; a cursor of another group, status, unit or organization | 400 · foundation.validation_failed; 400 · collection.cursor_tampered; 400 · collection.cursor_incompatible | test/e2e/catalog-modifier-group-offers.e2e-spec.ts › refuses a malformed query, and a tampered cursor or one issued for another group, filter, unit or organization |
| Cashier and waiter (sellables only), kitchen, bar and a member without a role on the offers of an existing, an unknown and another organization's group, with any unit and any cursor | the same 403 · catalog.action_forbidden, decided before the cursor or any lookup, nothing recorded | test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts › answers a cashier, a waiter (who only see sellables), a kitchen and a bar member and a member without a role the same plain denial with an existing, an unknown and another organization group, any unit and any cursor, and records nothing; src/modules/catalog/application/modifiers/read-modifier-groups.use-case.spec.ts › refuses a member without the view permission plainly, for any group and unit, before decoding the cursor or looking anything up |
| A manager of one unit on the offers: at its unit; without a unit, at another unit of the organization, at an unknown unit or a foreign unit | 200, and 404 · catalog.not_found for an unknown group; the same plain 403 for every other unit, existing group or not | test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts › lets a manager of one business unit read at that unit only, never without it, at another unit of the organization, at an unknown unit or at a unit of another organization |
| The offers of an unknown group and of another organization's group, for an organization-wide reader; an unknown unit | the identical 404 · catalog.not_found; 404 · organization.structure_not_found | test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts › answers the organization-wide reader an unknown group and another organization group with the identical not found, and an unknown unit with the structure code; src/modules/catalog/application/modifiers/read-modifier-groups.use-case.spec.ts › answers not found for an unknown group without listing anything |
| The offers of a group on a lapsed plan | 200: reads never ask about the plan | test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts › keeps the read open to the owner of an organization whose plan lost the catalog |
| Where-used of a group that an item offers, one variant overrides and another variant opts out of (K9g) | the item offer with variantId: null and the override with its variantId, never the opt-out; a group only opted out of answers an empty page | test/e2e/catalog-variant-offer-route.e2e-spec.ts › lists the override of a group with its variant in the where-used read, and never an opt-out |
| A text option with a length, a prefix and a print hint switched off; the same set sent back (K14c) | 200; the option answers kind: "text", textMaxLength, prefix and printsOnReceipt: false, an option with defaults carries none of those keys, and the set sent back is already in effect (same body, same tag) | test/e2e/catalog-option-presentation.e2e-spec.ts › stores a text option with its length, prefix and print hints, answers them only when they differ from the default and keeps them when the set is sent back; src/modules/catalog/presentation/dtos/items/item-response.spec.ts › answers the kind, text length, prefix and print hints of an option only when they differ from the default |
| The prefix and the print hints of an instruction option, set and then cleared | 200 each time; cleared, the option answers no presentation key again and the tag moves with every real change | test/e2e/catalog-option-presentation.e2e-spec.ts › writes the prefix and the print hints of an instruction option and changes them later, one write moving the tag; src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › writes an option whose prefix or print hints changed and counts that as more than a withdrawal |
| A group created with a text option and an extra-sauce option | 201 with kind: "text" and maxQuantity 1, and the instruction with prefix: "extra" and maxQuantity 3 | test/e2e/catalog-option-presentation.e2e-spec.ts › creates a group whose options already carry a kind, a prefix and a text length; src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts › refuses an option whose kind, quantity, prefix and text length disagree before any write, naming the option and the rule |
A text option without a length or with two units or a prefix other than add; a length on an instruction; light with two units; no on a variant option | 422 · catalog.modifier_option_presentation_invalid listing each option by name and the rule at options[n].textMaxLength, .maxQuantity or .prefix, nothing written | test/e2e/catalog-option-presentation.e2e-spec.ts › refuses options whose rules disagree with 422, naming each option and the rule, and writes nothing; src/modules/catalog/domain/modifiers/option-presentation.spec.ts › asks light, no, on_the_side and substitute for a maximum quantity of one; src/modules/catalog/domain/modifiers/option-presentation.spec.ts › holds a text option to exactly one unit and to no prefix or the add prefix; src/modules/catalog/presentation/failures/option-set-refusal-failure.spec.ts › answers 422 for the presentation rule %s naming the option and the rule it breaks |
| A text length of 0, 501 or 1.5, a prefix outside the six, a print hint that is not a boolean, a text option that names a variant | 400 · foundation.validation_failed at options[n].textMaxLength, .prefix, .printsOnReceipt or the option | test/e2e/catalog-option-presentation.e2e-spec.ts › refuses a malformed presentation at the field with 400; src/modules/catalog/presentation/dtos/modifiers/modifier-request.schemas.spec.ts › refuses a text length outside 1 to 500, a prefix outside the six and print hints that are not booleans |
| Two writers of a text option sent at the same version | one 200 and one 412, the group holds one text option | test/e2e/catalog-option-presentation.e2e-spec.ts › lets one of two writers of a text option at the same version win and answers the other 412 |
| The database refuses a text option without a length, a prefix outside the six, a prefix that needs one unit with more, and a prefix that does not fit the kind | modifier_options_text_max_length_chk, modifier_options_prefix_chk, modifier_options_prefix_quantity_chk, modifier_options_prefix_kind_chk and modifier_options_text_shape_chk; an option written as before is an instruction with both hints on | database/tests/contracts/catalog-option-kind-contracts.ts › a text option that names a variant is refused; database/tests/contracts/catalog-option-kind-contracts.ts › an option written without the new columns is an instruction with no length, no prefix and both hints on |
C13 — Offer modifier groups on an item
PUT /catalog/items/{itemId}/modifier-groups with the ordered list of groups and their selection rule on this item; needs catalog.items.manage organization-wide, and the catalog.items capability unless the list only removes groups (owner decision: a lapsed plan can always detach); accepts the item's If-Match.
| Case | Expected answer | Proving test |
|---|---|---|
| A required single choice and an optional multiple choice, in order; the same list again; a stale tag; an empty list | 200 with the item and its modifierGroups[] (groups, rules and options), its new tag equal to the read's, one catalog.item.modifier_groups_set; 200 without a record; 412; 200 with no group | test/e2e/catalog-modifier-offers.e2e-spec.ts › offers a required single choice and an optional multiple choice in order, answers the item with its groups and options, records once and replays an identical list without a record; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › compares two offers in order, rules included; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › summarises an offer as its ordered offer ids with their groups, rules and statuses, or null when empty |
| A minimum above the maximum or above the units the available options of the group can reach (the sum of their maximum quantities, F1); a new inactive or archived group; a negative minimum, a zero maximum, a repeated group, a variant scope, 21 groups | 422 · catalog.selection_rule_invalid; 422 · catalog.modifier_group_not_active; 400; nothing written | test/e2e/catalog-modifier-offers.e2e-spec.ts › refuses a minimum above the maximum or above the active options, and a new inactive or archived group, writing nothing; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › refuses %s; src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts › refuse a required group whose available options cannot reach its minimum in units, replacing nothing |
| Two defaults on a burger whose maximum is one (the reproduced case); one default | 422 · catalog.default_options_invalid, the item tag unchanged; 200 with defaultOptionIds | test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › refuses two defaults on a burger whose maximum is one, writing nothing, and accepts one; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › refuses %s; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › accepts no default, or defaults that are available options of the group up to the maximum; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › judges the selection rule before the defaults; src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts › offers the defaults sent and refuses defaults beyond the rule before replacing anything |
| The same group with different defaults on two items; the same defaults again in another order | each item answers its own defaultOptionIds in the order of the group, recorded as offerId:groupId:min:max:status:sorted defaults; 200 without a write or a record, the same tag | test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › gives the same group different defaults on two items, answers them in option order, replays an identical list without a write and keeps them off the group; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › compares the defaults of two offers as a set and keeps an identical kept offer unjudged; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › summarises the defaults of a group after its status, sorted, only when it has some |
| A default that is unknown, of another group, of another organization or inactive; a repeated default; 51 defaults | the same 422 · catalog.default_options_invalid for all four; 400 · foundation.validation_failed; 400, since a group holds at most 50 options | test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › refuses as one identical 422 a default that is unknown, of another group, of another organization or inactive; src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts › accepts as many defaults as a group can hold options, and refuses one more |
| An extra whose target variant is active but whose item is archived or inactive, as a default | the same 422 · catalog.default_options_invalid: the command judges defaults with the structural activeOption of the till | test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › refuses to preselect an extra whose target variant is active but whose item is archived or inactive, as the till would not offer it; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › needs an active option whose target variant, when it has one, is active and belongs to an active item, the structural rule of the writes, which have no unit to judge listing for |
| One option chosen up to twice offered with a minimum and a maximum of two (F1); an extra worth two units whose target item is paused, not counted | 200; with the target paused, a changed rule whose minimum the options left cannot reach answers 422 · catalog.selection_rule_invalid and a reachable one 200 | test/e2e/catalog-unit-capacity.e2e-spec.ts › offers one option chosen up to twice with a minimum of two, sells the item everywhere and admits exactly two units; test/e2e/catalog-unit-capacity.e2e-spec.ts › counts no unit of an extra whose target item is paused, in the offer command as in every reader; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › accepts a minimum that the available options can reach through their quantities; src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts › hands the offer command every available option of a group with its maximum quantity, judged by the structural rule of the writes, which has no unit to judge listing for, so it can count units |
| A lapsed plan detaches a group while the kept group keeps its defaults; it only edits defaults | 200, the kept defaults unchanged; 403 · platform.capability_not_entitled (a change of defaults is a rule change) | test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts › lets a lapsed plan detach a group while the kept defaults stay, but gates a change that only edits defaults; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › never calls an offer shrinking when a kept group changes its defaults |
A default that starts above one unit on a multiple choice (defaultQuantities by option id); the same list again; the quantity lowered to one; a body without defaultQuantities (K14a) | 200 with defaultQuantities on the item and on the offers of the group, only while a default is above one unit; the identical list writes nothing and keeps the tag; a changed quantity moves the tag; the record shows o1*3 after the option id | test/e2e/catalog-offer-default-quantities.e2e-spec.ts › stores and answers the quantity on the item and on the group offers, replays it without a write and moves the item tag when it changes; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › counts a quantity change of a default as a change of the offer, so the item tag moves, and never as a shrinking one; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › writes the quantity of a default after its id, only when it is above one |
A quantity above the maximum of its option; quantities that add up to more than maxSelections (K14a) | 422 · catalog.default_quantities_invalid pointing at offers[i].defaultQuantities with a message that names the rules; the item tag unchanged; the defaults are judged first, so an unknown default is still catalog.default_options_invalid | test/e2e/catalog-offer-default-quantities.e2e-spec.ts › refuses %s with a 422 that points at defaultQuantities and writes nothing; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › refuses %s; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › judges the defaults before their quantities |
A quantity of 0, 1000 or 1.5; a key that is not an id; a quantity for an option that is not in defaultOptionIds (K14a) | 400 · foundation.validation_failed at offers[i].defaultQuantities.<optionId>, nothing judged | test/e2e/catalog-offer-default-quantities.e2e-spec.ts › refuses %s at the door with a 400; src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts › reads the quantity of a default from defaultQuantities, one to 999 per option, and none when it is absent |
| A lapsed plan changes the quantity of a default, lowering it included (K14a decision: never a shrinking edit) | the plan is asked and the write is refused; nothing is written | src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts › writes a default quantity that is the only change of an item list, asking about the plan because it is never a shrinking edit; src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts › compares the quantities sent with the stored ones, so repeating the stored quantity changes nothing and lowering it is a write |
An option limited in an offer (optionLimits by option id, 0 withholds it); the same list again; the limits lifted; a body without optionLimits (K14b) | 200 with optionLimits on the item and on the offers of the group, only while the offer limits an option; the identical list writes nothing and keeps the tag; changed or lifted limits move the tag; the record shows ;limits=o1=1+o2=0 after the status and the defaults | test/e2e/catalog-offer-option-limits.e2e-spec.ts › stores and answers the limits on the item and on the group offers, replays them without a write and moves the item tag when they change; src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts › writes the limits with the offer and records them in the audit of the list, sorted, after the status; src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts › answers the item as stored and records nothing when the limits sent are the stored ones, however they are ordered; src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts › writes the removal of every limit, so a list sent without limits offers its options fully again; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › compares the limits of two offers as a set and never mistakes an empty one for a change; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › writes the limits of an offer after its defaults, sorted, only when it has some |
| A limit equal to or above the maximum of its option; a limit on an option the group does not have (K14b) | 422 · catalog.option_limits_invalid pointing at offers[i].optionLimits.<optionId> with a message that names the option and its own maxQuantity; the item tag unchanged; a limit may only lower an option | test/e2e/catalog-offer-option-limits.e2e-spec.ts › refuses %s with a 422 that names the option and its maximum and writes nothing; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › refuses %s; src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts › refuses a limit %s, naming the option and its maximum, and writes nothing; src/modules/catalog/domain/modifiers/offer-list-judgement.spec.ts › points at the limited option of an offer it refuses, with the real maximum of that option; src/modules/catalog/presentation/failures/offer-list-refusal-failure.spec.ts › names the option and its real maximum when a limit would not lower it |
A limit of -1, 1000 or 1.5; a key that is not an id; a field next to optionLimits that the body does not know (K14b) | 400 · foundation.validation_failed at offers[i].optionLimits.<optionId>, nothing judged | test/e2e/catalog-offer-option-limits.e2e-spec.ts › refuses %s at the door with a 400; src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts › reads the limit of an option from optionLimits, zero to 999 per option, and none when it is absent; src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts › refuses an unknown field next to optionLimits, so a misspelt limit is never silently dropped |
| A lapsed plan changes only the limits of an offer, or lifts them (K14b: never a shrinking edit) | the plan is asked and the write is refused; nothing is written | src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts › asks about the plan for a limit that is the only change of an item list, because it is never a shrinking edit; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › counts a limit-only edit as a change that needs the plan, never as a shrinking one |
| A minimum above what the limited options can reach; every option limited to 0 on a required group; a default limited to 0; a default quantity above its limit (K14b) | 422 · catalog.selection_rule_invalid; 422 · catalog.default_options_invalid; 422 · catalog.default_quantities_invalid; the limits are judged first and the rule against the effective maximum of each option; a default quantity equal to its limit is accepted | test/e2e/catalog-offer-option-limits.e2e-spec.ts › judges the interplay with the minimum and the defaults against the effective maximum of each option; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › reaches a minimum through the effective maximum of the options, not through their own; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › counts nothing for an option limited to zero, so a group whose options are all limited cannot be required; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › refuses a default that is limited to zero in this offer; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › judges a default quantity against the effective maximum of its option; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › judges the limits before the selection rule; src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts › refuses a minimum the limited options can no longer reach and a default that is limited to zero |
| A stored limit that became moot because its option was lowered, or whose option left, sent back unchanged with another change of the rule; a limit that changed (K14b) | an unchanged limit is never judged again, so reading an offer and sending it back does not fail; a changed one is judged against the option as it is now | src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › keeps a limit that became moot or whose option left unjudged while the rest of the rule changes; src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts › judges again a kept offer whose limits changed, but not one whose limits are the same; src/modules/catalog/domain/modifiers/option-limits.spec.ts › leaves unjudged a limit that is already stored for the same option, even when it became moot or its option left |
| A variant override with limits of its own beside the limits of the item (K14b) | each list keeps its own limits; the item read answers optionLimits on the item offer and on the override of the variant, and a read of an offer without limits has no optionLimits key | test/e2e/catalog-offer-option-limits.e2e-spec.ts › gives each variant override its own limits, independent of the ones of the item; src/modules/catalog/presentation/dtos/items/item-response.spec.ts › answers optionLimits on an offer only when it limits an option, on the item and on a variant override |
| A group the item already offers, deactivated since, kept with its rule; a new group added beside it; an inactive item | 200; no status of the item or of the group changes | test/e2e/catalog-modifier-offers.e2e-spec.ts › keeps a group the item already offers after it was deactivated, judges only new groups and changed rules, and never changes the status of the item or the group; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › keeps a group already offered with the same rule even when it is inactive now or lost its active options; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › judges again the rule of a kept group whose rule changed, but never its status |
| A lapsed plan detaches one of two groups, then every group, and repeats a list; it attaches a group, makes a rule stricter or reorders | 200 with one record per real change, none for the repeat, never asking about the plan; 403 · platform.capability_not_entitled with one denied record each, asked after the item lock | test/e2e/catalog-modifiers-access.e2e-spec.ts › lets an organization whose plan lost the catalog detach one of two groups and then every group from an item, and repeat that list, while attaching a group or changing a rule answers the entitlement code with one denied record; src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts › let a lapsed plan drop groups from an item with the plain permission only, never asking about the plan; src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts › ask about the plan after the item lock when an offer adds a group, and record the entitlement refusal without writing; src/modules/catalog/domain/modifiers/modifier-offer.spec.ts › calls an offer shrinking only when it drops groups and keeps every other group with its exact rule, whatever its order |
| An archived item; a group or an item of another organization or unknown | 422 · catalog.item_archived; 404 · catalog.not_found; nothing written | test/e2e/catalog-modifier-offers.e2e-spec.ts › refuses an archived item, and an unknown or foreign group or item with 404, writing nothing |
| The restaurant journey: cooking point and extras offered on a burger, an option edited and another deactivated | every read carries the groups in order with their options; the tag changes twice and the first one stops matching | test/e2e/catalog-modifiers-journey.e2e-spec.ts › creates the cooking point and the extras, offers them on a burger, reads the burger with its groups, sees its tag change after an option edit and a deactivation, and the old tag stop matching |
| A required offer retired by the command (K9b) and another suspended in SQL (the route writes no suspension yet) | the retired one is ignored: the item stays in the till lists, the scan and resolve, without that group; the suspended one hides the item with required_modifier_unsatisfiable | test/e2e/catalog-till-parity.e2e-spec.ts › keeps an item sellable after its required group is retired, but not after it is suspended; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › ignores a retired offer before judging a required group, and keeps a suspended one as required but unavailable |
| A retired offer, in SQL; the group offered again on that item | absent from the item read and from the items that offer the group; 200, the group offered again; the status allows only active, inactive and retired, and a retired row keeps its link, so a second row for the same item or variant and group is refused | test/e2e/catalog-modifier-group-offers.e2e-spec.ts › leaves a retired offer out of the item read and out of the items that offer the group, and lets the item offer the group again; database/tests/contracts/catalog-offer-retired-contracts.ts › NINAKU_CATALOG_OFFER_RETIRED_OK |
| The maximum of an offered group edited; the offers reordered (K9b) | 200; each offer keeps its id, updated in place with its new rule or place; the record lists each offer as offerId:groupId:min:max:status; no statement deletes an offer or rewrites its identity, scope or group | test/e2e/catalog-modifier-offer-identity.e2e-spec.ts › keeps the offer id when its maximum changes; test/e2e/catalog-modifier-offer-identity.e2e-spec.ts › keeps every offer id when the offers are reordered; src/modules/catalog/infrastructure/modifiers/item-offer.queries.spec.ts › never delete an offer of the %s scope: they retire the links left out and update the kept and revived ones in place; src/modules/catalog/infrastructure/modifiers/item-offer.queries.spec.ts › the %s never rewrites the identity, scope or group of an offer |
| A group added beside one already offered (K9b) | 200; only the new group gets a new offer id | test/e2e/catalog-modifier-offer-identity.e2e-spec.ts › gives a new id only to a newly offered group |
| A group left out of the list, then offered again (K9b) | 200; the offer is retired with the same id and no defaults, out of the item read, and the record ends with it as retired; offered again, the same id returns to active with the rules sent, with no second row | test/e2e/catalog-modifier-offer-identity.e2e-spec.ts › revives the same offer id when a retired group is offered again |
| An offer a Pricing modifier price references, edited then retired; an offer a Sales-shaped row references, retired (K9b) | both PUTs succeed; the price row still joins its offer, now retired with the edited rule; the sale row stays readable with its offer retired; the three foreign keys of Pricing, Commerce and Sales to the offers are all NO ACTION | src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts › edits and retires an offer that a pricing row references without breaking the reference; src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts › retires an offer that a sale row references and keeps the row readable |
| The audit record fails after the offers were written (K9b) | 500; one transaction: the offers, their ids, the item tag and the audit trail are unchanged | test/e2e/catalog-modifier-offer-identity.e2e-spec.ts › leaves the offers, their ids, the audit trail, the announced changes and the version untouched when the record fails after the write |
The runtime role deletes an offer, one nothing references or a retired one, or reaches it through a DELETE of its item, variant or group (K9c) | 42501; the offer keeps its row and id; retiring, reviving and editing it in place still work, and the defaults of a retired offer are still deleted | database/tests/contracts/catalog-offers-never-deleted-contracts.ts › NINAKU_CATALOG_OFFERS_NEVER_DELETED_OK |
| The published shape of an offered group | exactly the stored values, accepted by the strict schema | src/modules/catalog/presentation/dtos/items/item-response.spec.ts › answers the item, its variants, its categories and its modifier groups exactly as stored, in the published shape |
variantOverrides of PUT /catalog/items/{itemId}/modifier-groups (K9g, the variant route merged into it by API v7 PR 5): an override of an item group and of a group the item does not offer; its rule changed and reordered; left out; listed again with optedOut: true | 200 with the item, its tag equal to the read's; the override keeps its offer id in place; left out it is retired; listed again the same id returns as inactive with 0, 1 and no defaults whatever was sent; the item-level offer never changes; the record names the variant (catalog.item.variant) and lists offerId:groupId:min:max:status (catalog.item.variant.modifier_groups) | test/e2e/catalog-variant-offer-route.e2e-spec.ts › overrides and opts out per variant, keeps the override id while its rule or order changes, retires it when left out and revives the same id |
One PUT /catalog/items/{itemId}/modifier-groups with the item list and two variant lists; then the item list alone; then one variant with an empty list; that same body again | 200 with the item and the read's tag, one catalog.item.modifier_groups_set per changed list (three); without variantOverrides every variant keeps its overrides; the variant sent empty follows the item again while the variant not sent keeps its overrides; the repeat records nothing | test/e2e/catalog-variant-offer-route.e2e-spec.ts › sets the item list and the lists of the variants sent in one call, one record per changed list, leaves a variant not sent and every variant without variantOverrides untouched, and clears a variant sent with an empty list |
| A variant the item does not have, a rule no option can reach inside a variant list, an archived variant, eleven variant lists | 404 · catalog.not_found at variantOverrides[1].variantId; 422 · catalog.selection_rule_invalid at variantOverrides[0].offers[0].minSelections; 422 · catalog.variant_archived at variantOverrides[0].variantId; 400; nothing written | test/e2e/catalog-variant-offer-route.e2e-spec.ts › points at a variant the item does not have, an archived variant and a refused rule inside a variant list, writing nothing: the item list, every variant override and the audit trail stay as they were; src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts › takes at most 10 variant lists per request |
The same variant list again; a stale If-Match | 200 with the same tag and no record; 412 · foundation.precondition_failed, nothing written | test/e2e/catalog-variant-offer-route.e2e-spec.ts › answers the same list with 200 and no record, refuses a stale If-Match with 412 writing nothing, and tags the answer like the item read |
| An archived variant; a variant of another item; an unknown variant; an archived item (K9g) | 422 · catalog.variant_archived; 404 · catalog.not_found for both; 422 · catalog.item_archived; nothing written | test/e2e/catalog-variant-offer-route.e2e-spec.ts › refuses an archived variant, a variant of another item, an unknown one and an archived item, writing nothing |
The item route suspends an offer (suspended: true), then resumes it (K9g) | the same offer id becomes inactive, the item answers suspended: true; resumed, the same id is active again | test/e2e/catalog-variant-offer-route.e2e-spec.ts › suspends an item offer in place and resumes the same id through the item route |
| The audit record fails after the variant offers were written (K9g) | 500; one transaction: the variant offers, their ids, the item tag and the trail are unchanged | test/e2e/catalog-variant-offer-route.e2e-spec.ts › leaves the variant offers, their ids, the item tag and the trail untouched when the record fails after the write |
| The variant route without a session | 401 · identity.session_required before any lookup | test/e2e/catalog-variant-offer-access.e2e-spec.ts › refuses a request without a session before anything else |
| A cashier, a kitchen member, a member without a role, a manager of one unit, on existing and unknown targets (K9g) | the plain 403 · catalog.action_forbidden, nothing about the plan, one denied record each, nothing written | test/e2e/catalog-variant-offer-access.e2e-spec.ts › refuses a cashier, a kitchen member, a member without a role and a manager of one unit with the plain denial on existing and unknown targets, one denied record each and nothing written |
| An item, a variant or a group of another organization on the variant route (K9g) | 404 · catalog.not_found, like unknown ids; nothing changes there | test/e2e/catalog-variant-offer-access.e2e-spec.ts › answers an item, a variant and a group of another organization like unknown ones and changes nothing there |
| A lapsed plan: changes an override; opts a variant out, drops its overrides, suspends an unchanged item offer; then opts back in or resumes (K9g) | 403 · platform.capability_not_entitled with one denied record for the change; 200 without asking about the plan; 403 with one denied record each, the opt-out and the suspension still stored | test/e2e/catalog-variant-offer-access.e2e-spec.ts › lets a lapsed plan opt a variant out, drop its overrides and suspend an unchanged item offer, while a changed override, an opt-in from an opt-out or a resume answers the entitlement code with one denied record and changes nothing |
| The plan rule of withheld offers (K9g) | an opt-out or a drop on a variant is never gated; an override and an opt-in are; a suspension of an unchanged item rule is not, a resume or a suspension with a new rule is; a pure reorder stays gated | src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts › never gates an opt-out or a retirement on a variant, but gates an override and an opt-in; src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts › never gates suspending an item offer whose rule is unchanged, but gates resuming it and suspending a changed rule |
| An opt-out written by the variant route on a required item-level group (K9g, after K9g-0) | admit answers no offer for that variant and Sales stores its line with no selection, refusing a selection through the item offer; the inheriting variant keeps the minimum: admit answers selection_below_minimum and Sales refuses an empty line but stores the item offer | test/e2e/catalog-variant-offer-sales-parity.e2e-spec.ts › lets an opted-out variant sell with no selection in Catalog and in Sales, refuses the item offer on it, and keeps the item minimum on the other variant |
| A group deactivated before it was offered: suspended on the item, then resumed; opted out on a variant, then turned into an override (K9g review fix) | the suspension and the opt-out are stored (200, never need an active group); the resume and the override answer 422 · catalog.modifier_group_not_active and the row stays inactive, so no request sequence makes an inactive group an active offer | test/e2e/catalog-variant-offer-activation.e2e-spec.ts › refuses to resume on the item a suspended offer of a group deactivated before it was offered, and keeps it suspended; test/e2e/catalog-variant-offer-activation.e2e-spec.ts › refuses to turn into an override an opt-out of a group deactivated before, and keeps the opt-out |
The rule of every transition to active (K9g review fix) | a new offer, a resume from a suspension and an opt-in from an opt-out judge the group status and the rule again, even with the same rule; an active offer kept active with its rule is never judged again; a suspension or an opt-out never needs an active group | src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts › judges the group status and the rule again whenever an offer becomes active: resumed from a suspension or an opt-out, even with the same rule; src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts › never lets a group that is not active become an active offer in two requests, suspended or opted out first |
| An active item offer suspended and an active variant override opted out in place, then resumed (K9g) | the same rows persist inactive, then active again, ids unchanged; each scope upserts by the columns and predicate of its own partial unique index | src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts › updates in place an active item offer to suspended and an active variant override to opted out, persisting and reading back inactive, and back to active when resumed; src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts › upserts each scope by the columns and predicate of its partial unique index |
| The order of the offers command (K9g, one route since API v7 PR 5) | permission, catalog version, item lock, every variant sent judged (unknown 404, archived 422, pointing at variantOverrides[i].variantId), the plan only for a gated change, the groups of every changed list shared once in id order, each changed list written with one record, the read; a list sent as stored is neither judged nor written | src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts › writes an override after the permission, the version and the item, asking about the plan, and records the variant beside its lists; src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts › writes the item list and every changed variant list in one transaction, share-locking their groups once in id order, leaving unchanged and unmentioned lists untouched, one record per changed list; src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts › points at the offer it refuses inside the list it belongs to, writing nothing; src/modules/catalog/domain/modifiers/offer-list-judgement.spec.ts › points at the field of every offer it refuses, under the list it belongs to; src/modules/catalog/domain/modifiers/offer-list-judgement.spec.ts › points at a variant the item does not have and at an archived one |
| The statements of each scope (K9g) | the item statements touch only item_variant_id IS NULL, the variant ones only their variant, each upserts by its own partial unique index; none deletes an offer or rewrites its identity | src/modules/catalog/infrastructure/modifiers/item-offer.queries.spec.ts › confine every statement of a scope to that scope |
| Writing a variant scope in PostgreSQL (K9g) | ids kept in place, retired, revived, an opt-out stored inactive, the item-level rows and their defaults untouched | src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts › writes the offers of one variant in place, retires and revives them, writes an opt-out as inactive, and never touches the item-level offers; src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts › clears and writes the defaults of the variant offers only |
Barcodes
C11 — Set the barcodes of a variant
PUT .../variants/{variantId}/barcodes (API v7 PR 4, migration 0103) replaces the whole set under a required If-Match on the item tag. A barcode keeps its id; one left out is archived, never deleted; suspended keeps it inactive. A set that only suspends, archives or drops the primary never asks the plan. The value is unique among the active barcodes of the catalog (migration 0079).
| Case | Expected answer | Proving test |
|---|---|---|
| Set two barcodes, one suspended; replay the same set in another order; the same value on another item; another organization | 200 with the set and the item tag GET repeats, one catalog.item.barcodes_set with the live set before and after; the same tag, nothing recorded; 409 · catalog.barcode_taken at barcodes[1].barcode with no constraint name; 200 elsewhere | test/e2e/catalog-barcodes.e2e-spec.ts › sets the barcodes of a variant, answers the set with the item tag, records one record with the live set before and after, refuses the same value on another item and lets another organization use it |
| A barcode left out; its value on another variant; brought back while taken; brought back once freed | archived, absent from the set and the item read, the row kept; 200; 409 at barcodes[0].barcode; 200 with the same id, inactive when sent suspended | test/e2e/catalog-barcodes.e2e-spec.ts › archives a barcode left out, never deleting it, frees its value and brings it back with the same id when its value is sent again; src/modules/catalog/domain/items/barcode-set.spec.ts › brings an archived barcode back with its own id when its value is sent again, suspended as inactive |
| The primary swapped between two existing barcodes; a new barcode made primary while the former one is demoted | 200 each with exactly one primary: rows leaving active are written first, then rows staying or becoming active with the primary cleared, then the one primary, then the new rows, so the non-deferrable one-primary index never sees two | test/e2e/catalog-barcodes.e2e-spec.ts › swaps the primary between two existing barcodes, and makes a new barcode primary while the former primary is demoted, in one save each |
| Twenty barcodes replaced by twenty others; 21 | 200, the archived ones do not count; 400 · foundation.validation_failed | test/e2e/catalog-barcodes.e2e-spec.ts › does not count archived barcodes toward the 20 of a variant and refuses 21 at the schema; src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts › refuses 21 barcodes, a repeated value or two primaries before opening a transaction |
| A misfit symbology, a changed value, an unknown id, a suspended primary; a repeated GTIN, two primaries, a blank value | 422 · catalog.barcode_invalid, 422 · catalog.barcode_value_fixed, 404 · catalog.not_found, 422 · catalog.barcode_not_active, each at its entry; 400 at barcodes[i].barcode or .isPrimary; nothing written | test/e2e/catalog-barcodes.e2e-spec.ts › points at each wrong entry: a misfit symbology, a changed value, an unknown id, a suspended primary, a repeated GTIN and two primaries; src/modules/catalog/domain/items/barcode-set.spec.ts › points at each unknown id, changed value, misfit value and suspended primary; src/modules/catalog/presentation/dtos/items/variant-barcodes.schemas.spec.ts › points at the repeated GTIN, the repeated id and the second primary; src/modules/catalog/domain/items/barcode.spec.ts › refuses %s as %s |
| A lapsed plan suspends or archives; brings one back; an archived variant or item | 200 without asking the plan; 403 · platform.capability_not_entitled; 422 · catalog.variant_archived, 422 · catalog.item_archived | test/e2e/catalog-barcodes.e2e-spec.ts › suspends and archives on a lapsed plan, refuses bringing a barcode back there, and refuses an archived variant or item; src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts › suspends and archives barcodes on a lapsed plan without asking the plan, and refuses bringing one back |
| Archiving a variant | its active barcodes are deactivated in the same command with one catalog.item.barcode_deactivated record; the value can be added to another variant that the till and the back office then find; archiving again writes nothing | test/e2e/catalog-barcode-lookup.e2e-spec.ts › archiving a variant releases its active barcodes in the same command, so the value can be added to another variant that the till and the back office then find, and archiving again writes nothing |
| Archiving an item; restoring it | the active barcodes of all its variants are deactivated and recorded once; restoration reactivates none; the values can then be set on another item | test/e2e/catalog-barcodes.e2e-spec.ts › archiving an item releases the active barcodes of all its variants, and restoring it reactivates none |
| One GTIN in two encodings | a UPC-A is stored as its 13-digit EAN-13 form; the EAN-13 form on another variant 409 · catalog.barcode_taken; the till and the back office find it by the 12, 13 or 14-digit form | test/e2e/catalog-barcode-lookup.e2e-spec.ts › stores a UPC-A as its 13-digit EAN-13 form, refuses the same GTIN in the other encoding with catalog.barcode_taken, and finds the variant by either form at the till and in the back office; src/modules/catalog/domain/items/barcode.spec.ts › stores and looks up %s as the GTIN %s |
| Value unique among active barcodes only, per organization and catalog | two retired barcodes may share a value; a second active one is a unique violation | database/tests/contracts/catalog-barcode-contracts.ts › a second active barcode with the same value in one catalog is a unique violation |
| An archived barcode in SQL | the status check accepts archived and refuses any other status; its value can be active on another variant; only the active row is live for a scan; catalog.resolve_barcode answers the active variant of a value an archived barcode shares and nothing for a value only archived barcodes hold; the one-primary index reads active rows only | database/tests/contracts/catalog-barcode-contracts.ts › the status check accepts an archived barcode; database/tests/contracts/catalog-barcode-contracts.ts › the value of an archived barcode can be active on another variant; database/tests/contracts/catalog-barcode-contracts.ts › the barcode lookup resolves a value shared by an archived and an active barcode to the active variant; database/tests/contracts/catalog-barcode-contracts.ts › the barcode lookup finds nothing for a value only an archived barcode holds |
Two saves of one set with the same tag; no If-Match or * | one 200, one 412 · foundation.precondition_failed; 428 · precondition.required | test/e2e/catalog-barcodes.e2e-spec.ts › serializes two saves of the same set: the second, sent with the same tag, answers 412; test/e2e/catalog-required-preconditions.e2e-spec.ts › $route answers 428 without If-Match or with *, 412 with a stale tag and 200 with the current one, and a replayed list with its new tag answers 200 recording nothing |
| The plan, the change check and the audit | the item and version locked first, then the variant rows read, the values brought to life checked against other variants, one write, one record whose summaries come from the key the change check compares; an unchanged live set writes and records nothing | src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts › locks the item, reads the variant barcodes, checks the values it brings to life, writes the changes and records one record with the live set before and after; src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts › answers the stored item when the live set does not change, writing and recording nothing; src/modules/catalog/domain/items/barcode-set.spec.ts › summarizes the live barcodes with every compared field, in id order, ignoring archived ones |
| Statement budget | 15 statements to add, suspend and archive at once, the same for 1 and 10 barcodes (23 before the one-statement item read) | test/e2e/statement-budget.e2e-spec.ts › replaces the barcode set of a variant, adding, suspending and archiving at once, in the same statements whatever the number of barcodes |
| What the client sees for each refused entry | the catalog code, its status, meta.errors[] with the field and a message that says how to fix it; only the leading kind is listed | src/modules/catalog/presentation/failures/barcode-set-refusal-failure.spec.ts › answers %s with %i and %s, pointing meta.errors at the entry with a message that says how to fix it; src/modules/catalog/presentation/failures/barcode-set-refusal-failure.spec.ts › answers the kind that comes first when several are refused, listing only its entries |
C18 — Look up a barcode at an outlet
GET /catalog/outlets/{outletId}/barcode-matches?barcode=, needs catalog.sellables.view at the outlet; and the back-office GET /catalog/barcode-matches?barcode= with catalog.items.view. Both 200, Cache-Control: private, no-cache, no ETag, nothing recorded.
| Case | Expected answer | Proving test |
|---|---|---|
| A cashier scans a listed variant; unknown value, suspended or archived barcode, inactive variant or item, another organization's code | 200 with the variant and quantity policy; data: null for every other case | test/e2e/catalog-barcode-lookup.e2e-spec.ts › resolves a scanned barcode at an outlet to its sellable variant for a cashier, records nothing, and answers null for an unknown value, a suspended or archived barcode, an inactive variant, an inactive item and a barcode of another organization |
| An outlet no unit operates today; kitchen and a cashier of another outlet; an unknown outlet | data: null; the same plain 403 for existing and unknown outlets; 404 · organization.structure_not_found only for an organization-wide holder | test/e2e/catalog-barcode-lookup.e2e-spec.ts › answers null at an outlet no business unit operates today, and refuses a kitchen member and a cashier of another outlet with the same plain denial for an existing and an unknown outlet, while only an organization-wide holder learns that an outlet is unknown; src/modules/catalog/application/barcodes/look-up-barcode.use-case.spec.ts › checks catalog.sellables.view at the outlet before any lookup, so a refused caller never reaches the catalog |
| The back office finds a barcode whatever the variant status; unknown value; a cashier | 200 with item and variant; data: null; 403 · catalog.action_forbidden | test/e2e/catalog-barcode-lookup.e2e-spec.ts › finds the item and the variant of an active barcode in the back office whatever their status, answers null for an unknown or foreign value, and keeps it from a cashier |
| A missing, blank, too long or NUL value; an unknown parameter | 400 · foundation.validation_failed | test/e2e/catalog-barcode-lookup.e2e-spec.ts › refuses a missing, blank, too long or NUL-holding value and an unknown parameter with 400 at the till and in the back office |
| An item whose required group lost its last available option; the option reactivated | data: null like an unknown code, as the till lists leave it out; resolved again | test/e2e/catalog-till-parity.e2e-spec.ts › answers null to the scan of an item whose required group lost its last available option, exactly like the lists, and resolves it again once an option is reactivated |
| The scan and the sellables read on one table of cases | the same verdict for listed, delisted, inactive variant, satisfiable and unsatisfiable required group, inactive group, option, target variant and target item, and the contract agrees | test/e2e/catalog-till-parity.e2e-spec.ts › gives the scan, the sellables read and the sellable contract the same verdict for a listed and a delisted item, an inactive variant, a satisfiable and an unsatisfiable required group, and an inactive group, option, target variant or target item |
| Per-variant offers (K9f): a variant whose own required offer has no available option, and one that inherits a required item-level group with none, left out; a variant that opts out of that group, or overrides it, resolved | data: null for the unconfigurable variant only, the other variants of the item still scan, as the lists and the contract agree | test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › judge every variant and list its offers through the one effective-offer fragment: the till, the search, the scan, resolve and admit |
| Two sellable matches at one outlet | 409 · catalog.barcode_ambiguous | src/modules/catalog/application/barcodes/look-up-barcode.use-case.spec.ts › answers null without a match, an unknown outlet as structure not found and two matches as ambiguous |
| Two active barcodes with one value in the back office | 409 · catalog.barcode_ambiguous, ordered and bounded like the till | src/modules/catalog/application/barcodes/look-up-barcode.use-case.spec.ts › answers the single match, null without one, and two matches as ambiguous like the till |
| The scan issues an exact number of statements | 7, guards, BEGIN and COMMIT included | test/e2e/statement-budget.e2e-spec.ts › $route issues exactly $statements SQL statements |
| A retail journey: matrix, barcodes, scan, edit, deactivation | the till answers null for the deactivated variant while the back office still finds it | test/e2e/catalog-variants-journey.e2e-spec.ts › a shop owner builds a shirt as a size by colour matrix, labels two variants, a cashier scans one at the till, an edit changes the item tag, and a deactivated variant still resolves in the back office while the till answers null |
Point-of-sale reads
C17 — Read what an outlet sells
GET /catalog/outlets/{outletId}/sellable-items, needs catalog.sellables.view at the outlet, decided before the outlet is looked up; never gated by the plan; records nothing. 200 with a strong ETag c<catalog version>-s<structure version>-<business unit or none>-d<delivery fingerprint> (the fingerprint since A6, AC4), Cache-Control: private, no-cache and meta: { catalogVersion, truncated, omittedItems, media }; 304 on a matching If-None-Match, decided before any item is read.
| Case | Expected answer | Proving test |
|---|---|---|
| A default above one unit on the till read, then the maximum of its option lowered to one (K14a) | the offer answers defaultQuantities only while some default is above one unit; the quantity is never above what the option still allows; an offer without quantities has no defaultQuantities key | test/e2e/catalog-offer-default-quantities.e2e-spec.ts › shows the till the quantity of a default only when it is above one and never above what the option still allows; src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts › answers defaultQuantities on an offer and on an override only when a default starts above one unit |
| The till read of an offer with limited options, and of one without (K14b) | the offer carries optionLimits (option id to its limit, 0 meaning the option is not offered there) only for the available options a limit really lowers; an offer with none has no optionLimits key; the options of a group stay listed once with their own maxQuantity | test/e2e/catalog-offer-option-limits.e2e-spec.ts › carries the limits of each offer on the till and shows nothing when no option is limited; src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts › answers optionLimits on an offer and on an override only when it limits an option, and parses them as the wire contract; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › answers the limits of an offer only for the available options it really lowers, so the till carries nothing when nothing is limited |
| A required offer whose limited options can no longer reach its minimum, stored in SQL (K14b) | the variant is not sold: the till leaves the item out, admit refuses it with required_modifier_unsatisfiable; one keyed probe per offer, and one per option only in an offer that has limits, lowers the units the offer can reach | test/e2e/catalog-offer-option-limits.e2e-spec.ts › stops selling a variant whose required offer its limited options can no longer reach, in the till and in admit alike; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › lowers the units an offer can reach by the limits of the offer, one keyed probe per offer and one per option only in an offer that has limits, ignoring an option without a limit; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › reads the limits of an offer only when its group has some, asking once per statement, as a set, which groups of the organization do, one row each; src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › probes the limits of an offer only when its group has some, asking once per group of the item with a one-row probe |
| A sellable item with a named variant and the default one | name order, active variants with the quantity policy, categoryId, no codes, SKUs or prices; meta.catalogVersion equals the stored version | test/e2e/catalog-till-sellables.e2e-spec.ts › answers the sellable items in name order with their active variants and quantity policy, a strong tag and the catalog version it was read at |
| An inactive item, an archived item, an inactive variant of a sold item | the first two absent; the item stays with its other variants | test/e2e/catalog-till-sellables.e2e-spec.ts › leaves out an inactive item, an archived item and an inactive variant, and keeps an item while one variant stays active |
| A branch below an inactive ancestor; an item with no category | the branch leaves the tree, its descendants keep their status, its items sell with categoryId: null; reactivating the ancestor brings the branch back | test/e2e/catalog-till-sellables.e2e-spec.ts › hides a category branch below an inactive ancestor without touching its descendants, keeps its items selling with no category, and sells an item that has no category at all |
| Offered groups and their options | only available options (an inactive option, an inactive group, an inactive target variant are unavailable); defaults exactly as the offer; an optional group without options leaves the item; an item whose required rule cannot be met is omitted and counted in meta.omittedItems (no id or name) | test/e2e/catalog-till-sellables.e2e-spec.ts › offers only available options, preselects exactly the defaults of the offer, drops an optional group that lost its group or options, and leaves out an item whose required group can no longer be satisfied, counting it in meta.omittedItems |
| An extra whose target variant is active but whose item is archived or inactive | the option leaves the group and its preselection, the dish keeps selling; a required group left without options removes the item from the lists, the scan and the contract alike | test/e2e/catalog-till-sellables.e2e-spec.ts › drops an extra whose target variant is active but whose item is archived or inactive, and its preselection, while the dish keeps selling |
| The assortment of the unit operating each of two outlets | no row and all sell everything but exclusions; selected only listings; none and an inactive unit nothing | test/e2e/catalog-till-freshness.e2e-spec.ts › follows the assortment of the unit that operates each outlet: no row and all sell everything but an exclusion, selected sells only listings, none and an inactive unit sell nothing |
If-None-Match with the tag and its weak form; after a catalog write; after a write of another organization | 304, 304; 200 with a new tag and the next catalog version; still 304 | test/e2e/catalog-till-freshness.e2e-spec.ts › answers 304 to its own tag and to its weak form while nothing changed, 200 with a new tag and a newer catalog version after a catalog write, and ignores writes of another organization |
| 2,001 sellable items | 2,000 items and meta.truncated: true; the search pages through all 2,001 under one catalog version | test/e2e/catalog-till-freshness.e2e-spec.ts › answers at most 2,000 items with truncated true, and the search pages through every one of them |
Kitchen, bar, catalog.items.view alone, no role | the same plain 403 at an existing, unknown, closed and foreign outlet; nothing recorded | test/e2e/catalog-till-access.e2e-spec.ts › refuses a kitchen and a bar member, a holder of catalog.items.view alone and a member without a role the same plain denial on both till reads at an existing, an unknown, a closed and a foreign outlet, and records nothing |
| A cashier and a waiter scoped to their outlet | 200 there; the same plain 403 at another outlet of the same unit, an unknown, a closed and a foreign outlet | test/e2e/catalog-till-access.e2e-spec.ts › lets a cashier and a waiter scoped to their outlet read and search it, and refuses them another outlet of the same business unit, an unknown, a closed and a foreign outlet with the same plain denial |
| An unknown or foreign outlet for an organization-wide holder | 404 · organization.structure_not_found | test/e2e/catalog-till-access.e2e-spec.ts › tells only an organization-wide holder that an outlet is unknown or belongs to another organization, with 404 organization.structure_not_found on both reads |
| A lapsed plan | 200 for the owner and a cashier | test/e2e/catalog-till-access.e2e-spec.ts › keeps both till reads open on a lapsed plan for the owner and a cashier |
| An outlet no unit operates today | 200 with businessUnitId: null, empty lists, meta.truncated: false, meta.omittedItems: 0, a tag ending in -none; a manager anchored at a unit gets the plain 403 | test/e2e/catalog-till-access.e2e-spec.ts › answers an outlet no business unit operates today with an empty read and an empty page to a caller the permission admits, and the plain denial to a manager anchored at a business unit |
| A structure write: an outlet assigned to a unit, the unit renamed | 200 with a new tag to the previous If-None-Match, the catalog version unchanged | test/e2e/catalog-till-freshness.e2e-spec.ts › changes its tag after a structure write: an outlet assigned to a business unit, and the unit renamed, answer 200 to the previous tag while the catalog version stays the same |
| The outlet moves to another business unit between the pre-check and the read of a refresh (race) | one coherent state: the unit, the items and the structure and unit segments of the tag come from the statement that read the items, equal to the state before or the state after the move, never the items of one with the tag of the other; the pre-check tag is never answered 304 once the outlet moved | test/e2e/catalog-till-reassignment-race.e2e-spec.ts › answers either the state before the reassignment or the state after it, never the items and the unit of one with the tag of the other; test/e2e/catalog-till-reassignment-race.e2e-spec.ts › answers the state after the reassignment when it committed before the outlet was read; test/e2e/catalog-till-reassignment-race.e2e-spec.ts › never answers 304 to the tag of the state before the reassignment once the outlet moved; src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › builds the unit, the tag and the lists only from what the statement returned when the outlet moved after the outlet was read |
| A reader anchored only at the unit that used to operate the outlet, after the outlet moved to another unit | the plain 403 on the read and on the search; never the items of the new unit | test/e2e/catalog-till-reassignment-permission.e2e-spec.ts › refuses the read and the search once the outlet moved before the permission was decided |
| The outlet moves to another business unit between the permission and the read, for a reader anchored only at the old unit (race) | the permission and the statements that read the unit share one snapshot, so the answer never holds the exclusive items of the new unit, on the read, the search and the barcode scan | test/e2e/catalog-till-reassignment-permission.e2e-spec.ts › answers the old unit and its item, never the exclusive items of the new unit, when the outlet moves between the permission and the read; test/e2e/catalog-till-reassignment-permission.e2e-spec.ts › answers the old unit and its item to a search, never the exclusive items of the new unit, when the outlet moves between the permission and the read; test/e2e/catalog-till-reassignment-permission.e2e-spec.ts › answers no match for the barcode of an exclusive item of the new unit when the outlet moves between the permission and the scan; src/foundation/transactions/tenant-transaction-runner.service.spec.ts › opens a read-only repeatable-read transaction before applying the context when the work needs one snapshot |
| A refused member sends the owner's current tag | the plain 403, never 304, at another outlet and at an unknown one | test/e2e/catalog-till-access.e2e-spec.ts › answers a refused member who sends the owner's current tag the plain denial, never 304, at its outlet's neighbour and at an unknown outlet |
| The read issues an exact number of statements | 8 for 200 when no listed item or variant has an image (9 with images, AC4), 7 for 304, guards, BEGIN and COMMIT included | test/e2e/statement-budget.e2e-spec.ts › $route issues exactly $statements SQL statements |
If-None-Match compares weakly | the strong tag, its weak form, a list entry and * match | src/foundation/http/conditional-requests/conditional-request.spec.ts › compares weakly, so the strong tag, its weak form, a list entry and "*" all match |
| A restaurant journey | modifiers and defaults at the waiter's till; a unit manager's delisting removes the dish; a deactivated category leaves the tree while its dish sells | test/e2e/catalog-till-journey.e2e-spec.ts › a restaurant till loads its dishes with their modifiers and defaults, stops showing a dish its unit manager delists, and keeps selling the dishes of a category the owner deactivates under no category |
| Per-variant offers (K9f): an active variant offer replaces the item offer, an inactive one opts the variant out, a missing or retired one inherits; a required group without available options on one variant | only that variant leaves variants[], the item stays while another variant is configurable; the item-level modifierGroups are unchanged | test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › judge every variant and list its offers through the one effective-offer fragment: the till, the search, the scan, resolve and admit |
A required item-level group suspended (inactive, K9f), then resumed | every variant that inherits it leaves the lists, a variant whose active offer replaces it stays; all come back on resume | test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › hides every variant while a required item-level group is suspended and shows them again when it resumes; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › ignores a retired offer before judging a required group, and keeps a suspended one as required but unavailable |
| A required group whose options left still reach its minimum in units after another option is deactivated or archived (F1); its only option's maximum lowered below the minimum, then raised again; an extra whose target item is paused | the item stays listed, scanned, found and resolved; omitted from every reader and refused by admit with required_modifier_unsatisfiable, then sold again; the lowering itself is accepted (option writes do not re-judge offers) | test/e2e/catalog-unit-capacity.e2e-spec.ts › keeps an item sellable while the options left can still reach its minimum in units, hides it when they cannot, and refuses to offer that minimum again; test/e2e/catalog-unit-capacity.e2e-spec.ts › counts no unit of an extra whose target item is paused, in the offer command as in every reader; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › judges a required group satisfiable by the units its available options can reach, not by how many options it has |
| A variant with an active override, one with an opt-out, one that inherits (K9g, written by the variant route) | variants[].modifierGroupOverrides only on the first two (optedOut false with its rule and defaults, true for the drop); a group only an override offers joins the top-level modifierGroups | test/e2e/catalog-variant-offer-till.e2e-spec.ts › answers on the till list and the search the override and the opt-out of each variant, and the groups only an override offers; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › answer as the overrides of each listed variant exactly the rows that keep the item offer from it: its live rows, an inactive one as an opt-out |
admit on each of those variants (K9g) | exactly the rule the till shows: the item maximum on the inheriting one, the override's own maximum and required group on the overriding one, option_not_offered on the opted-out one | test/e2e/catalog-variant-offer-till.e2e-spec.ts › admits on each variant exactly the rule the till shows for it |
| An item with no override; a variant offer changed (K9g) | no modifierGroupOverrides key at all (zero bytes added); the tag moves and the old If-None-Match answers 200 | test/e2e/catalog-variant-offer-till.e2e-spec.ts › adds no byte to an item without overrides and moves the till tag when a variant offer changes |
| A suspended item offer; a variant row retired by the variant route; an override of a deactivated group (K9g) | the item modifierGroups leave the suspended offer out while the override stays; the retired row and the override with nothing to sell are not answered, a variant left with none carries no key; the overrides come from the same live-row fragment effectiveOffer uses | test/e2e/catalog-variant-offer-till.e2e-spec.ts › never answers a suspended item offer, a retired variant row or an override of a group with nothing to sell; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › matches every row of this variant that is not retired, active or inactive |
| The menus an outlet shows on its till (M2b): two active menus assigned in order | menus in the outlet order, each { menuId, name, sections }, a section { sectionId, name, entries, subsections }, a subsection without subsections, an entry { itemId } or { itemId, variantIds } for selected variants; the same answer for the owner and a member holding only catalog.sellables.view | test/e2e/catalog-till-menus.e2e-spec.ts › answers the two active menus the outlet shows, in its order, the same to the owner and to a member who only sees what the outlet sells |
| What a menu leaves out of the till (M2b): a draft and an archived menu; a suspended or retired section with its subsections; a suspended entry; an entry whose item is delisted at the unit or inactive; a selected variant that is inactive or not configurable | none of them; a section left without entries and without non-empty subsections, and a menu left without sections, are dropped | src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts › answers the active menus in the outlet order with their active sections, subsections and the entries the till sells, dropping what is left empty |
| The SQL against the menu availability rule of the domain (M2b) | the statement answers exactly what isAvailableEntry and isActivePart decide on the stored menus and the items of the same answer | src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts › agrees with the menu availability rule of the domain applied to the stored menus and the items of the same answer |
| A truncated list with menus (M2b) | an entry keeps an item that sells beyond the 2,000 answered; the till resolves it through C19 | src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts › keeps an entry whose item sells beyond a truncated list, so the till resolves it through the search |
| An outlet nobody assigned menus to (M2b) | menus: [] and every other key byte for byte as before M2b | test/e2e/catalog-till-snapshot.e2e-spec.ts › answers a till with a classified item offering a required group, a plain item, an item with two variants and a dessert exactly as the golden file holds it; src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts › reads only the menus of the outlet asked for: a sibling outlet of the same organization keeps its own and one without an assignment reads none |
| A sibling outlet of the same organization with its own menus; a third outlet without an assignment (M2b) | each outlet reads only its own menus; the third reads menus: [] while it still sells | src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts › reads only the menus of the outlet asked for: a sibling outlet of the same organization keeps its own and one without an assignment reads none |
| Stored positions that contradict names and creation order (M2b) | menus in the outlet positions, sections, subsections and entries in their stored order, never by name or id | src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts › follows the stored positions of the menus, sections, subsections and entries, not their names or creation order |
| A menu assigned, activated, reordered, a section renamed, a section suspended (M2b) | each moves the till tag; the If-None-Match read before answers 200 with the new tag and the new menus | test/e2e/catalog-till-menus.e2e-spec.ts › moves the till tag when a menu is assigned, activated, reordered, a section renamed or suspended, so a tag read before any of them no longer answers 304 |
| Statements with menus (M2b) | the same as without menus: 8 for 200, 7 for 304 | test/e2e/catalog-till-menus-statement-budget.e2e-spec.ts › reads what an outlet with two menus sells in the same statements as one without menus, and answers 304 in the same |
| Plan of the menus on stale statistics (M2b): 2,001 items, 10 menus of 1,000 entries, 3 active | no sequential scan of a menu tree table, no looped scan wider than 100 rows, no lookup wider than one section's 200 entries, at most 14 rows and 40 buffers per shown entry | src/modules/catalog/infrastructure/till/till-menus.plan.integration-spec.ts › reads the menu tables with statistics that miss the import; src/modules/catalog/infrastructure/till/till-menus.plan.integration-spec.ts › composes the menus of the outlet reading each section and entry by its key, never a whole table, and a bounded cost per entry it shows |
| Wire shape of the menus (M2b) | ids only; variantIds only on a selected entry; a nested subsection or an item field refused by the schema; the example accepted | src/modules/catalog/presentation/dtos/till/till-menus-response.spec.ts › answers the menu tree by ids, with variantIds only on an entry that offers selected variants; src/modules/catalog/presentation/dtos/till/till-menus-response.spec.ts › refuses a subsection inside a subsection and an entry carrying anything about the item; src/modules/catalog/presentation/dtos/till/till-menus-response.spec.ts › documents an example the schema accepts |
| A plain item next to a combo (B2c) | the same bytes as before the composition, without a bundleGroups key | test/e2e/catalog-till-combo-composition.e2e-spec.ts › answers a plain item byte for byte as before, without a bundleGroups key, next to a combo |
| A cashier, refused the back-office composition, reads a combo with a suspended group, a suspended choice, an inactive and an unlisted component, a variant-specific choice and an optional group left with nothing (B2c) | bundleGroups with the active groups and only the choices a sale accepts, in order, with the B1 field names; no key on the plain components | test/e2e/catalog-till-combo-composition.e2e-spec.ts › shows a cashier, who cannot read the back-office composition, the groups and choices a sale of the combo accepts, and no key on a plain item |
| A choice of a combo suspended (B2c) | the tag moves, the old If-None-Match answers 200 and the group leaves bundleGroups | test/e2e/catalog-till-combo-composition.e2e-spec.ts › moves the tag when the composition changes, so a till holding the old one reads the new choices |
Every live choice of a combo admitted alone by admit (B2c): suspended, inactive, unlisted, a required modifier suspended, an incomplete component combo, a component that needs a modifier, a variant-specific choice | the till shows exactly the choices admit accepts and hides every one it refuses | test/e2e/catalog-till-combo-composition-parity.e2e-spec.ts › shows every choice a sale of the combo accepts and hides every one it refuses: suspended, inactive, unlisted, unfillable, an incomplete combo and a suspended or empty group |
| Combos nested three levels deep, and a component combo that would need a fourth (B2c, F1b) | the same parity at every level; the deeper component hidden | test/e2e/catalog-till-combo-composition-parity.e2e-spec.ts › judges a nested combo three levels deep as the sale does, hiding a component combo that would need a fourth level |
| A selection that follows the components by reference into a fourth combo level (B2c) | the component carries bundleGroups; admit refuses the request too_deep | test/e2e/catalog-till-combo-composition-parity.e2e-spec.ts › lets the till see, by the bundleGroups key of a component, that a combo chosen at the third level cannot open a fourth: the sale refuses it as too deep |
| The till and the sellable rule judge a choice with one text (B2c) | choiceAvailable is what bundleComposable counts at each level and what both till statements carry | src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › is the very text bundleComposable counts a choice with at each combo level, so one rule decides which choices count and which the till shows; src/modules/catalog/infrastructure/till/till-bundle-groups.queries.spec.ts › is evaluated once per item and is all both till statements carry about a combo: its composability and its groups, with no second copy of the choice rule |
| A page of 50 combos holding combos three levels deep in the same page, under stale statistics (B2c) | every group with a choice and a three-level chain of combos in the page; no sequential scan, no looped scan wider than 100 rows, at most 350 rows and 1,100 buffers per requested item | src/modules/catalog/infrastructure/till/till-read.plan.integration-spec.ts › searches a page of fifty combos, each with its groups and the choices the sale accepts nested three levels deep, reading every group and choice by its key |
| An empty category, and one whose only items are hidden at the outlet, delisted from the unit, archived or inactive (OV25) | the category is not in categories | test/e2e/catalog-till-category-pruning.e2e-spec.ts › sends no category that holds nothing the outlet sells: an empty one, and one whose only items are hidden at the outlet, delisted from the unit, archived or inactive |
| A category with no item of its own and a sellable item in a descendant (OV25) | it is sent with the ancestors that lead to it; the siblings that hold nothing are not | test/e2e/catalog-till-category-pruning.e2e-spec.ts › sends a category with no item of its own when a descendant holds a sellable item, with the ancestors that lead to it and without the siblings that hold nothing |
| An item linked to a category as secondary only (OV25) | the category is not sent and the item keeps its primary category | test/e2e/catalog-till-category-pruning.e2e-spec.ts › counts the primary category only: a category reached by a secondary link alone is not sent and the item keeps its primary category |
| Two outlets of one unit, one hiding the only item of a category (OV25) | each outlet is judged on its own: the other still receives the category | test/e2e/catalog-till-category-pruning.e2e-spec.ts › prunes per outlet: the sibling outlet of the same unit keeps the category the first outlet hides |
| An item whose primary category is inactive (OV25) | it keeps selling with categoryId: null and the inactive category is not sent | test/e2e/catalog-till-category-pruning.e2e-spec.ts › keeps selling an item whose primary category is inactive with no category and does not send that category |
| The last sellable item of a category hidden after a read (OV25) | the tag moves and the earlier If-None-Match answers 200, never 304 | test/e2e/catalog-till-category-pruning.e2e-spec.ts › moves the tag when the last sellable item of a category is hidden, and the tag it had no longer answers 304 |
| The search of what an outlet sells (OV25) | carries no category tree and is not pruned; each item still names its primary category | test/e2e/catalog-till-category-pruning.e2e-spec.ts › leaves the search without a category tree, each item naming its primary category |
| The tree of a limited read (OV25) | the categories cover the whole outlet whatever the page of items | src/modules/catalog/infrastructure/till/till-read.categories.repository.integration-spec.ts › sends the categories that hold something the outlet sells across the whole outlet, whatever the page of items, and leaves out the empty ones and the folders with nothing below |
| A text option and an instruction option with a prefix in an offered group (K14d) | each option answers kind, and a text option textMaxLength, prefix and printsOnReceipt: false only when they differ from the default; an option with defaults carries none of those keys | test/e2e/catalog-text-selections.e2e-spec.ts › shows a text option on the till with its kind, length, prefix and a print hint switched off, and an option with defaults with none of those keys; src/modules/catalog/infrastructure/till/published-groups.spec.ts › flattens the presentation an option holds and leaves an option with defaults exactly as it was; src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts › answers the presentation of an option only when it differs from the default, and parses it as the wire contract |
C19 — Search what an outlet sells
GET /catalog/outlets/{outletId}/sellable-item-search?q=&limit=&after=&before=, the access rule of C17; the item shape of C17; keyset cursor bound to the route, the organization, the outlet, the unit operating it today and q; meta.pageInfo and meta.catalogVersion; no ETag.
| Case | Expected answer | Proving test |
|---|---|---|
Prefix of name, code or SKU, any case or accent; %, _ and a backslash; a delisted or inactive item | found; literal; an empty page exactly like an unknown name | test/e2e/catalog-till-search.e2e-spec.ts › finds by a prefix of the name, the code or a SKU ignoring case and accents, takes %, _ and a backslash literally, and never finds what the outlet does not sell, exactly like what does not exist |
| The SKU of an active, an inactive and an archived variant | the till finds the item only by the active one; the back-office list by all three | test/e2e/catalog-till-search.e2e-spec.ts › finds an item by the SKU of an active variant only, never by the SKU of an inactive or archived one, while the back office still finds all three |
A tampered cursor; one of another outlet, q or unit; both cursors, limit=101, empty or long q, barcode | 400 · collection.cursor_tampered; 400 · collection.cursor_incompatible; 400 · foundation.validation_failed | test/e2e/catalog-till-search.e2e-spec.ts › refuses a tampered cursor, a cursor of another outlet, another q or another business unit, both cursors and a limit above 100 with 400 |
| The outlet moves to another business unit while a page is read | the page is the page of the unit the outlet had when the read started, with a cursor that unit accepts, never a mix; the cursor is refused 400 · collection.cursor_incompatible on a later request, once the unit changed | test/e2e/catalog-till-search-reassignment-race.e2e-spec.ts › answers the next page of the business unit the outlet had when the page was read, with a cursor that unit accepts, when the outlet moves meanwhile, and refuses that cursor afterwards; test/e2e/catalog-till-search-reassignment-race.e2e-spec.ts › answers the first page of the business unit that operated the outlet when the page was read, never a mix with the new unit, when the outlet moves meanwhile; src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › refuses a cursor issued for the unit the outlet had when the statement read another one, and answers the unit of the statement otherwise |
| Items added while paging; paging back | no gap, no duplicate; meta.catalogVersion constant on a stable catalog and newer after a write | test/e2e/catalog-till-search.e2e-spec.ts › pages forward and back with no gap and no duplicate while items are added, and the pages of a stable catalog carry one catalog version that moves when the catalog changes |
| A unit that sells nothing | empty pages, with and without q | test/e2e/catalog-till-search.e2e-spec.ts › answers an empty page at a unit that sells nothing, before and after a listing mode change |
| The search issues an exact number of statements | 8 when no item of the page has an image (9 with images, AC4), guards, BEGIN and COMMIT included | test/e2e/statement-budget.e2e-spec.ts › $route issues exactly $statements SQL statements |
| A retail journey | the scan resolves the exact variant and a few letters find the same shirt | test/e2e/catalog-till-journey.e2e-spec.ts › a retail till scans a barcode to the exact variant and finds the same shirt by typing a few letters |
| Per-variant offers (K9f) | the search leaves out the same unconfigurable variants as the till, and the item while no variant is configurable | test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override |
| The overrides and opt-outs of each variant (K9g) | the same modifierGroupOverrides as C17 | test/e2e/catalog-variant-offer-till.e2e-spec.ts › answers on the till list and the search the override and the opt-out of each variant, and the groups only an override offers |
| A combo found by the search (B2c) | the same bundleGroups as the till read | test/e2e/catalog-till-combo-composition.e2e-spec.ts › answers the search with the same composition the till read carries |
| The search of an item whose offers limit options (K14b) | the same optionLimits as the till read, in the same statement text | src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › carries the limits in the offers of the item and of each variant override of both till statements |
Assortment
C14 — Choose how a business unit's assortment works
PUT /catalog/business-units/{businessUnitId}/assortment with { mode } and GET /catalog/business-units/{businessUnitId}/assortment; needs catalog.assortment.manage at the unit (organization-wide or assigned to it), the read also catalog.items.view; the mode change is gated by catalog.items.
| Case | Expected answer | Proving test |
|---|---|---|
A unit nobody configured (since D1, the active all row provisioning stored for the first unit) | 200, mode: all, listedCount the active items, a strong ETag, private, no-cache; 304 for the tag in its strong and weak form; nothing written | test/e2e/catalog-assortment.e2e-spec.ts › answers a unit nobody configured as selling every active item, with a strong ETag, private, no-cache, and 304 for its tag in the strong and the weak form, writing nothing |
| The first unit of an organization, or a unit registered with verticals that already run in another unit (D1) | the row stored when the registration commits reads all; its tills sell every active item | test/e2e/business-unit-default-assortment.e2e-spec.ts › sells the whole catalog at the first unit of the organization; test/e2e/business-unit-default-assortment.e2e-spec.ts › sells the whole catalog at a unit registered with a vertical another unit runs, at its tills too |
| A unit registered with a vertical no other unit runs, or with none (D1) | none; its tills sell nothing until a curator chooses the mode, and then follow it | test/e2e/business-unit-default-assortment.e2e-spec.ts › sells nothing at a unit registered with a vertical no other unit runs until a curator chooses its assortment, and its till follows |
| A vertical turned on after the registration committed (D1) | the stored default stays; nothing recomputes it | test/e2e/business-unit-default-assortment.e2e-spec.ts › sells nothing at a unit registered without verticals, and a vertical turned on later leaves the stored assortment |
| A mode chosen right after registering (D1) | all, selected or none replaces the default | test/e2e/business-unit-default-assortment.e2e-spec.ts › keeps the %s mode a curator chooses right after registering, whatever the default was |
| Another organization running the vertical (D1) | never counts: the unit starts at none | test/e2e/business-unit-default-assortment.e2e-spec.ts › never counts the units of another organization as running the vertical |
Switch to selected with nothing listed; the same mode again | 200, listedCount: 0, a new tag, private, no-store, one catalog.assortment.mode_changed with the mode before and after; the repeat answers the same tag, no record, no Idempotent-Replayed | test/e2e/catalog-assortment.e2e-spec.ts › switches a unit to selected with nothing listed, answering listedCount 0 with a new tag and private, no-store, records the mode before and after once, and answers the same mode again with the same tag and no record; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › answer the same mode again with nothing written or recorded |
A stale or weak If-Match | 412 · foundation.precondition_failed, nothing written; the current tag is accepted | test/e2e/catalog-assortment.e2e-spec.ts › refuses a stale or weak If-Match with 412 and writes nothing, and accepts the current tag; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › refuse a stale If-Match after the version lock and the stored mode, writing nothing, and accept the tag naming the version, the mode and the unit status |
| The tag the unit had before a mode switch; another organization writing its own catalog | 200 with the new mode and a new tag; 304 while only the other organization writes | test/e2e/catalog-assortment.e2e-spec.ts › answers 200 for the tag a unit had before its mode switched, and keeps answering 304 while another organization writes its own catalog |
| The unit is deactivated, then reactivated | 200 with listedCount 0, then back, and a different tag each time, never 304 for the tag it had (the tag names the mode and the unit status) | test/e2e/catalog-assortment.e2e-spec.ts › answers a new tag and the new count once the unit is deactivated or reactivated, never 304 for the tag it had |
| How each mode is stored and read | no row and an active all row read all, an active selected row selected, an inactive row none; none keeps the mode it had | src/modules/catalog/domain/assortment/assortment.spec.ts › reads %s as %s; src/modules/catalog/domain/assortment/assortment.spec.ts › stores all and selected as an active row of that mode; src/modules/catalog/domain/assortment/assortment.spec.ts › stores none as an inactive row that keeps the mode it had, all without a row; src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts › stores the mode of a unit once and later changes only its mode and status |
| A lapsed plan | the mode change 403 · platform.capability_not_entitled; reading and delisting still 200 | test/e2e/catalog-assortment-access.e2e-spec.ts › keeps reading and delisting open on a lapsed plan, refuses setting the mode and listing with platform.capability_not_entitled, and never tells a manager of another unit about the plan |
| An inactive unit | the mode change and a listing 422 · authorization.place_not_operational; an organization-wide holder still reads and delists; a manager assigned to it 403 · authorization.scope_unavailable | test/e2e/catalog-assortment-access.e2e-spec.ts › refuses to set the mode or list at an inactive unit with authorization.place_not_operational, still lets an organization-wide holder read and delist there, and answers a manager anchored at it that its place is unavailable |
C15 — List or delist items at a business unit
POST /catalog/business-units/{businessUnitId}/assortment/listing-changes with { list, delist } (up to 200 each, at least one item, never one in both); gated by catalog.items only when list names an item (API v7 PR 7; until then .../listings and .../delistings with { itemIds }).
| Case | Expected answer | Proving test |
|---|---|---|
List and delist in selected mode | 200 with listedCount; one record per call naming the items that changed; a repeat records nothing | test/e2e/catalog-assortment.e2e-spec.ts › lists and delists in selected mode, records once per call the items that changed, and records nothing for a repeat; src/modules/catalog/domain/assortment/assortment.spec.ts › lists in selected mode by inserting a missing inclusion and reactivating an inactive row; src/modules/catalog/domain/assortment/assortment.spec.ts › delists in selected mode only by deactivating an inclusion, since an item without a row is not listed |
Delist in all mode and list back | an exclusion row, then lifted; the item status and every other unit untouched; an archived item may be delisted | test/e2e/catalog-assortment.e2e-spec.ts › delists in all mode by excluding the item and lists it back, never changing the item status nor what another unit sells, archived items included; src/modules/catalog/domain/assortment/assortment.spec.ts › delists in all mode by inserting a missing exclusion and deactivating an active row; src/modules/catalog/domain/assortment/assortment.spec.ts › lists in all mode only by lifting an exclusion, since an item without a row is already listed |
| Only what changes is written | missing rows inserted, others change only their status; nothing when every item is already in the requested state | src/modules/catalog/domain/assortment/assortment.spec.ts › writes nothing when every item is already in the requested state; src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts › inserts the missing listings and only changes the status of the others, while the assortment guard keeps the business unit row locked until commit; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › answer a curation that changes nothing with nothing written or recorded |
| 200 items in one call | 200, one record naming all of them | test/e2e/catalog-assortment.e2e-spec.ts › lists 200 items in one call, one record naming them all |
No item, 201 items in a list, a repeated item, an item in both lists, a malformed id, the former itemIds, an unknown mode | 400 · foundation.validation_failed pointing at the field, such as delist[0] | test/e2e/catalog-assortment.e2e-spec.ts › refuses a change naming no item, more than 200 items in a list, a repeated item, an item in both lists, a malformed id and an unknown mode with 400, pointing at the field; src/modules/catalog/presentation/dtos/assortment/assortment.schemas.spec.ts › points at the delisting of an item the same change lists |
| List and delist in one change; the former routes | 200; one items_listed and one items_delisted record, each only when its direction changed; 404 for .../listings and .../delistings | test/e2e/catalog-assortment.e2e-spec.ts › lists and delists in one change, keeps one record per direction that changed, and no longer serves the former listings and delistings routes; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › list and delist in one change: ask about the plan once, share every item in one read, write each direction once and keep one record per direction that changed; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › record only the direction that changed when the other one finds every item already in place |
| A change with an archived item to list or an unknown item to delist; a refused change | the whole change refused, nothing written; the refusal recorded under items_listed when list names an item, else items_delisted | src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › refuse a whole change when an item to list is archived or an item to delist is unknown, writing nothing; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › record a refused change that lists under the listing action and a delisting-only one under the delisting action |
An archived item listed; any curation while the mode is none | 422 · catalog.item_archived; 422 · catalog.assortment_disabled; nothing written; the listings come back with the mode | test/e2e/catalog-assortment.e2e-spec.ts › refuses listing an archived item with catalog.item_archived and any curation while the unit sells nothing with catalog.assortment_disabled, writing nothing, and keeps the listings when the unit is switched back on; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › refuse listing an archived item, an unknown item and any curation while the unit sells nothing, writing nothing |
A forged organizationId or businessUnitId in the body; an item of another organization | 400; the same 404 · catalog.not_found as an unknown item, the whole call refused | test/e2e/catalog-assortment-access.e2e-spec.ts › takes the unit only from the path: refuses a forged organizationId or businessUnitId in a body, and answers an item of another organization exactly like an unknown item, writing nothing; src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts › shares no item of another organization and no unknown item |
The till follows the rows: selected without the listing, none, all, a delisting at one unit | data: null at that unit's outlet while another unit's outlet resolves; nothing in none; everything in all; another unit unaffected | test/e2e/catalog-assortment-till.e2e-spec.ts › answers null at the till of a unit in selected mode that has not listed the variant, while the till of a unit that sells everything resolves it, and resolves it there once listed; test/e2e/catalog-assortment-till.e2e-spec.ts › answers null at every till of a unit that sells nothing, resolves at a unit in all mode, and a delisting at one unit never changes what the till of another unit finds |
C16 — Read a business unit's assortment
GET /catalog/items?businessUnitId=&listed=true|false (C2 with the assortment filter) and the count of GET .../assortment (C14); the workspace answers the mode, curate and the OD5 warning.
| Case | Expected answer | Proving test |
|---|---|---|
listed=true and listed=false in all, selected and none mode, paged | the items the unit sells and the others; the cursor binds listed (400 · collection.cursor_incompatible across) | test/e2e/catalog-assortment-reads.e2e-spec.ts › lists the items a unit sells with listed=true and the others with listed=false, following its mode, and pages them with a cursor bound to that filter |
listed agrees with catalog.listed_items and catalog.variant_is_listed | the same items for no row, all, selected and turned off, at an active and an inactive unit, with a listing, an exclusion and no row; listed=false is the rest | src/modules/catalog/infrastructure/reads/listed-filter.integration-spec.ts › agrees with catalog.listed_items and catalog.variant_is_listed for every mode, unit status and listing state |
listed in OpenAPI and in the request | the closed choice true or false; anything else, or listed without a unit, 400 | src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts › documents listed as the closed choice of true or false; src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts › accepts only true or false for listed, and only with a business unit |
listed without businessUnitId; a manager of one unit | 400; 200 at its unit, 403 at another | test/e2e/catalog-assortment-reads.e2e-spec.ts › refuses listed without a business unit, and lets a manager of one unit filter only at that unit |
listedCount | exactly what catalog.listed_items admits: every active item without a row, only listings in selected, nothing when off | src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts › counts what a unit sells exactly as catalog.listed_items: every active item without a row, only the listings in selected mode, nothing when turned off |
| OD5 warning only to an organization-wide curator | a manager of one unit and a reader of the whole catalog get []; an organization-wide holder of catalog.assortment.manage gets units_with_different_verticals | test/e2e/catalog-assortment-reads.e2e-spec.ts › tells only an organization-wide curator that units run different verticals, never a reader assigned to one unit nor a reader of the whole catalog |
The workspace offers curate | to the owner and to a manager of that unit on the current plan; not to a mere viewer nor on a lapsed plan; nothing recorded | test/e2e/catalog-assortment-reads.e2e-spec.ts › offers curating the named unit only to a caller who may curate it on the current plan, and records nothing |
| OD5: active units with different verticals | warnings: ["units_with_different_verticals"]; none for the same verticals or once the other unit is inactive | test/e2e/catalog-assortment-reads.e2e-spec.ts › warns in the workspace when the active business units of the organization run different verticals, and not when they run the same ones; src/modules/catalog/application/reads/workspace-verticals.spec.ts › asks Organization for the active units, then Platform for the verticals of exactly those units; test/e2e/catalog-workspace-parity.e2e-spec.ts › answers the same catalog, counts and warnings the single statement read before K10b, through every mix of units and verticals, and the same body on a second read a second later, its page cursors bound to the same keyset position and still accepted by GET /catalog/items |
Internal contract
C20 — Resolve and admit a sellable variant
CatalogSellableContract (no HTTP route): resolve(context, { businessUnitId, variantIds }) and admit(context, { businessUnitId, lines }) in the caller's tenant transaction, proven through a consumer module that sees only the contract (test/fixtures/catalog-sellable/).
| Case | Expected answer | Proving test |
|---|---|---|
| A sellable variant at its unit | sellable: true, refusal: null, the snapshot (item and variant names and codes, SKU, sales unit and quantity policy, the effective offers with their id, source, available options, each target's sales unit and defaults) and the catalog version C17 answers | test/e2e/catalog-sellable-contract.e2e-spec.ts › resolves a sellable variant into its snapshot, with its effective offers, their available options and defaults, and the catalog version it was read at; src/modules/catalog/application/sellable/catalog-sellables.spec.ts › answers a verdict per requested variant in the requested order, with the catalog version, in one read and without any lock |
| A variant of another organization, an unknown or a malformed one; a unit of another organization, an unknown, an inactive or a malformed unit | unknown with no snapshot, identical for all three; business_unit_unavailable for all four units, the variant's own snapshot kept | test/e2e/catalog-sellable-contract.e2e-spec.ts › answers another organization variant exactly like an unknown one, and another organization unit, an unknown, an inactive and a malformed unit as the same unavailable unit; src/modules/catalog/application/sellable/catalog-sellables.spec.ts › answers a malformed variant as unknown and a malformed unit as no unit, without sending either to SQL |
| An archived or inactive item, an archived or inactive variant, a unit that sells nothing, a delisted item, an item not selected, an unsatisfiable required group | item_archived, item_inactive, variant_archived, variant_inactive, assortment_none, not_listed, not_listed, required_modifier_unsatisfiable, the first failing fact in that order | test/e2e/catalog-sellable-contract.e2e-spec.ts › names why a variant is not sellable: archived or inactive item, archived or inactive variant, assortment none, not listed and an unsatisfiable required group; src/modules/catalog/domain/sellable/sellability.spec.ts › answers no refusal when the shared rule says sellable, and unknown when the variant was not found; src/modules/catalog/domain/sellable/sellability.spec.ts › names the first failing fact in a fixed order: unit, item, variant, assortment, listing, required groups, combo groups; src/modules/catalog/domain/sellable/sellability.spec.ts › answers not listed when the shared rule refuses for a reason no diagnostic column names |
| The verdict is the shared rule of the till lists and the scan | the same fragment inside the statement; the scan, the sellables read and the contract agree on every case of the rule, the required group lost to an inactive option, group or target variant included | src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts › judge sellability with the one shared rule of the till lists and the scan, for the requested variants at the requested unit; src/modules/catalog/domain/sellable/sellability.spec.ts › trusts the shared rule over every diagnostic column, so a sellable verdict is never refused; test/e2e/catalog-till-parity.e2e-spec.ts › gives the scan, the sellables read and the sellable contract the same verdict for a listed and a delisted item, an inactive variant, a satisfiable and an unsatisfiable required group, and an inactive group, option, target variant or target item |
| An admission with chosen options, the preselected default among them, and the same variant on two lines | every line admitted with its lineIndex, the snapshot and catalog version resolve answers, its quantity in the shortest decimal form and its effective offers holding its selections | test/e2e/catalog-sellable-offers.e2e-spec.ts › admits quantities and answers the effective offer identity and everything a sale line stores; src/modules/catalog/application/sellable/catalog-sellables.spec.ts › shares the locks before it reads, and answers every line with its index, its snapshot, its quantity and the effective offers holding its selections |
| An option of another item, an inactive option of an offered group, fewer or more units than an offer allows, an archived item, a line quantity off policy, an option above its maximum | CatalogNotSellableError with every refusal per line: option_not_offered, option_unavailable, selection_below_minimum, selection_above_maximum, item_archived, quantity_out_of_policy, option_quantity_above_maximum; nothing admitted | test/e2e/catalog-sellable-contract.e2e-spec.ts › refuses the whole admission, line by line, for an option of another item offer, an unavailable option, a count below the minimum or above the maximum, and an archived item; src/modules/catalog/application/sellable/catalog-sellables.spec.ts › refuses the whole admission with every refusal: a variant that is not sellable, an unknown one, an option not offered or unavailable, a broken selection count, a line quantity off policy and an option quantity above its maximum; src/modules/catalog/domain/sellable/sellability.spec.ts › accepts a selection inside every offered rule; src/modules/catalog/domain/sellable/sellability.spec.ts › refuses an option no offer of the item holds, and one an offer holds but that is not available; src/modules/catalog/domain/sellable/sellability.spec.ts › refuses a group chosen below its minimum or above its maximum, in offer order |
| No variants or lines, more than 100, a repeated variant, a repeated option on a line, more than 100 options on a line | CatalogSellableRequestInvalidError (empty, too_many, duplicate, too_many_options) before any SQL or lock | src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › rejects an empty request, more variants than the limit and a repeated variant before any SQL; src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › rejects no lines, too many lines, too many options on a line and a repeated option before any lock |
| A group judged on its units (K9d): three of one sauce in a group of maximum 3, then four; two cheeses and a bacon in a group of maximum 2 | admitted; selection_above_maximum; selection_above_maximum, as the Sales schema sums quantity | src/modules/catalog/domain/sellable/sellability.spec.ts › judges every group on the sum of the chosen quantities, as the sales schema does; test/e2e/catalog-sellable-quantities.e2e-spec.ts › admits an option chosen several times, judging the option on its maximum, each group on the sum of its units and the line on the sales policy of the variant |
| Cheese × 2 in a group of minimum 2 (K9d) | admitted with one selection { optionId, quantity: 2 }; one cheese selection_below_minimum | src/modules/catalog/domain/sellable/sellability.spec.ts › meets a minimum of two with one option chosen twice |
An option chosen more times than its maxQuantity (K9d) | option_quantity_above_maximum naming the option, then the group verdicts on the units | src/modules/catalog/domain/sellable/sellability.spec.ts › refuses a quantity above the option maximum, naming the option |
| A line quantity below the variant minimum, above its maximum or off its step counted from the minimum (K9d) | quantity_out_of_policy, the rule of the Sales order-line trigger (mod(quantity - minimum, step) = 0) | src/modules/catalog/domain/sellable/sellability.spec.ts › refuses a line quantity below the minimum, above the maximum or off the step of the variant |
| A selection quantity of 0, 1000, 1.5, negative or not a number; a line quantity that is zero, negative, not a decimal or with more than 6 decimals; an option twice in a line in any case (K9d) | CatalogSellableRequestInvalidError with invalid_quantity or duplicate before any lock or SQL | src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › rejects a selection quantity outside 1 to 999 or not an integer, and an option repeated in a line, before any lock |
| Parity with the Sales schema (K9d) | for ten selections, the rows Sales would store pass the real sales.validate_line_modifier_insert trigger and sales.assert_order_line_modifier_consistency exactly when admit admits them | test/e2e/catalog-sellable-quantities.e2e-spec.ts › agrees with the sales schema on every selection: the rows built from an admitted line pass the real modifier trigger and group check, and the rows of a refused one fail them |
| The answer carries the effective offer (K9e) | per line, every effective offer of the variant: itemModifierGroupId (the offer's stable id), modifierGroupId, source (item until K9f, variant since for an active variant override), min, max, the group name; per selection the option id, name, kind, quantity, target variant and its sales unit | test/e2e/catalog-sellable-offers.e2e-spec.ts › admits quantities and answers the effective offer identity and everything a sale line stores; src/modules/catalog/domain/sellable/admitted-offer.spec.ts › answers every effective offer of the line with its identity, its rules and the selections it holds, in request order; src/modules/catalog/contracts/catalog-sellable.contract.spec.ts › names every type its results carry, so a consumer never imports catalog/domain |
| Removed and reduced defaults (K9e) | an available default not selected is removed; one selected with less than its default quantity is reduced; a default no longer available is never named | src/modules/catalog/domain/sellable/admitted-offer.spec.ts › derives the removed and reduced defaults of each offer from the final selection; src/modules/catalog/domain/sellable/admitted-offer.spec.ts › never names an unavailable default, which the offer no longer lists among its options |
| A default with a quantity above one: chosen with less, with as much or more, left out; an unavailable one with a quantity (K14a) | reducedDefaults names the one chosen with less than its quantity (defaultQuantity, quantity); one chosen with as much or more is named nowhere; one left out is removedDefaults with its quantity as defaultQuantity; an unavailable one is never named and no preselection is added | test/e2e/catalog-offer-default-quantities.e2e-spec.ts › measures every default against its own quantity: admit names the one chosen with less, the one dropped and never adds a preselection; src/modules/catalog/domain/sellable/admitted-offer.spec.ts › measures each default against its own quantity: a lower selection reduces it, an equal or higher one keeps it, a missing one removes it; src/modules/catalog/domain/sellable/admitted-offer.spec.ts › never adds a preselection the line did not choose and ignores the quantity of an option that is no longer available |
resolve and admit of an offer that limits options (K14b) | each effective offer lists only the options it still allows: an option limited to 0 is absent, the others carry their effective maximum; choosing the excluded option is option_not_offered, more than the effective maximum option_quantity_above_maximum; a selection within the limit is admitted | test/e2e/catalog-offer-option-limits.e2e-spec.ts › answers resolve and admit per effective offer: an option limited to zero is not offered, the others are measured against their effective maximum; src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts › answers each effective offer with the options it still allows: an option limited to zero is absent and the others carry their effective maximum |
| An offer whose every option is limited to 0 (K14b) | the offer is not offered at all in the snapshot of resolve, and the other offers are kept as they are | src/modules/catalog/domain/sellable/admitted-offer.spec.ts › drops an offer whose options are all limited to zero, so nothing is offered where nothing can be chosen, and tidies the others |
| Parity with the Sales schema (K9e) | the rows built from the admitted answer alone (offer id, names, quantities, the unit of an extra) pass the real sales.validate_line_modifier_insert trigger and sales.assert_order_line_modifier_consistency | test/e2e/catalog-sellable-offers.e2e-spec.ts › builds sale rows from the admitted answer alone, offer identity, names, quantities and the unit of an extra, and the real sales trigger and group check store every one of them |
| The effective offer of each variant (K9f): wings ×6 inherits the item rule (max 2), ×25 has an active override (max 6), ×3 an inactive one | resolve and admit answer the item offer with source: item, the override's id with source: variant, and nothing for ×3; five units admitted on ×25, three refused on ×6 (selection_above_maximum), any sauce on ×3 option_not_offered; the Sales rows built from the ×25 answer pass the real trigger and group check, and fail with the item offer id | test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › resolves sauces per variant: inherited, overridden and opted out; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › lets an active variant offer replace the item offer, an inactive one opt the variant out, and a missing or retired one inherit the item offer |
| An opt-out of a required item-level group (K9f, X13 closed by K9g-0) | admit admits the variant with no selection and no offer; Sales agrees since migration 0090: it stores the line with no selection and refuses a selection through the item offer | test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › admits a variant that opted out of a required item-level group with no selection, and the sales schema agrees: it stores the line with no selection and refuses the item offer (X13 parity); database/tests/contracts/sales-variant-offer-contracts.ts › NINAKU_SALES_VARIANT_OFFER_OK |
| Per-variant sellability (K9f) | an unconfigurable variant is refused with required_modifier_unsatisfiable while its siblings are admitted, exactly as the scan, the till and the search | test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override; test/e2e/catalog-variant-modifier-offers.e2e-spec.ts › hides every variant while a required item-level group is suspended and shows them again when it resumes |
| A combo whose required group is suspended, or whose available choices cannot reach its minimum (B2b), and the same combo once the group can be completed | left out of the till list (not counted in meta.omittedItems), the search and the scan; resolve answers sellable: false with bundle_group_unsatisfiable; admit explains it with the group at its path (bundle_group_unavailable for the suspended group, bundle_group_unsatisfiable for the empty one) and never admits a line the rule leaves out; sold again everywhere once resumed. The SQL fragment (bundleComposable, a keyed anyRowOf probe, no EXISTS, inside sellableCandidate) answers what the domain isSatisfiable answers on the same rows; a catalog of 1,000 combos under stale statistics keeps the till plans within their row and buffer bounds | test/e2e/catalog-combo-till-parity.e2e-spec.ts › leaves out a combo whose required group is suspended or has no available choice everywhere, explains why without counting it as omitted, and sells it again once the group can be completed; src/modules/catalog/infrastructure/reads/bundle-composable.integration-spec.ts › answers in SQL what the domain rule of satisfiable groups answers for the same combos; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › leaves out a combo with a live required group that is suspended or whose available choices cannot reach its minimum, probing the groups of the item by their index; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › is part of every sellable variant, so the scan and the sellable contract leave the same combos out; src/modules/catalog/domain/sellable/sellability.spec.ts › explains a combo the shared rule leaves out because a required group cannot be completed with the reason admission gives that group; src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts › answers the combo not sellable with the reason admission gives the group; src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts › explains the refusal at admission with the group, its reason and the path to fix it; src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts › refuses the line with the catalog reason when the groups it reads raise nothing, never admitting what the catalog leaves out; src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts › judges a combo inside a combo that cannot be completed through its own groups, at the path of its choice; src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts › refuses a combo inside a combo with the catalog reason at the path of its choice when its own groups raise nothing; src/modules/catalog/infrastructure/till/till-read.plan.integration-spec.ts › lists 2,000 of them, half of them combos nested three levels deep, and the groups they offer reading each item by its key, never the whole organization per item |
| A combo whose required choice is itself a combo that cannot be completed (F1b): the Pack familiar offering a children combo whose toy group is suspended or emptied, the same Pack with a plain drink as another choice of that group, a megapack three levels up, and a component combo the sale would have to open below the third level | the Pack, the children combo and the megapack are left out of the till list (not counted in meta.omittedItems), the search and the scan, and resolve answers them sellable: false with bundle_group_unsatisfiable, while admit explains the refusal with the group of the children combo; with the drink still available the Pack stays sold; all of them are sold again once the toy group can be completed. A choice counts only when its component combo can itself be composed, recursively down to the third combo level; below it a component combo with a group the sale would open is not available, as admit refuses it with bundle_too_deep. The SQL fragment unrolls the three levels as keyed anyRowOf probes (no EXISTS, no recursive CTE) and answers what the domain isComposable answers on the same nested rows; 1,000 combos nested three levels deep under stale statistics keep the till plans within their bounds | test/e2e/catalog-nested-combo-till-parity.e2e-spec.ts › leaves the Pack familiar out with its children combo while the toy group is suspended or emptied, and sells both again once it can be completed; test/e2e/catalog-nested-combo-till-parity.e2e-spec.ts › keeps selling the Pack familiar when the same required group still offers a plain drink while its children combo cannot be completed; test/e2e/catalog-nested-combo-till-parity.e2e-spec.ts › carries a toy group that cannot be completed up through three combo levels to the megapack, and back once it can; src/modules/catalog/infrastructure/reads/bundle-composable.integration-spec.ts › answers in SQL what the domain answers for combos whose choices are themselves combos, down to the deepest level a sale may open; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › asks the same of a component that is itself a combo, unrolled once per permitted combo level, and refuses below the deepest level a component the sale would have to open; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › composes a combo whose required choice is a children combo that can be completed, and not once the toy group of the children combo is suspended; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › keeps a combo composable when another available choice of the same required group can fill it; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › adds only the capacity of the choices that can themselves be composed; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › follows the composition through three combo levels; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › refuses at the deepest permitted level a component that is itself a combo the sale would have to open, as admit refuses it for depth |
| A combo whose required component the unit cannot sell on its own because of its required modifiers (F1c): the breakfast whose only coffee needs a milk group that is suspended, an offer of it suspended, its options all suspended or too few units to reach its minimum; the same coffee with an optional milk group suspended or a variant that opts out of it; the breakfast with a tea as another choice; a pack and a megapack above the breakfast | the breakfast is left out of the till list, the search and the scan, and resolve answers it sellable: false with bundle_group_unsatisfiable, while admit refuses the coffee at its path with required_modifier_unsatisfiable; meta.omittedItems counts the coffee (a plain item the unit cannot configure), never the breakfast; with an optional milk group, an opted-out variant or a tea it stays sold everywhere; sold again once the milk resumes. A choice counts toward its minimum only when its component would be sold on its own at the reader's unit by the shared rule (variantConfigurable reused, recursively down to the third level); the SQL fragment and the domain isComposable, which judges each component with blockingRefusalOf as admit does, answer the same on the same rows; 1,000 combos whose every component carries a required modifier group keep the till plans within their bounds under stale statistics | test/e2e/catalog-component-combo-till-parity.e2e-spec.ts › leaves the breakfast out while its only coffee needs a milk that is suspended or the coffee is not listed at the unit, and sells it again at every step back; test/e2e/catalog-component-combo-till-parity.e2e-spec.ts › keeps selling the breakfast through a tea of the same required group while its coffee cannot be sold at the unit; src/modules/catalog/infrastructure/reads/bundle-component-sellable.integration-spec.ts › counts a component only when its required modifiers can be satisfied, as admit judges it, at every combo level; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › counts a choice only when its component would itself be sold at the business unit of the reader: listed there, with its required modifiers satisfiable; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › leaves out a breakfast whose only required coffee cannot be configured at the unit, as admit refuses the coffee, and composes it once the coffee can be configured again; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › keeps a breakfast whose coffee cannot be configured when a tea of the same required group can fill it; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › adds only the capacity of the components that the unit could sell on their own; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › judges the components of a component combo by the same rule, down to the deepest level; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › judges a component combo by its own groups, not by the composability the stored facts carry for it; src/modules/catalog/infrastructure/till/till-read.plan.integration-spec.ts › lists 2,000 of them, half of them combos nested three levels deep, and the groups they offer reading each item by its key, never the whole organization per item |
| A combo whose required component is not listed at the business unit (F1c): the breakfast whose only coffee is delisted at the unit, or listed only at another unit; the breakfast with a tea as another choice; a pack above the breakfast; the coffee listed again | the breakfast is left out of the till list (not counted in meta.omittedItems), the search and the scan, resolve answers bundle_group_unsatisfiable, and admit refuses the coffee at its path with not_listed; a listing at another unit does not count; with the tea it stays sold; sold again once the coffee is listed. The listing probe is the listedAt of the shared rule at the reader's unit, keyed by the unit | test/e2e/catalog-component-combo-till-parity.e2e-spec.ts › leaves the breakfast out while its only coffee needs a milk that is suspended or the coffee is not listed at the unit, and sells it again at every step back; test/e2e/catalog-component-combo-till-parity.e2e-spec.ts › keeps selling the breakfast through a tea of the same required group while its coffee cannot be sold at the unit; src/modules/catalog/infrastructure/reads/bundle-component-sellable.integration-spec.ts › counts a component only when it is listed at the business unit of the reader, keeping a combo whose alternative is listed; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › leaves out a combo whose only component is not listed at the business unit, or whose unit cannot operate; src/modules/catalog/domain/sellable/bundle-composability.spec.ts › judges a component it knows nothing about as unavailable, as admit refuses an unknown variant |
| One option chosen up to twice on a group of minimum and maximum two (F1): twice, once, three times | admitted, and the rows Sales would store pass its real trigger and group check; selection_below_minimum; option_quantity_above_maximum and selection_above_maximum | test/e2e/catalog-unit-capacity.e2e-spec.ts › offers one option chosen up to twice with a minimum of two, sells the item everywhere and admits exactly two units |
| The statements inside the caller transaction | resolve 1; admit 3: the version row, the unit row, the read | test/e2e/catalog-sellable-contract.e2e-spec.ts › resolves in one statement and admits in three inside the caller transaction: the version row, the unit row, then the read |
| Resolving, admitting and refusing | nothing written, the catalog version unchanged, no audit record | test/e2e/catalog-sellable-contract.e2e-spec.ts › writes nothing and records no audit, whether it resolves, admits or refuses; src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts › read without any lock or write, scoped to the tenant of the transaction |
| A future Sales module | may import catalog/contracts/** only, and the contract surface names every type its results carry (ModifierOptionKind included); the sellable rule, the use case and the repository are refused by R1 | src/modules/catalog/contracts/catalog-sellable.contract.spec.ts › names every type its results carry, so a consumer never imports catalog/domain; tooling/repository/check-module-boundaries.spec.ts › R1: lets a sales use case consume CatalogSellableContract and refuses the catalog sellable rule, use case and repository behind it |
| A selection of a text option with a decomposed accent (K14d) | admitted; the admitted selection answers text in NFC (José, not Jose plus a combining accent) and the length is counted after normalizing; a selection without text answers no text | test/e2e/catalog-text-selections.e2e-spec.ts › admits a decomposed text and answers it composed (NFC), and counts the composed length against the limit; test/e2e/catalog-text-selections.e2e-spec.ts › counts the characters of a composed text once after normalizing, so a decomposed text of the same length fits; src/modules/catalog/application/sellable/sellable-request.spec.ts › normalizes the text of a selection to NFC and keeps a selection without text as it was; src/modules/catalog/domain/sellable/admitted-offer.spec.ts › carries the text of a selection into the admitted selection and nothing for a selection without text |
A text option chosen without its value, with a blank value, with a control character or NUL, or longer than textMaxLength; a value sent to an option that is not text | text_required, text_invalid, text_invalid, text_too_long, text_not_allowed, each naming the option; a text option taken twice is option_quantity_above_maximum first | test/e2e/catalog-text-selections.e2e-spec.ts › refuses a text selection without its value, a blank one, one with a control character and one longer than the option, naming the option; test/e2e/catalog-text-selections.e2e-spec.ts › refuses a value sent to an option that is not a text option and a text option taken twice; src/modules/catalog/domain/sellable/text-selection.spec.ts › refuses NUL and any control character, a line break and a tab included; src/modules/catalog/domain/sellable/sellability.spec.ts › refuses a text option taken twice by its quantity before judging the value |
| More than ten selections with text in a line, the components of a combo counted in the same line | CatalogSellableRequestInvalidError with too_many_texts before any lock | test/e2e/catalog-text-selections.e2e-spec.ts › refuses more than ten selections with text in a line before any lock; src/modules/catalog/application/sellable/sellable-request.spec.ts › counts the texts of the components of a combo in the same line |
| An item whose only required group holds a text option | listed on the till and sellable; admission without a selection is selection_below_minimum, with the option but no value text_required, with the value admitted | test/e2e/catalog-text-selections.e2e-spec.ts › sells an item whose only required group holds a text option, and demands its value |
The options of the snapshot of resolve and the item read of a text option and of an option with a prefix | the same fields under the same rule; an option with defaults carries no presentation key | test/e2e/catalog-text-selections.e2e-spec.ts › shows the same options on the item read and in the snapshot of resolve; src/modules/catalog/domain/sellable/admitted-offer.spec.ts › publishes the presentation of an option flat, only the fields that differ from the default, and nothing for an option with defaults |
| The contract surface of text selections | optional text, optional presentation fields and four new refusal reasons, with the ten-text limit published | src/modules/catalog/contracts/catalog-sellable.contract.spec.ts › adds the text of a selection, the presentation of an option and the text refusals as optional, additive pieces |
Cross-tenant and concurrency invariants
| Invariant | Expected answer | Proving test |
|---|---|---|
| Every catalog command locks the catalog version row of its organization before any item row | the lock order of §5 | src/modules/catalog/application/items/create-item.use-case.spec.ts › locks the catalog version before any write, generates the code from the name, writes the item and its default variant and records the creation; src/modules/catalog/application/items/change-item-status.use-case.spec.ts › deactivates an active item after locking the version and the item, and records the status before and after |
| An edit and a lifecycle change of the same item race | both apply one after the other, no deadlock, no 500 | test/e2e/catalog-items-concurrency.e2e-spec.ts › lets an edit and a deactivation of the same item race without a deadlock: both apply, one after the other; database/tests/concurrency/catalog-item-concurrency.ts › NINAKU_CATALOG_ITEM_CONCURRENCY_OK: an update and a deactivation of one item lock the catalog version row first, so the second waits and both apply without a deadlock |
| A creation key is unique per organization; the same key in another organization is independent | catalog_items_creation_idempotency_uq | database/tests/contracts/catalog-item-creation-contracts.ts › the same creation idempotency key twice in one organization is a unique violation; src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts › claims a creation key once per organization and finds the item and the hash of its request by it |
| A caller without the permission never learns about the plan | the same status and body as any plain denial | test/e2e/catalog-items-access.e2e-spec.ts › answers a member without the permission in an organization without the catalog exactly like any plain denial, so it never learns about the plan; src/modules/catalog/presentation/failures/to-public-catalog-failure.spec.ts › answers any other operation refusal exactly like a plain denial, so nobody learns about the plan |
| Every category command locks the catalog version row before the category row, and a refused one locks nothing | the lock order of §5 | src/modules/catalog/application/categories/edit-category.use-case.spec.ts › lock the catalog version row before the category row, then write and record one change; src/modules/catalog/application/categories/edit-category.use-case.spec.ts › refuse a member without the manage permission with one denied record and no lock |
| Two parallel moves that would close a cycle together | one 200, one 422 · catalog.category_cycle, the tree stays acyclic | test/e2e/catalog-categories-concurrency.e2e-spec.ts › lets only one of two parallel moves that would close a cycle together succeed, and refuses the other with catalog.category_cycle |
| Parallel creates, and a rename, a move and a deactivation of one category sent together | all applied one after the other, no deadlock, no 500 | test/e2e/catalog-categories-concurrency.e2e-spec.ts › creates eight categories under one parent in parallel with no deadlock, no conflict and no 500; test/e2e/catalog-categories-concurrency.e2e-spec.ts › applies a rename, a move and a deactivation of one category sent together, one after the other, with no deadlock |
| Every created item has exactly one active default variant | written with the item in one transaction; the deferred trigger is the backstop | test/e2e/catalog-items.e2e-spec.ts › creates an item with its active default variant and answers the same body and strong ETag the read answers |
Every modifier command locks the catalog version row first, then the group or the item FOR UPDATE, then shares the variants or the offered groups in id order; a refused command locks nothing, except an offer refused for the plan, decided after the item lock | the lock order of §5 | src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › locks the version then the group, asks about the plan, share-locks the new variants, then writes the profile, the changed and the new options and one record; src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts › create a group after locking the catalog version, then share-lock its variants in id order before writing the group, its numbered options and one record; src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts › offer groups after the plain permission, the version and the item, asking about the plan before share-locking the groups in id order and replacing the offers; src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts › locks the group for update and shares the variants and the offered groups in id order |
| Writing the offers of an item never touches a variant-scoped offer row | the retirement and the write are scoped to item_variant_id IS NULL (K9b), so such a row survives a PUT unchanged | src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts › retires only the offers of the whole item and leaves a variant-scoped offer row untouched |
Modifier UPDATE lists never name an identity, namespace, parent or option target column | item_id, organization_id, catalog_id, created_at, modifier_group_id, option_kind, item_variant_id stay out of every SET | src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts › %s never names an identity, namespace, parent or target column in its SET list |
| Six sets of one group sent at the same version; two sets that each read the group and add the same code | one 200 and five 412, the group equal to the winner; one option with that code, the other set 412 (it read before the first landed) or 400 at options[3].code (its body repeats the code it read), never 200; no deadlock, no 500 | test/e2e/catalog-modifiers-concurrency.e2e-spec.ts › lets six sets of one group named at the same version end with one 200 and five 412, no deadlock and no 500; test/e2e/catalog-modifiers-concurrency.e2e-spec.ts › lets two sets that each read the group and add the same code ignoring case keep one option with that code, the later one answering 412 when it read before the first landed and 400 when its body repeats the code it read |
| A rename, a reorder and an option add through the set and an option deactivation of one group sent together | applied one after the other; a set that read an older version is refused whole with 412; the transition, which carries no If-Match, always applies | test/e2e/catalog-modifiers-concurrency.e2e-spec.ts › applies a rename, a reorder, an option add and an option deactivation of one group sent together one after the other, each set naming the version it read and a stale one refused whole |
One group offered on six items in parallel; two parallel offers on one item sent with the same If-Match | the six 200; the duel one 200 and one 412 · foundation.precondition_failed, and the item ends with the winner's list, whole | test/e2e/catalog-modifiers-concurrency.e2e-spec.ts › offers one group on six items in parallel, and serialises two parallel offers on the same item so the one that names the version first wins whole and the other answers 412 |
| An option deactivation and an offer preselecting it, sent together, six rounds | both serialise on the catalog version row; the offer answers 200 or 422 · catalog.default_options_invalid; no item is left preselecting the inactive option | test/e2e/catalog-modifiers-concurrency.e2e-spec.ts › serialises an option deactivation and an offer that preselects that option, sent together, so no item is left preselecting an inactive option |
| A default can only name an option of the offered group, catalog and organization; the runtime reads, inserts and deletes defaults but never updates them; another tenant sees none; deleting an offer (only the administrator can since K9c) deletes its defaults | composite foreign keys item_modifier_group_defaults_offer_fk and item_modifier_group_defaults_option_fk, tenant RLS with the scalar subquery, ON DELETE CASCADE (migration 0080) | database/tests/contracts/catalog-offer-default-contracts.ts › NINAKU_CATALOG_OFFER_DEFAULT_OK |
Migration 0082 is the contract step of 0080: catalog.modifier_options.is_default is gone and no index, constraint, trigger or view of the table names it; the copy of 0080, replayed over the revision 91 column in a rolled back savepoint, still keeps only available defaults in option order up to each maximum and can run again; a retired variant's cleanup reads its options through modifier_options_variant_idx, and the items that offer a group are found through item_modifier_groups_group_idx | no column, 0 definitions; defaults o1, o1+o2, none for an inactive group; index scans on 3,000 options and 3,000 offers; no modifier statement names is_default | database/tests/contracts/catalog-offer-default-migration-contracts.ts › NINAKU_CATALOG_OFFER_DEFAULT_MIGRATION_OK; src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts › never names is_default, the option column the contract step of revision 92 dropped |
| Every variant and barcode command locks the catalog version row, then the item row; parallel adds, default selections and mixed commands of one item | applied one after the other, no deadlock, exactly one default at commit | test/e2e/catalog-variants-concurrency.e2e-spec.ts › adds eight variants to one item in parallel, each once, with no deadlock and no 500; test/e2e/catalog-variants-concurrency.e2e-spec.ts › serialises two parallel default selections on one item so exactly one default stands at commit; test/e2e/catalog-variants-concurrency.e2e-spec.ts › applies a variant edit, a barcode set and a default switch of one item sent together, one after the other, with no deadlock, the set answering 412 when the item moved after its tag was read |
| Two parallel adds of one barcode value on two items | one 201, one 409 · catalog.barcode_taken | test/e2e/catalog-variants-concurrency.e2e-spec.ts › lets one of two parallel adds of the same barcode on two items win and refuses the other with catalog.barcode_taken |
Sellables-only roles, kitchen, bar, no role and catalog.items.manage alone on every assortment route | the same plain 403 · catalog.action_forbidden at the caller's unit, another unit, an unknown unit and a unit of another organization; commands audited as denied; nothing written | test/e2e/catalog-assortment-access.e2e-spec.ts › answers a cashier, a waiter, a kitchen and a bar member, a member without a role and a holder of catalog.items.manage alone the same plain denial on every assortment route, at its unit, another unit, an unknown unit and a unit of another organization, and writes nothing |
| A manager anchored at one unit | 200 at its unit; at another unit, an unknown unit and a foreign unit the same plain 403, decided before any lock or lookup, with one denied record | test/e2e/catalog-assortment-access.e2e-spec.ts › lets a manager anchored at one unit curate and read that unit, and refuses another unit of the organization, an unknown unit and a unit of another organization with the same plain denial, writing nothing there; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › refuse a caller anchored at another unit with one denied record, never locking or looking the unit up |
| A manager anchored at another unit, at an inactive unit | the same plain 403 as at an active or unknown unit, on every route; nothing written | test/e2e/catalog-assortment-access.e2e-spec.ts › answers a manager anchored at another unit the same plain denial at an inactive unit as at an active or unknown one, writing nothing |
Organization-wide holders, catalog.assortment.manage alone included; OD3 | any unit 200; only they learn 404 · organization.structure_not_found; the assortment holder cannot edit or create items | test/e2e/catalog-assortment-access.e2e-spec.ts › lets an organization-wide owner, manager and holder of catalog.assortment.manage alone curate any unit, tells only them that an unknown unit or a unit of another organization does not exist, and keeps the shared items out of reach of the assortment holder; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › tell only an organization-wide holder that the unit is unknown, before any lock and without a denied record |
| A member of another organization | its own tenant: 404 like any unknown unit; the victim's tenant: 403 · tenant.context_rejected | test/e2e/catalog-assortment-access.e2e-spec.ts › answers a stranger signed in to another organization like any unknown unit, and refuses it the tenant of the unit |
| A member of another organization at a till | its own tenant: 404 like an unknown outlet; the victim's tenant: 403 · tenant.context_rejected | test/e2e/catalog-till-access.e2e-spec.ts › answers a stranger signed in to another organization like an unknown outlet, and refuses it the tenant of the outlet |
| Identical item names in two organizations at their tills | each search finds only its own item and answers its own catalog version | test/e2e/catalog-till-search.e2e-spec.ts › keeps identical names of two organizations apart and answers the catalog version of its own organization, never the other one |
| Every assortment command decides authorization at the unit first, then locks the catalog version row, then shares the items in id order | the lock order of §5; delisting never asks about the plan | src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › decide catalog.assortment.manage with the capability at the business unit before anything else, then lock the catalog version row, read the stored mode, write it and record one change; src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts › delist with the plain permission at the unit, never asking about the plan, then lock the version, share the items in id order and write only what changes; src/modules/catalog/infrastructure/assortment/assortment.queries.spec.ts › shares the curated items in id order |
Assortment UPDATE lists never name an identity, ownership or namespace column | only assortment_mode and status | src/modules/catalog/infrastructure/assortment/assortment.queries.spec.ts › %s never names an identity, ownership or namespace column in its SET list |
| Two parallel mode changes; a mode change and a listing; a listing and an archival; two managers curating two units | all serialised on the catalog version row, no deadlock, no 500; the listing 200 or 422 as the order decides; each unit holds only its own listings | test/e2e/catalog-assortment-concurrency.e2e-spec.ts › applies two parallel mode changes of one unit one after the other with no deadlock, ending in one of them with one record per real change; test/e2e/catalog-assortment-concurrency.e2e-spec.ts › serialises a mode change and a listing of one unit sent together, round after round: the listing answers 200 or catalog.assortment_disabled, never a 500; test/e2e/catalog-assortment-concurrency.e2e-spec.ts › serialises a listing and an archival of the same item sent together, round after round: the listing answers 200 before the archival or catalog.item_archived after it, never a 500; test/e2e/catalog-assortment-concurrency.e2e-spec.ts › lets two managers curate their own units in parallel with no deadlock, each unit holding only its own listings |
An admission shares the catalog version row, then the business unit row (through OrganizationContextContract.shareBusinessUnit), then reads; resolve locks nothing | the lock order of §5: catalog commands of the organization (another unit's included) wait until the sale's transaction ends, then apply | test/e2e/catalog-sellable-concurrency.e2e-spec.ts › makes an item deactivation, and a delisting and a mode change at another unit, wait until the admitting transaction ends, then applies them; src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts › share the catalog version row of the tenant and nothing else, leaving the business unit row to Organization; src/modules/catalog/application/sellable/catalog-sellables.spec.ts › shares the locks before it reads, and answers every line with its index, its snapshot, its quantity and the effective offers holding its selections; src/modules/catalog/application/sellable/catalog-sellables.spec.ts › locks the version row even when the unit is malformed, and refuses every line as an unavailable unit |
| A catalog write outside any command, and the Organization module's deactivation of the admitted unit, while an admission is held | both wait: every bumped catalog write updates the shared version row, and the unit row is shared | test/e2e/catalog-sellable-concurrency.e2e-spec.ts › makes a catalog write outside any command and a deactivation of the business unit wait too |
| An admission arriving while a command holds the version row | it waits, then judges what the command committed (item_inactive), never a half state | test/e2e/catalog-sellable-concurrency.e2e-spec.ts › waits for a catalog command that already holds the version row, then judges what it committed, never a half state |
| Two admissions at one unit | neither waits for the other | test/e2e/catalog-sellable-concurrency.e2e-spec.ts › lets two admissions at the same unit run side by side, because both only share the rows |
| Admissions, item transitions, listings and mode changes sent together, six rounds | no deadlock, no unexpected error: each admission admitted or refused, each command 200 | test/e2e/catalog-sellable-concurrency.e2e-spec.ts › never deadlocks admissions against item, listing and assortment commands sent together, round after round |
| Another organization's variant or unit at the contract | the same answer as an unknown one, under the caller's RLS context only | test/e2e/catalog-sellable-contract.e2e-spec.ts › answers another organization variant exactly like an unknown one, and another organization unit, an unknown, an inactive and a malformed unit as the same unavailable unit |
| Another organization and the menus of an outlet | sees no assignment, deletes none, cannot write one for itself nor link a menu and an outlet of two organizations | database/tests/contracts/catalog-outlet-menus-contracts.ts › another organization sees no assignment of this outlet; database/tests/contracts/catalog-outlet-menus-contracts.ts › the runtime cannot write an assignment of another organization; database/tests/contracts/catalog-outlet-menus-contracts.ts › a menu of another organization cannot be assigned to this outlet |
| Another organization and the hidden items of an outlet | sees none, deletes none, cannot hide an item of its own at this outlet nor an item of this one at its own | database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › another organization sees no hidden item of this outlet; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › the runtime cannot write a hidden item of another organization; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item of another organization cannot be hidden at this outlet; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an outlet of another organization cannot hide an item of this one |
Images
The catalog side of Assets (ASSETS_USE_CASES.md §7.5, slices A5b, A5c and A6, migration 0095, baseline revision 105; A6 adds no migration). An image is attached only through AssetUsageContract.assertAttachable, after the version row and the item are locked, and read only through AssetDeliveryContract.describe.
AC1 — Set an item's images
| Case | Expected answer | Proving test |
|---|---|---|
| Set two images, read them back, replace them, repeat the same list | 200 with the item, images[] with the first primary and its rendition URLs, a new tag that GET answers too; one catalog.item.images_set record per real change with the ids before and after | test/e2e/catalog-item-images.e2e-spec.ts › sets the images of an item with the first as primary, answers the item with its image urls and a new tag, and records the before and after lists; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › locks the version and the item, checks only the new assets, replaces the list and records the lists before and after; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › answers the same list with the stored item, writing and recording nothing |
| A processing, archived, private, foreign or unknown asset; 11 images; a repeated asset; a waiter; an archived item | 422 · catalog.image_not_attachable with one meta.errors[] entry per asset and its reason, 400, 400, 403 · catalog.action_forbidden, 422 · catalog.item_archived; nothing written, the version unchanged | test/e2e/catalog-item-images.e2e-spec.ts › refuses a processing, archived, private or foreign asset, more than 10 images and duplicates, and removes images on a lapsed plan; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › refuses an asset that is not attachable naming the item, the asset and the reason, writing nothing |
| A lapsed plan adds or reorders; it only removes | 403 · platform.capability_not_entitled; 200 | test/e2e/catalog-item-images.e2e-spec.ts › refuses a processing, archived, private or foreign asset, more than 10 images and duplicates, and removes images on a lapsed plan; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › removes images on a lapsed plan without asking the plan, and refuses adding one |
| An asset archived while it is being attached | the attach waits for the archive and answers 422 with reason archived, nothing attached | test/e2e/catalog-item-images.e2e-spec.ts › serializes attaching an asset with archiving it, so an archived asset is never attached |
| The old primary moves to another position | no violation of item_assets_one_item_primary_uq: the old primary is demoted first | database/tests/contracts/catalog-item-images-contracts.ts › moves the primary image to another asset of the same item without violating the one-primary index; src/modules/catalog/infrastructure/items/item-image.repository.integration-spec.ts › replaces the images of many items at once, demoting a kept primary before promoting the new first, and the item read answers them with their delivery urls |
| Each image statement moves the catalog version once when it changed rows, never otherwise | between one and four bumps per command, zero for the same list | database/tests/contracts/catalog-item-images-contracts.ts › bumps the catalog version once per image statement that changed rows, between one and four times per command, and lets the runtime replace an item's image set |
| Statement budget | 17 statements when the item already has an image (session, membership, BEGIN, tenant context, permission, version lock, the one item statement and one describe, the capability, assertAttachable, delete, demote, upsert, audit, the one item statement and one describe, COMMIT); 25 before the one-statement item read (API v7 PR 6) | test/e2e/statement-budget.e2e-spec.ts › sets an item's images in the measured statements |
| A repeated asset, 11 images, an unknown field, a malformed id, an uppercase id (lowercased, so the same asset in two cases repeats), an empty list | 400 · foundation.validation_failed at the request schema; the empty list is accepted and removes every image | src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts › accepts an empty list, which removes every image, and up to ten distinct images; src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts › lowercases an uppercase asset id, so the same asset in two cases counts as repeated; src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts › refuses %s |
| A repeated asset or 11 images reaching the use case without the schema | refused before any transaction with catalog refusal image_list_invalid, answered 400 · foundation.validation_failed; ten distinct images pass | src/modules/catalog/application/items/set-item-images.use-case.spec.ts › refuses %s at the use case boundary before opening a transaction; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › accepts ten distinct images at the use case boundary |
| What the client sees for each refusal | 404 catalog.not_found and 422 catalog.item_archived / catalog.image_not_attachable with the generic title of the status (Resource not found, Unprocessable content), retryable: false, meta.errors[] items.<itemId>.images.<assetId> with the reason as code; 422 catalog.variant_archived with its own title and no meta | src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts › answers %s with %i, the catalog code, the generic title of the status and no retry; src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts › names each refused asset of an item as items.<itemId>.images.<assetId> with its reason, and a refused item as items.<itemId>; src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts › reaches the client through the catalog failure mapping, which answers an archived variant with its own 422 and no field errors; src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts › answers a list refused at the use case boundary like the request schema does, 400 foundation.validation_failed |
| Another tenant updates, deletes or inserts image rows of a foreign organization | nothing changes; the insert is refused by the row policy (42501) | database/tests/contracts/catalog-item-images-contracts.ts › another tenant updates none of the catalog.item_assets rows of a foreign organization; database/tests/contracts/catalog-item-images-contracts.ts › another tenant deletes none of the catalog.item_assets rows of a foreign organization; database/tests/contracts/catalog-item-images-contracts.ts › an insert naming a foreign organization is refused by the catalog.item_assets row policy |
| Each statement kind bumps through its own trigger; a statement that changes nothing does not | INSERT, UPDATE, upsert and DELETE each move the version once; a no-op UPDATE, upsert or DELETE moves it zero times | database/tests/contracts/catalog-item-images-contracts.ts › one INSERT of two images moves the catalog version once, through the insert trigger; database/tests/contracts/catalog-item-images-contracts.ts › one UPDATE of two images moves the catalog version once, through the update trigger; database/tests/contracts/catalog-item-images-contracts.ts › the same upsert again, a no-op, leaves the catalog version as it was; database/tests/contracts/catalog-item-images-contracts.ts › one DELETE of two images moves the catalog version once, through the delete trigger; database/tests/contracts/catalog-item-images-contracts.ts › a DELETE that removes no image leaves the catalog version as it was |
AC2 — Set a variant's images
Since API v7 PR 4 through PUT /catalog/items/{itemId}/images with variants.
| Case | Expected answer | Proving test |
|---|---|---|
| Set the images of two variants and of the item in one request | each variant keeps its own list with its own primary beside the item list; one catalog.item.images_set and one catalog.item.variant_images_set per changed list, naming the variant, variants in id order | test/e2e/catalog-item-images.e2e-spec.ts › sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › replaces the item list and every variant list sent in one transaction, checks every new asset once, keeps a variant not sent, and records one record per changed list; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › sets the images of a variant apart from the item images and records them under the variant action; src/modules/catalog/infrastructure/items/item-image.repository.integration-spec.ts › keeps the images of a variant apart from the item images, with one primary each |
| Two pre-populated variants replaced in one call, sharing an asset and swapping each primary | each variant keeps one primary, the item list is untouched | src/modules/catalog/infrastructure/items/item-image.repository.integration-spec.ts › replaces the lists of two pre-populated variants in one call, sharing an asset and swapping each primary, without touching the item list |
| One primary per variant, untouched by the item list | a second variant primary violates item_assets_one_variant_primary_uq; replacing the item list keeps the variant rows | database/tests/contracts/catalog-item-images-contracts.ts › a variant keeps its own primary image apart from the item primary, one primary per variant, untouched by the item list |
| A variant of another item; an unknown variant; an archived variant | 404 · catalog.not_found; 404; 422 · catalog.variant_archived | test/e2e/catalog-item-images.e2e-spec.ts › sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › refuses an archived item, an unknown variant and an archived variant |
| A variant not sent; only unchanged lists sent | keeps its images; 200 with the same tag, nothing written or recorded | test/e2e/catalog-item-images.e2e-spec.ts › sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › keeps every variant not sent and records nothing when only unchanged lists are sent |
| A repeated variant, 101 variants, 11 images in a variant list | 400 · foundation.validation_failed at variants[i].variantId or variants, and image_list_invalid at the use case boundary | test/e2e/catalog-item-images.e2e-spec.ts › sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list; src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts › points at the repeated variant by its position; src/modules/catalog/application/items/set-item-images.use-case.spec.ts › refuses %s at the use case boundary before opening a transaction |
| A lapsed plan removes variant images | 200 without asking the plan | src/modules/catalog/application/items/set-item-images.use-case.spec.ts › removes variant images on a lapsed plan without asking the plan |
| Statement budget | 15 statements plus one audit per changed list, the same for 1 and 4 variants (23 before the one-statement item read) | test/e2e/statement-budget.e2e-spec.ts › sets the images of every variant of an item with a statement count independent of the number of variants except the audits |
AC3 — Item read with images
| Case | Expected answer | Proving test |
|---|---|---|
| The item and each variant answer their images | images[] and variants[].images[] with assetId, isPrimary, sortOrder, status, thumbnailUrl, cardUrl, detailUrl | src/modules/catalog/presentation/dtos/items/item-response.spec.ts › answers the images of the item and of each variant in their order, with their status and delivery urls |
| The tag follows the images | the old If-None-Match answers 200 after an image change, the new one 304; a stale If-Match answers 412 | test/e2e/catalog-item-images.e2e-spec.ts › changes the item tag when its images change, so the old If-None-Match answers 200 |
| The repository reads images through the contract the assets module exports | CatalogModule imports AssetsModule; ItemRepository resolves with AssetDeliveryContract and reads the URLs through it | src/modules/catalog/infrastructure/items/item.repository.wiring.spec.ts › lets the catalog module import the assets contract module; src/modules/catalog/infrastructure/items/item.repository.wiring.spec.ts › resolves the item repository with the delivery contract the assets contract module exports, and reads image urls through it |
| Delivery answers | one describe for every image of the item and its variants; an asset the answer does not name keeps status and URLs null; one statement and no describe without images; an item row without a variant is left out; a failing describe propagates; no row, no further statement | src/modules/catalog/infrastructure/items/item-reads.spec.ts › describes every image of the item and its variants in one call and fills their status and urls; src/modules/catalog/infrastructure/items/item-reads.spec.ts › leaves status and urls null for an asset the delivery answer does not name; src/modules/catalog/infrastructure/items/item-reads.spec.ts › reads the item in one statement and never asks for delivery when it has no image; src/modules/catalog/infrastructure/items/item-reads.spec.ts › leaves out an item row that comes back without a variant; src/modules/catalog/infrastructure/items/item-reads.spec.ts › lets a delivery failure propagate instead of answering the item without its images; src/modules/catalog/infrastructure/items/item-reads.spec.ts › answers nothing and asks nothing more when no item row comes back |
| The item read is one statement (API v7 PR 6) | one item, a locked item and many items by a uuid array are each one statement that nests the variants with their live barcodes, offers and images, the categories, the item offers with their options and defaults, and the item images aggregated once per item; every child reached by equality on its keys, no EXISTS, no OFFSET | src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › %s reads one item by its id, its only parameter; src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › reads many items binding their ids as one uuid array, its only parameter; src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › nests every child list inside the item row, so one statement answers the whole item; src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › aggregates the images of the item once per item and those of a variant once per variant, by their keys; src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › reaches every child by equality on its keys, never by EXISTS nor OFFSET |
| Each list of the item read keeps its order whatever the collation, and each offered group is built once | variants is_default DESC, code, id; barcodes status, is_primary DESC, barcode, id; offers sort_order, id; options and default options sort_order, lower(code), id; categories is_primary DESC, lower(name), id; images sort_order, id; the options and facts of a group are read once per item | src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › orders the %s by %s, whatever the collation of the database; src/modules/catalog/infrastructure/items/item-read.queries.spec.ts › builds the facts and the options of each offered group once per item, never once per offer |
| The item representation is byte for byte the one before the one-statement read | a rich item (three variants, live, inactive and archived barcodes, item and variant images, item offers with options and defaults, a variant override, an inactive and a retired offer, two categories) answers the golden file key by key, and read, lock and read-many answer the same entity tag | src/modules/catalog/infrastructure/items/item-read.golden.integration-spec.ts › reads the item with its variants, live barcodes, offers with their options and defaults, categories and delivered images exactly as the golden file holds them; src/modules/catalog/infrastructure/items/item-read.golden.integration-spec.ts › locks, reads among many and reads alone the same representation, so the entity tag of every path is the same |
| The item read stays keyed under stale statistics | the same catalog imported into a neighbour before ANALYZE and into the measured organization after it (2,001 items with offers, barcodes, images and 500 categories): a 40-variant item reads at most 1,000 rows and 2,000 buffers and no lookup wider than 50 rows, 100 items at most 4,000 rows and 10,000 buffers, an item of 500 variants each overriding the same 20 groups, locked, at most 40,000 rows and 50,000 buffers; no sequential scan, no looped scan wider than 50 rows | src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts › reads an item of 40 variants with their barcodes, images and offers by their keys, never scanning a whole table; src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts › reads 100 imported items at once by their keys, never scanning a whole table; src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts › locks an item of 500 variants that each override the same 20 groups by their keys, within the measured bound |
AC4 — Till read with images
C17 and C19 (slice A6, no migration): imageRef and aiGenerated on every item, on a variant only when it has a primary image of its own, and one meta.media.tileUrlTemplate; one AssetDeliveryContract.describe per answer, only when some listed item or variant has an image.
| Case | Expected answer | Proving test |
|---|---|---|
| An item with a primary image and one without; the image set; another organization sets an image | the first answers imageRef <token>/p1 and aiGenerated: false, the second imageRef: null; one meta.media.tileUrlTemplate; the asset id never appears; the tag moves and the old If-None-Match answers 200; the other organization's write leaves the tag at 304 and its token never appears | test/e2e/catalog-till-images.e2e-spec.ts › answers each sellable item with its image reference and one tile url template, and changes the till tag when an image is set, never when another organization's image changes |
| A variant with its own primary image; a variant without one | the variant answers its own imageRef and aiGenerated (true for an AI image); the other carries no image keys and shows the item image | test/e2e/catalog-till-images.e2e-spec.ts › answers a variant with a primary image of its own beside the item image, and no image keys on a variant that shows the item image; src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › answers each item and variant with the image reference of its own primary image and one tile url template, describing every image in one call |
| The media host changes | a new tag (the delivery fingerprint), the old If-None-Match answers 200 with the new template, the new tag 304 | test/e2e/catalog-till-images.e2e-spec.ts › changes the till tag and the tile url template when the media host changes, while the same tag still answers 304; src/modules/catalog/application/till/sellable-entity-tag.spec.ts › includes the delivery fingerprint so a new public base url gives another tag |
| An attached asset that is processing, private, or archived | imageRef: null for the first two (their tokens never appear); the archived one keeps its imageRef (archiving never changes delivery, ASSETS_USE_CASES.md §6.6) | test/e2e/catalog-till-images.e2e-spec.ts › never answers an image reference for an attached asset that is not ready or not public, and keeps delivering an archived one |
No listed item or variant has an image; a matching If-None-Match | no describe call; the 304 is decided before any item or image is read | src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › describes nothing when no listed item or variant has an image; src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › answers a current tag carrying the delivery fingerprint before reading any item or image |
| C19 pages | each page item with its imageRef, the template in meta.media, one describe per page | test/e2e/catalog-till-images.e2e-spec.ts › pages what an outlet sells with the image reference of each item and the tile url template; src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › answers a search page with the image references of its items, describing the page once |
| Statement budget with images | C17 200 9, 304 7, C19 9 | test/e2e/statement-budget.e2e-spec.ts › reads what an outlet sells with images in 9 statements and answers 304 in 7 |
| Each image link change; an attached asset archived | a new tag after every attach, reorder and detach; after the archival the same tag (304) and the same imageRef (the ETag invariant of "A6 as built") | test/e2e/catalog-till-images.e2e-spec.ts › moves the till tag on every image link change and keeps the tag and the image reference when an attached asset is archived |
| A second primary for an item or a variant; an item with a primary, three gallery images and a variant with its own primary | 23505 naming item_assets_one_item_primary_uq and item_assets_one_variant_primary_uq; the item once in C17, at a limit cut and across C19 pages | src/modules/catalog/infrastructure/till/till-read.repository.integration-spec.ts › refuses a second primary image for the same item or the same variant, the uniqueness the till joins rely on; src/modules/catalog/infrastructure/till/till-read.repository.integration-spec.ts › answers an item with a primary, three gallery images and a variant with its own primary once in the list and once across the search pages, with the limit boundary intact |
| The wire shape of a variant without, with an undeliverable and with a delivered own image; an item | no keys; null and false; its reference; an item always both keys | src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts › omits the image keys on a variant without a primary of its own, answers null and false for an undeliverable one and its reference otherwise; src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts › always answers both image keys on an item, null and false without an image |
The strict schemas and meta.media | the new keys validate, a missing imageRef or a non-boolean aiGenerated is refused; tileUrlTemplate a string or null, never absent | src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts › validates the new keys against the strict response schemas; src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts › answers the tile url template in meta.media, or null while the media host is unset |
| An outlet no unit operates today; an outlet selling 2,001 items | empty lists, the template and a -none-d… tag taken from the values of the statement itself, no describe; one describe for the 2,000 answered items only | src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › answers an outlet no business unit operates today with the empty lists, the template and a -none tag its statement returned, describing no image; src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › describes only the images of the 2,000 answered items when the outlet sells more |
AC5 — Set images for many items at once
| Case | Expected answer | Proving test |
|---|---|---|
| Five items set twice | 200 with every item and the tag GET answers, in request order; the version moves once, then at most four times; one record per changed item | test/e2e/catalog-item-images.e2e-spec.ts › sets the images of many items in one request moving the catalog version at most four times whatever the number of items, and refuses the whole request when one asset is not attachable; src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › locks the items in id order, checks every new asset once, writes every changed list in one call and audits each changed item; src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › writes and audits nothing for an item whose list did not change |
| One asset not attachable; an unknown item; an archived item | 422 · catalog.image_not_attachable naming each item and asset, 404 · catalog.not_found and 422 · catalog.item_archived naming each item; nothing written | test/e2e/catalog-item-images.e2e-spec.ts › sets the images of many items in one request moving the catalog version at most four times whatever the number of items, and refuses the whole request when one asset is not attachable; src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › refuses the whole request naming each item and asset when one asset is not attachable; src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › refuses unknown items before archived ones, naming each, and writes nothing |
| A lapsed plan | removal only 200; an addition 403 · platform.capability_not_entitled | src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › removes images on a lapsed plan and refuses the request when one item adds an image |
| Statement budget | 15 statements plus one audit per changed item, the same for 1 and 100 items (19 before the one-statement item read) | test/e2e/statement-budget.e2e-spec.ts › sets images for 100 items with a statement count independent of the number of items except the audits |
| Zero items, 101 items, a repeated item (also in two cases), a repeated asset or 11 images in one list | 400 · foundation.validation_failed at the schema, and the same refusal at the use case boundary before any transaction | src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts › accepts one to a hundred distinct items, each with an empty or a full list; src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts › refuses %s; src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › refuses %s at the use case boundary before opening a transaction |
An entry with the current tag of its item; one with a stale tag (its list unchanged); a wildcard ifMatch | 200; 412 · foundation.precondition_failed naming items.<itemId> with precondition_failed, nothing written, the version unchanged; 400 at items[0].ifMatch | test/e2e/catalog-item-images.e2e-spec.ts › writes a bulk entry carrying the current tag of its item and refuses the whole request naming every item whose tag is stale; src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › checks the tag of each item sent with ifMatch against the locked item and writes when it matches; src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › refuses the whole request naming every item whose ifMatch is stale, even when its list is unchanged, writing nothing |
| An unknown item and a stale tag; an archived item and a stale tag | 404 first; 412 before 422 | src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts › refuses an unknown item before comparing tags, and a stale tag before an archived item |
Combos
Slice B1 (migration 0100, baseline revision 110): the composition of a combo, kit or package (CATALOG_USE_CASES.md, "B1 as built"). A combo is a normal item that owns bundle groups; prices stay with Pricing.
C22 — Set and read what a combo is made of
| Case | Expected answer | Proving test |
|---|---|---|
| Make an item a combo with three groups (Principal, Bebida, Acompañamiento), read it back, revalidate | 200 with the groups and their choices in order, each choice with its item name, variant, SKU, statuses, quantityPerSelection, maxSelectionCount, isDefault, suspended and isAvailable; GET answers the same body and strong tag; If-None-Match answers 304; one catalog.item.bundle_groups_set record | test/e2e/catalog-bundle-groups.e2e-spec.ts › makes an item a combo with three groups, answers the names in order and reads back the same with the same ETag; src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › locks the version then the combo, judges the components, writes, re-reads and records the composition before and after |
| Reorder groups, drop a choice, send it again | every kept group and choice keeps its id; the dropped choice is retired (the row stays), and sending its variant again revives the same id; a choice sent without choiceId keeps its id when its variant is already in the group | test/e2e/catalog-bundle-groups.e2e-spec.ts › keeps every id on a reorder, retires a removed choice without deleting it and revives it with the same id; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › keeps the id of every group and choice the client sends back, in the order sent; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › keeps the id of a choice sent without its id, and revives a retired one with its old id; src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › writes a combo, keeps every id on a rewrite, retires instead of deleting and reads back names and statuses in order |
| New groups and choices; a group without a code | only they get new ids; a new group without code takes one derived from its name (postre-del-dia), the first free against every code the combo holds or held and the codes sent (bebida-2), group when the name has no letter or digit; a kept one keeps its code | src/modules/catalog/domain/bundles/bundle-composition.spec.ts › gives a new id only to a new group and a new choice, and derives the code of a new group from its name; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › derives the first free code, skipping the codes of every stored group, retired ones included, and the codes sent; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › falls back to group when the name holds no letter or digit; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › keeps the stored code of a kept group when the client omits it |
Send the composition the combo already has (quantityPerSelection 1.0 for 1 included) | 200, the same body and tag, no write, no record, the catalog version unchanged, the plan never asked | test/e2e/catalog-bundle-groups.e2e-spec.ts › answers the same composition again without a write, a record or a catalog version change; src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › answers the stored composition when nothing changes, writing and recording nothing and never asking the plan; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › finds the same composition when every group and choice comes back unchanged; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › treats an equal quantity written another way as the same; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › sees a change in the order, a rule, a quantity, a cap, a default, a suspension or a new group |
| Rename a group and change nothing else | one catalog.item.bundle_groups_set record whose before and after differ and name the group as it was and as it is: the summary and the change check come from one key, so they cannot drift | src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › records a rename of a group alone with different before and after summaries that both name it; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › lists each live group with its rule, its status, its name and its choices, and nothing when there are none; test/e2e/catalog-audit-renames.e2e-spec.ts › records the old and the new name of a combo group renamed and nothing else |
A stale If-Match; the current one | 412 · foundation.precondition_failed before anything is judged or written; 200 | test/e2e/catalog-bundle-groups.e2e-spec.ts › refuses a stale If-Match with 412 and writes with the current one; src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › refuses a stale If-Match before anything is judged or written, and accepts the current tag |
| A combo inside a combo; a loop; the combo itself | 200; 422 · catalog.bundle_cycle with meta.errors[] at bundleGroups[i].choices[j].variantId; the SQL guard (bundle_composition_acyclic) is the backstop and maps to the same code | test/e2e/catalog-bundle-groups.e2e-spec.ts › allows a combo inside a combo and refuses a loop or the combo itself, naming the field; src/modules/catalog/domain/bundles/bundle-judgement.spec.ts › refuses the combo itself or a combo that contains it as a choice; src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › refuses a cycle and an archived component with typed refusals pointing at the variant, writing nothing; src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › maps the SQL cycle guard to a cycle refusal when a write would close a loop; src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › marks a component that contains the combo, directly or through nesting; database/tests/contracts/catalog-bundle-groups-contracts.ts › a combo cannot contain, through nesting, a combo that contains it; database/tests/contracts/catalog-bundle-groups-contracts.ts › an item cannot be a choice of its own group (bundle_composition_acyclic) |
| A new choice whose item or variant is archived, or of another catalog; one already in the group archived since | 422 · catalog.bundle_component_unavailable naming the field; the kept one stays | test/e2e/catalog-bundle-groups.e2e-spec.ts › refuses an archived variant as a new choice and shows a deactivated one as unavailable; src/modules/catalog/domain/bundles/bundle-judgement.spec.ts › refuses a new choice whose item or variant is archived or lives in another catalog, but keeps one already offered |
| A deactivated component in a required group; caps that cannot reach the minimum; a suspended required group | isAvailable: false on the choice and isSatisfiable: false on the group; an optional group stays satisfiable; a suspended required group is never satisfiable; the same rule B2 judges | test/e2e/catalog-bundle-groups.e2e-spec.ts › refuses an archived variant as a new choice and shows a deactivated one as unavailable; src/modules/catalog/domain/bundles/bundle-satisfiability.spec.ts › always satisfies an optional group, even suspended and without an available choice; src/modules/catalog/domain/bundles/bundle-satisfiability.spec.ts › counts each available choice at its cap and refuses when they cannot reach the minimum; src/modules/catalog/domain/bundles/bundle-satisfiability.spec.ts › never satisfies a required group that is suspended; src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts › tells the manager a suspended required group leaves the combo unsellable and none of its choices can be picked |
| An unknown group, choice or variant; a choice id with another variant; a code another group holds or held | 404 · catalog.not_found, 422 · catalog.bundle_choice_mismatch, 409 · catalog.code_taken, each with meta.errors[] naming the field and how to fix it; the first kind by precedence wins | src/modules/catalog/domain/bundles/bundle-judgement.spec.ts › points at an unknown group, choice or variant with not_found; src/modules/catalog/domain/bundles/bundle-judgement.spec.ts › refuses a choice id sent with another variant than the one it names; src/modules/catalog/domain/bundles/bundle-judgement.spec.ts › refuses a code another group of the item holds, retired groups included; src/modules/catalog/presentation/failures/bundle-refusal-failure.spec.ts › answers %s with %i and %s, pointing meta.errors at the field with a message that says how to fix it; src/modules/catalog/presentation/failures/bundle-refusal-failure.spec.ts › answers the kind that comes first when several are refused, listing only its fields |
| Another organization; a cashier; a foreign variant; min above max; 21 groups | 404; 403 · catalog.action_forbidden; 404 at bundleGroups[0].choices[0].variantId; 400 at bundleGroups[0].minSelections; 400 at bundleGroups | test/e2e/catalog-bundle-groups.e2e-spec.ts › answers 404 for another organization, 403 to a member without the permission and 400 with pointers for broken rules |
| Rules that need no lookup | 400 · foundation.validation_failed with the field: minimum above maximum, a group without choices, a variant twice in a group, defaults outside the minimum and the maximum, a quantityPerSelection that is not a decimal above 0 and at most 999, a maxSelectionCount above the group maximum or caps that cannot reach the minimum, a suspended default, a repeated group, code or choice, more than 20 groups or 50 choices, an unknown field such as a price; an empty list is accepted | src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › points at a minimum above the maximum, a group without choices and a repeated variant; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › points at defaults outside the minimum and the maximum, and accepts none; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › refuses a zero, negative, too large or non-decimal quantity per selection; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › refuses a repeated group, code or choice id, more than 20 groups, more than 50 choices and unknown fields; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › accepts an empty list, which makes the item plain again; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › accepts the documented example and fills the defaults of a choice |
| A lapsed plan | an empty list 200; any other change 403 · platform.capability_not_entitled, nothing written | src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › removes every group on a lapsed plan, and refuses adding one; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › needs no plan only to remove every group |
| An unknown or archived combo | 404 · catalog.not_found; 422 · catalog.item_archived | src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › answers not found for an unknown item and refuses an archived one |
| Read | any item, archived included, after catalog.items.view; a plain item answers []; retired groups and choices are not shown; nothing locked or recorded; 403 before reading | src/modules/catalog/application/bundles/read-bundle-groups.use-case.spec.ts › reads the composition of any item, archived ones included, after the view permission, without locking or recording anything; src/modules/catalog/application/bundles/read-bundle-groups.use-case.spec.ts › answers not found for an unknown item and forbidden without the permission, before reading; src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts › shows only live groups and choices, with the cap and the suspension of each; src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts › documents an example that the schema accepts; src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › reads nothing of another organization |
| Statement budget | PUT 16 statements whatever the number of groups (session, membership, BEGIN, tenant context, permission, version lock, composition lock, capability, components, retire groups, upsert groups, retire choices, upsert choices, re-read, audit, COMMIT), 8 for an unchanged composition, GET 7 | test/e2e/statement-budget.e2e-spec.ts › sets and reads the bundle groups of a combo in the measured statements, whatever the number of groups |
| Runtime privileges and version triggers | SELECT, INSERT, UPDATE, never DELETE nor TRUNCATE, on both tables; EXECUTE on the two graph functions; one bump per statement kind on insert, update and delete; a no-op upsert and an UPDATE that changes nothing do not bump | database/tests/contracts/catalog-bundle-groups-contracts.ts › one INSERT of two choices moves the catalog version once; database/tests/contracts/catalog-bundle-groups-contracts.ts › the same upsert again, a no-op, leaves the catalog version as it was; database/tests/contracts/catalog-bundle-groups-contracts.ts › an UPDATE that changes no choice leaves the catalog version as it was; database/tests/contracts/catalog-bundle-groups-contracts.ts › the runtime cannot delete a choice, not even its own; database/tests/contracts/catalog-bundle-groups-contracts.ts › the runtime cannot delete a bundle group, not even its own; database/tests/contracts/catalog-bundle-groups-contracts.ts › retiring a group is an UPDATE that moves the version once |
| Another tenant | sees and updates none of either table; a group naming a foreign organization is refused (42501) | database/tests/contracts/catalog-bundle-groups-contracts.ts › a bundle group naming a foreign organization is refused; src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › reads nothing of another organization |
| A group, code or choice sent twice, a variant twice in a group | 400 · foundation.validation_failed with one meta.errors[] entry per repetition, pointing at the repeated element (bundleGroups[1].bundleGroupId), before the use case runs; the use case no longer judges repetitions again | src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › refuses a repeated group, code or choice id, more than 20 groups, more than 50 choices and unknown fields; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › points at a minimum above the maximum, a group without choices and a repeated variant |
| An otherwise unchanged composition with an unknown choice id or one of another variant | 404 · catalog.not_found or 422 · catalog.bundle_choice_mismatch, never the stored composition | src/modules/catalog/domain/bundles/bundle-judgement.spec.ts › judges the ids the client sends without reading any component, so an unchanged composition is judged too; src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › judges the ids sent back even when nothing else changes: an unknown choice id is not found and an id of another variant a mismatch; test/e2e/catalog-bundle-groups.e2e-spec.ts › refuses every change but emptying the combo on a lapsed plan, and judges the ids of an unchanged composition |
| A lapsed plan over HTTP | a new group 403 · platform.capability_not_entitled; an empty list 200 | test/e2e/catalog-bundle-groups.e2e-spec.ts › refuses every change but emptying the combo on a lapsed plan, and judges the ids of an unchanged composition |
| A retired choice and the cycle guard | a retired choice or group does not count, so the contained combo may contain the other; reviving the retired row is refused (bundle_composition_acyclic); the use case lookup agrees | database/tests/contracts/catalog-bundle-groups-contracts.ts › a retired choice does not count for the cycle guard, so the contained combo may now contain the other; database/tests/contracts/catalog-bundle-groups-contracts.ts › reviving the retired choice would close a loop and is refused (bundle_composition_acyclic); database/tests/contracts/catalog-bundle-groups-contracts.ts › a retired group does not count either, and reviving it is checked like reviving a choice; src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › ignores a retired choice when it looks for the combos that contain one, like the SQL cycle guard |
| The locking read | runs against PostgreSQL and answers what the plain read answers, retired rows included; an unknown item answers nothing | src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › locks the combo with its composition in the same statement and answers what the read answers, retired rows included |
Send back the groups GET answers, a null cap included | 200, the same body and tag, no record and no catalog version change | test/e2e/catalog-bundle-suspension.e2e-spec.ts › takes back the groups GET answers, a null cap included, as the same composition: 200, the same body and tag, no record and no version change; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › reads a null cap, as GET answers it, as no cap |
Suspend, cap and resume through PUT | suspended: true writes the group or choice inactive and answers suspended: true, isAvailable: false; maxSelectionCount is written and answered; sending it again without the flag resumes it with the same id; a retired row comes back as sent | test/e2e/catalog-bundle-suspension.e2e-spec.ts › writes a suspended choice and a cap, answers them, and resumes the choice keeping its id; src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts › writes the status and the cap the plan sends: a suspended group resumes, a choice is suspended and capped; src/modules/catalog/domain/bundles/bundle-composition.spec.ts › writes a suspended group or choice as inactive, and an offered one as active, with the cap of each choice; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › accepts a suspended group and a suspended choice, but never a suspended default; src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts › refuses a cap that is not a positive integer or exceeds the maximum of its group, and caps that cannot reach the minimum |
| Extra application fields | never reach the answer: a group and a choice carry exactly their documented keys | src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts › answers exactly the documented fields of a choice, whatever else the application carries |
| Write failures | the cycle guard, the code index and the two variant foreign keys map to typed refusals keeping the cause; any other failure passes through | src/modules/catalog/infrastructure/bundles/bundle-write-failure.spec.ts › maps %s on %s to a %s refusal that keeps the database error as its cause; src/modules/catalog/infrastructure/bundles/bundle-write-failure.spec.ts › lets any other failure through unchanged, a violation of another constraint included |
C28 — Admit a combo with the components the customer chose
Slice B2 (no migration, baseline revision 110): admit of CatalogSellableContract takes bundleSelections on a line and answers the tree a sale stores (CATALOG_USE_CASES.md, "B2 as built"; CATALOG_OPTIONS_V2.md §20).
| Case | Expected answer | Proving test |
|---|---|---|
| A combo with a chosen burger (with its cooking option), fries twice and the drink group left out | admitted; bundleGroups in catalog order, each choice with choiceId, quantity, quantityPerSelection, appliedByDefault, its own snapshot and admitted offers; the default drink applied once; the sales schema stores the rows built from the answer alone and refuses them without the drink | test/e2e/catalog-sellable-bundles.e2e-spec.ts › admits a combo with its chosen components and their modifiers, applies the default of a group left out, and the sales schema stores the tree it answers; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › admits the chosen choices with their modifiers, applies the default of a group left out and answers the tree a sale stores, reading the components in one more statement; src/modules/catalog/domain/sellable/bundle-admission.spec.ts › resolves every chosen choice of a sent group in request order, with its quantity, its modifier and nested selections and the path that points at it; src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts › applies the available defaults of a group the client left out, once each, pointing at where the group would be sent |
| The same choice sent twice in a group; quantities summed against the minimum and the maximum | two occurrences, as two Sales rows; bundle_selection_below_minimum or bundle_selection_above_maximum on the sum | src/modules/catalog/domain/sellable/bundle-admission.spec.ts › judges the minimum and the maximum of a group on the sum of the chosen quantities, a choice sent twice counting twice; src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › accepts the same choice twice in a group, as two occurrences |
| A sent group that leaves its default out | the choice sent is admitted and the available default is answered in removedDefaults; a default no longer available is never named | src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › names the default a sent group left out as removed and admits the choice the client made instead; src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts › names the available defaults a sent group left out as removed, never one it chose nor one no longer available |
A combo sent without bundleSelections whose required group has no default | bundle_selection_below_minimum for that group at lines[0].bundleSelections, with bundleGroupId and a detail | src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › refuses a combo sent without choices when a required group has no default, pointing at where the group belongs; src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts › refuses a required group the client left out when it has no default to apply, naming the group at the list it belongs to |
A group left out whose default is delisted at the unit or hidden at the outlet, in an optional and in a required group, with and without outletId | the default is not applied and nothing else takes its place: an optional group is admitted with no choice, a required one refuses bundle_selection_below_minimum at lines[0].bundleSelections; the combo still resolves as sellable while another choice is available; a chosen default that is no longer sold still refuses not_listed; hidden at the outlet only counts when outletId is sent | test/e2e/catalog-combo-defaults-context.e2e-spec.ts › applies no default that is no longer sold ($unavailability.name, outlet $outlet, minimum $minSelections), keeps refusing a chosen one and never swaps the drink |
| An unlisted component, a component modifier group left out, a choice of another group, a group above its maximum | not_listed and selection_below_minimum (with modifierGroupId) at the path of the choice, bundle_choice_not_offered, bundle_selection_above_maximum; nothing admitted | test/e2e/catalog-sellable-bundles.e2e-spec.ts › refuses a combo without its required choices, an unlisted component, a component modifier left out, a choice of another group and a group above its maximum, each with a reason, the path to fix and nothing admitted; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › refuses a component the business unit does not sell with its own sellable reason, and a component modifier group left below its minimum, at the path of the choice; src/modules/catalog/domain/sellable/bundle-admission.spec.ts › refuses a choice the group does not offer, retired or of another group, and a suspended one, and keeps them out of the sum |
| An unknown, retired or suspended group; groups sent to a plain item | bundle_group_not_offered, bundle_group_unavailable, at lines[i].bundleSelections[g] | src/modules/catalog/domain/sellable/bundle-admission.spec.ts › refuses an unknown or retired group as not offered and a suspended one as unavailable, pointing at each; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › refuses choices sent to a plain item as groups the item does not offer |
| A suspended choice; a choice above its cap; a required group whose components are all inactive; a suspended required group left out or sent; a suspended optional group left out | bundle_choice_unavailable; bundle_choice_above_maximum; bundle_group_unsatisfiable without judging its sum, as B1's isSatisfiable; bundle_group_unavailable at the list or at the sent group; nothing for the optional one | test/e2e/catalog-bundle-suspension.e2e-spec.ts › refuses at admission a suspended choice, a choice above its cap, and a suspended required group whether it is sent or left out; test/e2e/catalog-sellable-bundles.e2e-spec.ts › refuses a suspended choice, and a combo whose required group has no active component, as the manager screen shows it; src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts › refuses a required group whose live choices cannot reach its minimum as unsatisfiable, without judging its sum; src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts › neither requires nor answers an optional suspended group the client left out; src/modules/catalog/domain/sellable/bundle-admission.spec.ts › refuses a required suspended group the client leaves out as unavailable, at the list, and ignores an optional one; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › refuses a combo whose only required group is suspended, even when the client sends nothing |
A choice chosen more times than its max_selection_count | bundle_choice_above_maximum at its first entry, the entries summed | src/modules/catalog/domain/sellable/bundle-admission.spec.ts › refuses a choice chosen more times than its own cap, summing every entry of that choice |
| A combo inside a combo | the inner combo judged with its own groups and defaults, answered under its choice; the sales schema stores the nested rows | test/e2e/catalog-sellable-bundles.e2e-spec.ts › admits a combo inside a combo, judging and defaulting the inner groups, and the sales schema stores the nested tree; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › judges a nested combo with its own groups and defaults, one statement per level whatever the number of groups |
| Nesting deeper than 3, in the request or through defaults; exactly 3 | request problem too_deep before any lock; bundle_too_deep at the list, at most one read per allowed level; admitted | src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › rejects a combo choice quantity outside 1 to 999, a group sent twice at one level, an option repeated in a component, too many components or groups and nesting deeper than the bound, before any lock; src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › accepts nesting exactly to the depth bound and exactly the component limit, and refuses one level or one component more; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › refuses combos nested deeper than the bound, reading at most one statement per allowed level; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › admits combos nested exactly to the bound |
| A choice quantity off 1 to 999, a group twice at one level, an option twice in a component, more than 100 components or 20 groups; defaults beyond 100 components | invalid_quantity, duplicate, too_many_components before any lock; bundle_components_above_limit at lines[i] | src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › rejects a combo choice quantity outside 1 to 999, a group sent twice at one level, an option repeated in a component, too many components or groups and nesting deeper than the bound, before any lock; src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts › accepts nesting exactly to the depth bound and exactly the component limit, and refuses one level or one component more; src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › refuses a line whose defaults expand beyond the component limit |
| Defaults that fan out past 100 components through nested combos (20 groups of defaults on each level) | bundle_components_above_limit at lines[i], the only refusal; the line stops expanding at the level where it passes 100, reads no deeper level and at most 100 components, while the other lines of the admission keep expanding | src/modules/catalog/application/sellable/catalog-sellables-component-limit.spec.ts › stops expanding a line as soon as its defaults pass the component limit, reading no deeper level; src/modules/catalog/application/sellable/catalog-sellables-component-limit.spec.ts › keeps expanding the other lines of the admission while one line passes the limit; test/e2e/catalog-bundle-fan-out.e2e-spec.ts › refuses at admission a combo whose nested defaults pass the component limit, at the line |
A plain line, or an item whose groups are all optional and suspended (a required suspended group refuses bundle_group_unavailable instead) | answered with exactly lineIndex, snapshot, quantity, offers, no bundleGroups key, and no extra statement | src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts › answers no bundle groups on a plain line, byte for byte as before, and reads nothing more; src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts › says an item is a combo only while one of its groups is active |
| The groups a variant's item owns | read in SELLABLE_FACTS itself: live groups with live choices, names, quantityPerSelection in the shortest form, cap, default, statuses, componentActive (item and variant active) and componentOffered (also listed at the unit, not hidden at the outlet sent and configurable: the rule the till applies to a choice, without its own nested composability, which the nested descent explains); [] for a plain item; the snapshot unchanged | src/modules/catalog/infrastructure/sellable/sellable.repository.integration-spec.ts › reads the live groups of a combo with their live choices, names, quantities, caps, defaults and statuses in the same statement, and none for a plain item; src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts › read the live bundle groups of each variant item in the same statement, leaving retired groups and choices out |
| The contract surface | every type of the tree and of BundleAdmissionRefusal is exported by the contract | src/modules/catalog/contracts/catalog-sellable.contract.spec.ts › names every type of a combo admission, so the tree needs no catalog/domain import either |
| A composition change while a sale admits the combo | the PUT waits for the admitting transaction (one catalog version share lock), then applies | test/e2e/catalog-sellable-bundles.e2e-spec.ts › makes a composition change of the combo wait until the admitting transaction ends, then applies it |
| Statement budget | 6 statements for a plain line, 7 for a combo with three groups, 8 for a combo inside a combo: one per level of components | test/e2e/statement-budget.e2e-spec.ts › admits a plain line, a combo with three groups and a combo inside a combo in the measured statements, one more per level of components whatever the number of groups |
Menus
Slice M1 (migration 0101, baseline revision 111): the structure of a menu (CATALOG_USE_CASES.md, "M1 as built"). A menu says what is shown; which outlet shows it is M2a (C29, migration 0105, baseline revision 115), its time windows S1 (C31, migration 0107, baseline revision 117), and prices stay with Pricing.
C21 — Build and read the structure of a menu
| Case | Expected answer | Proving test |
|---|---|---|
| Create a draft menu, build Entradas with Frías, Principales (two selected sizes) and Postres, read it back, revalidate | 201 empty draft; 200 with the sections, subsections and entries in order, item and variant names, variantMode, isAvailable, entryCount and availableEntryCount; GET answers the same body and strong tag; If-None-Match answers 304; one catalog.menu.structure_set record | test/e2e/catalog-menus.e2e-spec.ts › creates a draft menu, builds Entradas with Frías, Principales and Postres, reads it back with the same ETag and revalidates with 304; src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › locks the version then the menu, judges the items, writes, re-reads and records the structure before and after |
| Reorder sections, move a subsection to the top and a section under it, drop an entry, send it again | every kept section and entry keeps its id; the dropped entry and section are retired (the rows stay), and sending the item again revives the same id | test/e2e/catalog-menus.e2e-spec.ts › keeps every id on a reorder and a move, retires a removed entry without deleting it and revives it with the same id; src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts › writes nested sections and entries, keeps every id on a rewrite, retires instead of deleting and reads back names in order |
| Identity without ids | a section sent by its code keeps the id of the section that holds it, retired ones included; an item sent again in the same section keeps its entry; an item added to another section gets a new entry; a new section without code takes one derived from its name, the first free against every code of the menu | src/modules/catalog/domain/menus/menu-structure.spec.ts › keeps the id of a section sent without its id when its code is on the menu, retired ones included; src/modules/catalog/domain/menus/menu-structure.spec.ts › keeps the id of an entry sent without its id when its item is already in that section, retired ones included; src/modules/catalog/domain/menus/menu-structure.spec.ts › gives a new id to an item added to another section even when another section shows it; src/modules/catalog/domain/menus/menu-structure.spec.ts › derives the code of a new section from its name, skipping every code of the menu, and keeps the stored code of a kept section; src/modules/catalog/domain/menus/menu-judgement.spec.ts › takes a section sent by its code alone for the section that holds the code, so its entries keep their ids |
| Order and selections | top sections, subsections under their parent and entries by position; selected variants in the order sent, none for all | src/modules/catalog/domain/menus/menu-structure.spec.ts › orders top sections, their subsections and their entries by position, under their parent; src/modules/catalog/domain/menus/menu-structure.spec.ts › plans the selected variants of an entry in the order sent, and none for all variants; src/modules/catalog/application/menus/menu-view.spec.ts › orders the top sections, their subsections and the entries of each by position, leaving retired ones out |
| Send the structure the menu already has | 200, the same body and tag, no write, no record, the catalog version unchanged, the plan never asked; a reorder, a rename, another variant order or a move is a change | test/e2e/catalog-menus.e2e-spec.ts › answers the same structure again without a write, a record or a catalog version change; src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › answers the stored menu when nothing changes, writing and recording nothing and never asking the plan; src/modules/catalog/domain/menus/menu-structure.spec.ts › finds the stored structure the same when every live section and entry comes back unchanged; src/modules/catalog/domain/menus/menu-structure.spec.ts › sees %s as a change |
| Rename a section and change nothing else | one catalog.menu.structure_set record whose before and after differ and name the section as it was and as it is: the summary and the change check come from one key | src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › records a rename of a section alone with different before and after summaries that both name it; test/e2e/catalog-audit-renames.e2e-spec.ts › records the old and the new name of a menu section renamed and nothing else |
A stale If-Match on the structure or the name; the current one | 412 · foundation.precondition_failed before anything is judged or written; 200 | test/e2e/catalog-menus.e2e-spec.ts › refuses a stale If-Match with 412 and writes with the current one; src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › refuses a stale If-Match before anything is judged or written, and accepts the current tag |
| Activate an empty menu, one with nothing available, an available one; empty an active menu; archive; touch an archived one | 422 · catalog.menu_empty; 422 · catalog.menu_nothing_available; 200 active with one catalog.menu.activated record; 422 · catalog.menu_empty; 200 archived, readable; 422 · catalog.menu_archived for its structure, 422 · catalog.transition_invalid for its activation | test/e2e/catalog-menus.e2e-spec.ts › refuses to activate an empty menu or one with nothing available, activates it once an item sells, and archives it; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › activates a draft menu with an available entry and records the status before and after; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › refuses to activate an empty menu, a menu with nothing available or an archived menu, writing nothing; src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › refuses to leave an active menu without entries; src/modules/catalog/domain/menus/menu-lifecycle.spec.ts › %s from %s is %s; src/modules/catalog/domain/menus/menu-lifecycle.spec.ts › blocks an empty menu, then a menu where nothing can be sold now, and lets the rest through |
| Archive on a lapsed plan; archive again; without the permission | 200, never asks the plan; 200 unchanged, no record; 403 · catalog.action_forbidden | src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › archives without the plan, answers an archived menu unchanged, and needs the permission; src/modules/catalog/domain/menus/menu-lifecycle.spec.ts › gates the activation and the restore by the plan, never the unpublishing or the archival |
Unpublish an active menu, empty the draft, archive, unpublish an archived one, restore it, restore again, activate (API v7 PR 3) | 200 with exactly { menuId, status: draft } and the tag the read answers, one catalog.menu.unpublished; 200; 200; 422 · catalog.transition_invalid; 200 draft with its structure, one catalog.menu.restored; 200, no record; 200 active | test/e2e/catalog-menus.e2e-spec.ts › unpublishes an active menu to draft, restores an archived one to draft with its structure, answers lean with the tag the read answers and records each real change once; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › unpublishes an active menu back to draft without the plan and records it; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › restores an archived menu to draft after the plan, whatever it holds, and records it; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › refuses a transition the status does not allow, naming the menu, its status and the transitions it allows; src/modules/catalog/domain/menus/menu-lifecycle.spec.ts › names the transitions each status allows, in the order of the verbs; src/modules/catalog/presentation/dtos/menus/menu-transition.schemas.spec.ts › refuses %j |
| Statement budget of every lifecycle route (API v7 PR 3) | category 12, 13 for a restore under a parent, 10 for a repeat; group and option 14, 13 for a reactivation or restore, 11 for a repeat; menu 10, 8 for a repeat | test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts › takes a category transition in 12 statements, 13 for a restore that judges its place under its parent again, and 10 for a repeat that records nothing; test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts › takes a modifier group transition in 14 statements, 13 for a reactivation or a restore that retire no offer default, and 11 for a repeat that records nothing; test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts › takes a modifier option transition in 14 statements, 13 for a reactivation or a restore that retire no offer default, and 11 for a repeat that records nothing; test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts › takes every menu transition in 10 statements, the activation that counts what the menu shows included, and 8 for a repeat that records nothing |
| Every audit action the catalog records is seeded; each transition has its own action, a variant restore included | the seed of 0380_audit.sql holds them all; no two transitions share an action | src/modules/catalog/application/catalog-audit-vocabulary.spec.ts › seeds every action the catalog records, so no write is refused for an unknown action; src/modules/catalog/application/catalog-audit-vocabulary.spec.ts › records each transition under its own action, a variant restore included |
| A new entry whose item is archived or of another catalog; a newly selected archived variant; one already on the section archived since | 422 · catalog.menu_item_unavailable naming the field; the kept one stays | test/e2e/catalog-menus.e2e-spec.ts › refuses an archived item as a new entry naming the field and shows a deactivated one as unavailable; src/modules/catalog/domain/menus/menu-judgement.spec.ts › refuses a new item that is archived or of another catalog, but keeps one already on that section; src/modules/catalog/domain/menus/menu-judgement.spec.ts › refuses a newly selected archived variant even on an entry already on the menu; src/modules/catalog/domain/menus/menu-judgement.spec.ts › accepts live items of the catalog in a new subsection |
| A deactivated item; a suspended section; an inactive selected variant next to an active one | isAvailable: false and availableEntryCount without it; every entry under a suspended section, its subsections included, unavailable; available while one offered variant is active | test/e2e/catalog-menus.e2e-spec.ts › refuses an archived item as a new entry naming the field and shows a deactivated one as unavailable; src/modules/catalog/application/menus/menu-view.spec.ts › counts the shown entries and calls one under a suspended section unavailable; src/modules/catalog/application/menus/menu-view.spec.ts › calls every entry under a suspended top section unavailable, its subsections included; src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › shows a selected inactive variant as offered and the entry available while another variant is active; src/modules/catalog/domain/menus/menu-lifecycle.spec.ts › calls an entry available when it, its sections and its item are active and one of its variants is; src/modules/catalog/domain/menus/menu-lifecycle.spec.ts › calls it unavailable when %s |
| An unknown section, entry, item or variant; an entry id with another item or section; a variant of another item; a code another section holds or held | 404 · catalog.not_found, 422 · catalog.menu_entry_mismatch, 422 · catalog.menu_variant_mismatch, 409 · catalog.code_taken, each with meta.errors[] naming the field and how to fix it; the first kind by precedence wins | src/modules/catalog/domain/menus/menu-judgement.spec.ts › points at an unknown section or entry with not_found; src/modules/catalog/domain/menus/menu-judgement.spec.ts › refuses an entry id sent with another item or under another section; src/modules/catalog/domain/menus/menu-judgement.spec.ts › refuses an unknown item or variant and a variant of another item, naming each field; src/modules/catalog/domain/menus/menu-judgement.spec.ts › refuses a code another section of the menu holds, retired sections included; src/modules/catalog/presentation/failures/menu-refusal-failure.spec.ts › answers %s with %i and %s, pointing meta.errors at the field with a message that says how to fix it; src/modules/catalog/presentation/failures/menu-refusal-failure.spec.ts › answers the kind that comes first when several are refused, listing only its fields; src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › refuses an archived item and a variant of another item with typed refusals naming the field, writing nothing |
Two sections that resolve to the same stored section: one by sectionId, another without it but with that section's code | 400 · foundation.validation_failed with meta.errors[] at the alias (sections[1].code, or .sectionId), before any lookup; never reaches the upserts | src/modules/catalog/domain/menus/menu-judgement.spec.ts › refuses two sections that resolve to the same stored section, one by its id and another by its code, pointing at the alias; test/e2e/catalog-menus.e2e-spec.ts › answers 404 for another organization, 403 to a member without the permission and 400 with pointers for broken rules |
| An otherwise unchanged structure with an unknown entry id | refused before the tree is compared or any item is read | src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › judges the ids sent back before comparing, so an unchanged structure with an unknown entry id is refused |
| Another organization; a cashier; a foreign item; selected without variants; a third level | 404; 403 · catalog.action_forbidden on the structure, the create and the read; 404 at sections[0].entries[0].itemId; 400 at sections[0].entries[0].variantIds; 400 at sections[0].subsections[0].subsections | test/e2e/catalog-menus.e2e-spec.ts › answers 404 for another organization, 403 to a member without the permission and 400 with pointers for broken rules |
| Rules that need no lookup | 400 · foundation.validation_failed with the field: selected without variants, all with a list, a variant twice, an item twice in a section, a section, code or entry twice in the tree, a third level, more than 50 sections or 1,000 entries in all or 200 in a section, an unknown variant mode or field; an empty list is accepted; a section or variant sent twice reaching the use case is refused before any lookup | src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › points at selected variants without any, all variants with a list and a variant sent twice; src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › points at an item twice in a section and a section, code or entry sent twice anywhere in the tree; src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › refuses a third level, too many sections or entries, an unknown variant mode and unknown fields such as a price; src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › accepts an empty list, which empties the menu; src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › accepts the documented example and fills the defaults of a section and an entry; src/modules/catalog/domain/menus/menu-judgement.spec.ts › refuses a section, code, entry, item per section or variant per entry sent twice, before anything else |
| A lapsed plan | a structure with sections, a create and an activation 403 · platform.capability_not_entitled; emptying a draft menu and archiving 200 | test/e2e/catalog-menus.e2e-spec.ts › refuses every change but emptying or archiving the menu on a lapsed plan; src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › empties a draft menu on a lapsed plan and refuses adding a section; src/modules/catalog/domain/menus/menu-structure.spec.ts › needs no plan only to empty the menu |
| An unknown or archived menu | 404 · catalog.not_found; 422 · catalog.menu_archived | src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts › answers not found for an unknown menu and refuses an archived one |
| Read and list | any menu, archived ones included, after catalog.items.view; the list with counts by name; nothing locked or recorded; 403 before reading | src/modules/catalog/application/menus/read-menus.use-case.spec.ts › reads any menu, archived ones included, and lists the menus after the view permission, without locking or recording anything; src/modules/catalog/application/menus/read-menus.use-case.spec.ts › answers not found for an unknown menu and forbidden without the permission, before reading; src/modules/catalog/presentation/dtos/menus/menu-response.spec.ts › documents examples that the schemas accept |
| Create, list, replay, rename, taken codes | 201 draft (a code derived from its name when none is sent, -2 on a repeat); the list by name with sectionCount and entryCount; the same key and body replays 201 with Idempotent-Replayed: true; a code taken ignoring case 409 · catalog.code_taken on create and rename; a rename changes the tag and records only what changed | test/e2e/catalog-menus.e2e-spec.ts › lists the menus with their counts, renames one, replays a create and refuses a taken code; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › creates a draft menu after the plan, records its code and name and answers it empty; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › derives the code from the name when none is sent, skipping the codes the catalog already holds; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › renames a menu writing and recording only what changed; src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › answers an unchanged name without a write or a record, and refuses an archived menu; src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › creates with a name and an optional code, and refuses a status or an empty name; src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › edits a name or a code, at least one, and nothing else |
| The 101st menu, archived ones counted | 422 · catalog.menu_limit_reached, nothing written | src/modules/catalog/application/menus/menu-commands.use-case.spec.ts › refuses a menu beyond the limit, archived ones included, and a lapsed plan |
| Statement budget | PUT .../structure 18 statements for one or three sections (session, membership, BEGIN, tenant context, permission, version lock, menu lock, capability, candidates, retire sections, upsert sections, retire entries, upsert entries, delete selections, upsert selections, re-read, audit, COMMIT), 8 for an unchanged tree, GET 7, activation 10 | test/e2e/statement-budget.e2e-spec.ts › sets, reads and activates a menu in the measured statements, whatever the number of sections and entries |
| Selected variants | written in order; switching the entry to all deletes them and lists every variant that is not archived | src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts › writes selected variants in order, drops them when the entry offers all variants again, and lists every active variant then |
Suspend and resume a section or an entry through PUT .../structure | suspended: true writes it inactive and answers suspended: true; the entries of a suspended section and a suspended entry leave availableEntryCount; sending them without the flag resumes them with the same ids; a retired row comes back as sent | test/e2e/catalog-menu-suspension.e2e-spec.ts › derives the codes of a menu and its sections from their names, writes suspensions, counts them out of availability and resumes them, sent by their derived codes, with the same ids; src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts › revives a retired section or entry it writes again, and writes the status the plan sends: a suspended entry resumes, a section is suspended; src/modules/catalog/domain/menus/menu-structure.spec.ts › writes a suspended section or entry as inactive and sees the suspension as a change |
| The locking read | answers what the plain read answers; an unknown menu answers nothing; list and count agree | src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts › locks the menu with its structure in the same statement and answers what the read answers; an unknown menu answers nothing |
| Runtime privileges, version triggers and audit vocabulary | SELECT, INSERT, UPDATE, never DELETE nor TRUNCATE, on menus, sections and entries; DELETE on the selected variants only; EXECUTE on the entry check; one bump per statement kind on insert, update and delete of the four tables; a no-op upsert and an UPDATE that changes nothing do not bump; five audit actions and the catalog.menu resource kind | database/tests/contracts/catalog-menus-contracts.ts › one INSERT of two entries moves the catalog version once; database/tests/contracts/catalog-menus-contracts.ts › the same upsert again, a no-op, leaves the catalog version as it was; database/tests/contracts/catalog-menus-contracts.ts › an UPDATE that changes no entry leaves the catalog version as it was; database/tests/contracts/catalog-menus-contracts.ts › archiving a menu is an UPDATE that moves the version once; database/tests/contracts/catalog-menus-contracts.ts › the runtime may delete a selected variant of an entry, which nothing references, but never truncate them; database/tests/contracts/catalog-menus-contracts.ts › the runtime cannot delete a menu entry, not even its own; database/tests/contracts/catalog-menus-contracts.ts › the runtime cannot delete a menu section, not even its own; database/tests/contracts/catalog-menus-contracts.ts › the runtime cannot delete a menu, not even its own; database/tests/contracts/catalog-menus-contracts.ts › the five menu commands have their audit actions, configuration, medium, before and after |
| SQL checks | a section and an entry can be retired; another status is refused; a creation hash without its key is refused and a key is used once; an entry in selected mode without a variant is refused at commit | database/tests/contracts/catalog-menus-contracts.ts › a section and an entry can be retired; database/tests/contracts/catalog-menus-contracts.ts › a section status other than active, inactive or retired is refused; database/tests/contracts/catalog-menus-contracts.ts › an entry status other than active, inactive or retired is refused; database/tests/contracts/catalog-menus-contracts.ts › a creation request hash without its key is refused; database/tests/contracts/catalog-menus-contracts.ts › a creation key is used once per organization; database/tests/contracts/catalog-menus-contracts.ts › an entry in selected mode without any variant is refused when the transaction commits |
| Another tenant | sees and updates none of the three tables; a menu naming a foreign organization is refused (42501); the repository finds no item, variant or menu of another organization | database/tests/contracts/catalog-menus-contracts.ts › a menu naming a foreign organization is refused; src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts › answers the items and variants it finds, wherever they belong, and reads nothing of another organization |
| Extra application fields | never reach the answer: an entry and a variant carry exactly their documented keys | src/modules/catalog/presentation/dtos/menus/menu-response.spec.ts › answers exactly the documented fields of an entry and a variant, whatever else the application carries |
| Write failures | the section code index, the entry item foreign keys and the selected-variant foreign keys map to typed refusals keeping the cause; the menu code index to catalog.code_taken; any other failure passes through | src/modules/catalog/infrastructure/menus/menu-write-failure.spec.ts › maps %s on %s to a %s refusal that keeps the database error as its cause; src/modules/catalog/infrastructure/menus/menu-write-failure.spec.ts › maps a taken menu code to the code refusal of the catalog; src/modules/catalog/infrastructure/menus/menu-write-failure.spec.ts › lets any other failure through unchanged, a violation of another constraint included; src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts › maps a taken menu code and an item of another catalog to typed refusals that keep the cause; src/modules/catalog/presentation/failures/menu-refusal-failure.spec.ts › answers the %s refusal with 422 %s |
| Audit summary | the live sections with their names and the entries in tree order; null for an empty menu | src/modules/catalog/domain/menus/menu-structure.spec.ts › summarizes the live sections with their names and the entries, and answers null for an empty menu |
C29 — Choose which menus an outlet shows, and in what order
GET and PUT /catalog/outlets/{outletId}/menus with { menuIds }; the read needs catalog.items.view or catalog.assortment.manage at the outlet, the write catalog.assortment.manage at the outlet, gated by catalog.items unless the list is empty.
| Case | Expected answer | Proving test |
|---|---|---|
| An outlet nobody assigned menus to; two menus set in order with the tag read; revalidation | 200 { outletId, menus: [] }, private, no-cache; the PUT answers 200 with the menus in the order sent and a new strong tag; GET answers the same body and tag; If-None-Match answers 304 with no body; positions 0 and 1 stored; one catalog.outlet.menus_set record with the ids after | test/e2e/catalog-outlet-menus.e2e-spec.ts › reads an outlet that shows nothing, sets two menus in order with the tag it read, and revalidates with 304 |
| The same menus in the same order; a new order of the same menus | 200, nothing written, no record, the catalog version unchanged; a new order is a change: written and recorded once | test/e2e/catalog-outlet-menus.e2e-spec.ts › answers the same menus in the same order with nothing written, and writes a new order once; src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › answers the same menus in the same order with nothing written or recorded; src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › writes a new order of the same menus, reading no candidate |
| The till of the outlet after its menus change | its tag moves (the catalog version moved); what it sells is the same: menus only organize | test/e2e/catalog-outlet-menus.e2e-spec.ts › moves the till tag when the menus of the outlet change, and never changes what the till sells |
If-Match missing or *; a stale tag | 428 precondition.required; 412 foundation.precondition_failed, checked after the version lock and the read; nothing written | test/e2e/catalog-outlet-menus.e2e-spec.ts › requires If-Match, refuses a stale one and writes nothing; src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › refuses a stale If-Match after the version lock and accepts the tag the read answered |
| A new menu unknown, of another organization or archived; eleven menus; a repeated menu | 404 catalog.not_found at menuIds[i]; 422 catalog.menu_archived at menuIds[i]; 400 foundation.validation_failed at menuIds and at the repetition; an unknown menu wins over an archived one; nothing written | test/e2e/catalog-outlet-menus.e2e-spec.ts › points at the menu it refuses: unknown, of another organization, archived, too many or repeated; src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › refuses an unknown menu, or one of another organization, before an archived one; src/modules/catalog/presentation/failures/outlet-menus-refusal-failure.spec.ts › answers an unknown menu first when another one is archived, listing only the unknown ones |
| A menu archived after it was assigned | stays listed with status archived and keeps its place when the list is sent again; unpublishing and archiving an assigned menu keep working | test/e2e/catalog-outlet-menus.e2e-spec.ts › keeps showing a menu archived after it was assigned, with its status, and its menu transitions keep working; src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › keeps an assigned menu that was archived since, and refuses one that starts being shown archived, pointing at it |
| Order of the steps of a write | catalog.assortment.manage with the plan at the outlet, then the catalog version row, then the stored list, the new menus only, one delete and one insert, one record, the read of the answer | src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › authorizes catalog.assortment.manage with the plan at the outlet, locks the catalog version before reading, then writes the set and records one change |
| An empty list | never gated by the plan and reads no menu; one record with the ids before and null after | src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › empties an outlet without the plan and without reading any menu; test/e2e/catalog-outlet-menus-access.e2e-spec.ts › gates every list but the empty one by the plan, so taking every menu off an outlet always works |
| A cashier, and a manager of another outlet, at this outlet or an unknown one | the same plain 403 catalog.action_forbidden on both routes; the write refusals are audited permission_missing; nothing written | test/e2e/catalog-outlet-menus-access.e2e-spec.ts › answers a cashier, and a manager of another outlet, the same plain denial on both routes whether the outlet exists or not, audits the write refusals and writes nothing |
| A manager assigned to the outlet | reads and sets its menus | test/e2e/catalog-outlet-menus-access.e2e-spec.ts › lets a manager assigned to the outlet read and set its menus; src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › lets a member who sets the menus read them without catalog.items.view |
| The organization-wide owner at an unknown outlet or one of another organization | 404 organization.structure_not_found on both routes, empty list or not | test/e2e/catalog-outlet-menus-access.e2e-spec.ts › tells the organization-wide owner that an unknown outlet, or one of another organization, does not exist; src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts › tells only an organization-wide holder that the outlet does not exist, and refuses a closed outlet with its own reason |
| A list at an outlet that is not operating | 422 authorization.outlet_not_operational, audited; the empty list still works | test/e2e/catalog-outlet-menus-access.e2e-spec.ts › refuses a list at an outlet that is not operating, and still lets it be emptied |
| Body rules | 0 to 10 ids in order; an eleventh at menuIds; each repetition at its position; a missing list, a malformed id and any other field refused | src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts › refuses more menus than an outlet can show, pointing at the list; src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts › points one issue at each repeated menu; src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts › refuses a missing list, a malformed id and any other field |
| Statements | GET 7, 304 7; PUT 15 whatever the number of menus, 14 without a stored list or without a new menu, 13 to empty, 10 for the same list | test/e2e/catalog-outlet-menus-statement-budget.e2e-spec.ts › reads the menus of an outlet in 7 statements and answers 304 in the same, whatever the number of menus; test/e2e/catalog-outlet-menus-statement-budget.e2e-spec.ts › sets the menus of an outlet in 15 statements whatever their number, 14 without a stored list to delete or without a new menu to judge, 13 to empty it and 10 for the same list, which records nothing |
| Repository | an unknown outlet told apart; the order sent kept; one version move per statement; only the menus of the organization judged | src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts › reads an outlet that shows nothing, and tells an unknown outlet apart; src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts › writes the menus in the order sent, replaces them as one set and moves the catalog version once per statement; src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts › answers the status of the menus of the organization only, and another organization sees no assignment |
| SQL table | one menu once per outlet, one menu per position, positions 0 to 9; no UPDATE for the runtime; one version move per statement; the organization of a row never changes | database/tests/contracts/catalog-outlet-menus-contracts.ts › the same menu twice at one outlet is refused; database/tests/contracts/catalog-outlet-menus-contracts.ts › two menus at the same position of one outlet are refused; database/tests/contracts/catalog-outlet-menus-contracts.ts › one INSERT of two assignments moves the catalog version once; database/tests/contracts/catalog-outlet-menus-contracts.ts › an assignment never moves to another organization |
Outlet hidden items
Slice K16 (migration 0106, baseline revision 116): the items of the assortment of its business unit that an outlet does not sell (CATALOG_USE_CASES.md, "K16 as built"). An outlet can only hide; it never sells what its unit does not list.
C30 — Choose which items an outlet does not sell
GET and PUT /catalog/outlets/{outletId}/hidden-items with { itemIds }; the read needs catalog.items.view or catalog.assortment.manage at the outlet, the write catalog.assortment.manage at the outlet, gated by catalog.items unless the set is empty.
| Case | Expected answer | Proving test |
|---|---|---|
| An outlet that hides nothing; two items set with the tag read; revalidation | 200 { outletId, items: [] }, private, no-cache; the PUT answers 200 with the items by name, each { itemId, code, name, status }, and a new strong tag; GET answers the same body and tag; If-None-Match answers 304 with no body; the ids stored and one catalog.outlet.hidden_items_set record with the sorted ids | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › reads an outlet that hides nothing, sets two items with the tag it read, and revalidates with 304 |
| The same set in any order; a different set | 200, nothing written, no record, the catalog version unchanged; a different set is written and recorded once | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › answers the same set in any order with nothing written, and writes a different set once; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › answers the same set in any order with nothing written or recorded |
| The till of the outlet after its set changes | the item leaves the till of that outlet, the till tag moves and the tag it sent before no longer answers 304; an empty set sells it again | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › takes the item out of the till of that outlet, moves the till tag, and the tag it had no longer answers 304 |
If-Match missing or *; a stale tag | 428 precondition.required; 412 foundation.precondition_failed, checked after the version lock and the read; nothing written | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › requires If-Match, refuses a stale one and writes nothing; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › refuses a stale If-Match after the version lock and accepts the tag the read answered |
| Two writes that name the same tag | one 200 and one 412, and the stored set is the winner's | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › answers 200 to one of two writes that name the same tag and 412 to the other |
| An item unknown or of another organization; 501 items; a repeated item | 404 catalog.not_found at itemIds[i] with a message that says how to fix it; 400 foundation.validation_failed at itemIds and at the repetition; nothing written | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › points at the item it refuses: unknown, of another organization, too many or repeated; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › refuses an unknown item, or one of another organization, pointing at its position, and writes nothing; src/modules/catalog/presentation/failures/outlet-hidden-items-refusal-failure.spec.ts › answers an unknown item with 404 catalog.not_found, pointing meta.errors at every unknown position with a message that says how to fix it; src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts › points at the position of every requested item the organization does not have; src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts › judges only the items that start being hidden |
| An item archived or deactivated after it was hidden; an archive racing a write | stays listed with its status until it is taken out; an item of any status can be hidden; the race ends in one consistent state (the write 200 or 412, never half) | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › keeps an item archived after it was hidden, with its status, and an archive racing a write leaves one consistent state; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › hides an item of any status, so an archived or deactivated one can be hidden too |
| Order of the steps of a write | catalog.assortment.manage with the plan at the outlet, then the catalog version row, then the stored set, the new items only, one delete and one insert, one record, the read of the answer | src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › authorizes catalog.assortment.manage with the plan at the outlet, locks the catalog version before reading, then writes the set and records one change |
| An empty set | never gated by the plan and reads no item; one record with the ids before and null after | src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › makes the outlet sell everything again without the plan and without reading any item; test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › gates every set but the empty one by the plan, so making an outlet sell everything again always works |
| A cashier, and a manager of another outlet, at this outlet or an unknown one | the same plain 403 catalog.action_forbidden on both routes; the write refusals are audited permission_missing; nothing written | test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › answers a cashier, and a manager of another outlet, the same plain denial on both routes whether the outlet exists or not, audits the write refusals and writes nothing; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › records the refusal of a member without the permission and answers the plain 403; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › refuses a caller without either permission with the plain 403, without learning whether the outlet exists |
| A manager assigned to the outlet | reads and sets its hidden items, and neither reads nor sets those of a sibling outlet (the sibling write is sent and answers the plain 403, its stored set unchanged) | test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › lets a manager assigned to the outlet read and set its hidden items, and neither read nor set those of a sibling outlet; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › lets a member who sets the hidden items read them without catalog.items.view; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › decides catalog.items.view at the outlet, then reads the hidden items by name in one step |
| The organization-wide owner at an unknown outlet or one of another organization | 404 organization.structure_not_found on both routes, empty set or not; an admitted caller at an unknown outlet 404 | test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › tells the organization-wide owner that an unknown outlet, or one of another organization, does not exist; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › tells a holder refused at an unknown outlet that it does not exist only when it holds a permission organization-wide; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › answers 404 to an admitted caller when the outlet does not exist; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › tells only an organization-wide holder that the outlet does not exist, and refuses a closed outlet with its own reason |
| A set at an outlet that is not operating | 422 authorization.outlet_not_operational, audited; the empty set still works | test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › refuses a set at an outlet that is not operating, and still lets it be emptied |
| Another organization's items and outlets | an item of another organization is 404 and nothing is hidden; the outlet of another organization is 404 for the reader | test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › never lets an organization hide, or read, what belongs to another one |
| Body rules | 0 to 500 ids; a 501st at itemIds; each repetition at its position; a missing list, a malformed id and any other field refused; the answer carries exactly itemId, code, name and status | src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts › takes no item, one, or up to the limit, in the order sent; src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts › refuses more items than an outlet can hide, pointing at the list; src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts › points one issue at each repeated item; src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts › refuses a missing list, a malformed id and any other field; src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts › answers the outlet and, for each hidden item, its id, code, name and status only |
| What the set means | compared as a set, not as an order; only the items that start being hidden are judged | src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts › compares the sets and not the order, because hiding has no order; src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts › tells a different set from the stored one; src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts › keeps the requested items the outlet does not hide yet, in the requested order |
| Till list, search, scan and sellable facts of the outlet that hides an item; its sibling outlet of the same unit | the item is absent from the list, the search and the scan and is not listed in the facts of that outlet; the sibling lists, finds, scans and sells it; no outlet is the unit-level answer; shown again, it is sold again | src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts › leaves the till list, the search, the scan and the sellable facts of that outlet only, and the sibling outlet of the same unit keeps selling it |
| An item the unit does not list | never sold at any outlet, hidden or not: an outlet can only hide | src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts › never makes an outlet sell what its business unit does not list |
| An outlet that moves to another business unit that lists the item too | the move is proven first (the outlet operates the new unit, the old unit is refused for it with outlet_not_operated_by_unit); the hidden item stays hidden there, a sibling outlet of the new unit sells it, showing it again sells it | src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts › stays hidden after the outlet moves to another unit that lists the item too, while a sibling outlet of the new unit sells it and the move itself is proven to have taken effect; test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts › keeps the hidden item hidden after the outlet moves to another business unit that lists it too, while a sibling outlet of the new unit sells it and the move is proven to have taken effect |
| A menu entry of a hidden item | leaves the menus of that outlet only; the sibling keeps it | src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts › leaves the entries of the menus it appears in at that outlet only; test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts › takes the entry of a hidden item out of the menus of that outlet only |
| A combo whose only component the outlet hides | incomplete at that outlet, in every read and in the admission (not_listed at the choice), complete at the sibling and again when shown | src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts › makes a combo whose only component it hides incomplete at that outlet and sellable at the sibling; test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts › makes a combo whose only component the outlet hides incomplete there, in every read and in the admission, and complete at the sibling |
| An option that targets an item, in a group an item that is itself sold offers, while the target is listed and shown | available at every outlet of the unit: in the till list and its group options, the search, resolve and admit, with an outlet and without one | test/e2e/catalog-option-target-listing.e2e-spec.ts › offers the option at every outlet, in every read and in the admission, while its target is listed and shown |
| The unit delists the target from an assortment that lists everything; it lists it again | the option is unavailable at every outlet of the unit and at the unit level (resolve, admit without outletId): an optional group leaves the offers and the item stays on sale, a required group with one option leaves the item out of the lists and the scan (required_modifier_unsatisfiable), a required group with a second option keeps the item with that option alone; selecting the delisted option is refused option_unavailable; listing it again restores all of it | test/e2e/catalog-option-target-listing.e2e-spec.ts › takes the option away from every outlet of a unit that delists its target from an assortment that lists everything, and offers it again when the target is listed |
| The unit has a selected assortment that lists the offering items but not the target | the same answers as a delisted target | test/e2e/catalog-option-target-listing.e2e-spec.ts › takes the option away from a unit whose selected assortment lists the offering items but not the target |
| An outlet hides the target; its sibling outlet of the same unit does not; the outlet shows it again | the option is unavailable at the outlet that hides it, in every read and in the admission, and available at the sibling outlet and at the unit level, where only the delisting applies; showing it restores the option | test/e2e/catalog-option-target-listing.e2e-spec.ts › takes the option away from the outlet that hides its target only, keeps it at the sibling outlet and at the unit level, and offers it again when the target is shown |
| The target is a combo that cannot be composed | the option is unavailable at every outlet, judged as a combo component is (bundleComposable) | test/e2e/catalog-option-target-listing.e2e-spec.ts › takes the option away from every outlet when its target is a combo that cannot be composed |
| A combo whose only component needs an option whose target the unit delists | incomplete in every read (bundle_group_unsatisfiable) and complete again when the target is listed: the options of a combo component are judged by the same scope | test/e2e/catalog-option-target-listing.e2e-spec.ts › leaves out a combo whose only component cannot be configured any more because the target of its required option is delisted, in every read, and brings it back when the target is listed |
| The target is a combo that could be composed | the option is unavailable too: an option sells a plain item | test/e2e/catalog-option-target-listing.e2e-spec.ts › takes the option away when its target is a combo that could be composed, because an option sells a plain item |
| The statements judge the targets where they read them | the scan and the filter of the search judge the target of each option they look at (inline); the list builds option_targets for the organization, resolve for the items it serves and their combo components, the page of the search page_targets for the items of the page; the writes keep the structural activeOption | src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › asks a set of targets the statement judged once, with a keyed lookup and no probe per option, when the scope carries one; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › judges the target of each option where it is read, as the unit and outlet of the reader would sell it, when the scope is inline: an active variant of an active item that is listed, not hidden and opens no group; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › judges each distinct active target once per statement with the one target rule, listing and hiding included; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › judges every active target of the organization when it is not told which items it serves, as the list does; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › judges only the targets of the options offered by the items it is told about, and by the components of their combos when asked: the page of the search and resolve; src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › is built by the list and resolve for their targets, by the search for the items of its page only, and not at all by the scan, which judges its options where it reads them |
| A group whose option sells a variant of a combo; the same group on a plain variant | 422 · catalog.option_target_not_plain, nothing written; 201 on the plain variant | test/e2e/catalog-option-target-plain.e2e-spec.ts › refuses a group whose option sells a variant of a combo with 422, writing nothing, and creates the same group on a plain variant |
| A new option on a variant of a combo in a set; a plain one | 422 · catalog.option_target_not_plain at options[i].variantId, nothing written; 200 on the plain variant | test/e2e/catalog-option-target-plain.e2e-spec.ts › refuses a new option on a variant of a combo in a set with 422 at the option, writing nothing, and accepts a plain one |
| The domain and the use cases judge a combo variant after unknown and inactive ones | the target is refused as not plain once it is known and active, in the create and in the set, before any write | src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts › refuses a new option whose variant belongs to a combo as not plain, once the variant is known and active, and keeps judging the others; src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts › points at a new option whose variant belongs to a combo as not plain after share-locking it, writing nothing; src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts › refuses a group whose option sells a variant of a combo as not plain, after share-locking it and before any write |
| The variants are locked with their items and read for combos | the variant and its item are share-locked in id order and the same statement reports whether the item opens a group, with the live-group fragment of the read; a combo variant is reported not plain | src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts › says in the statement that locks the variants whether each belongs to a combo, with the live-group fragment the till uses, so a write and a read agree on what a plain item is; src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts › locks the group for update and shares the variants and the offered groups in id order; src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts › locks the variants and says which of them belong to a combo, judging a combo by its live groups and a plain item by having none |
| The back-office item read (C3) | unchanged: it shows the stored status of each option and its target, which has no unit to be judged at | src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts › needs an active option whose target variant, when it has one, is active and belongs to an active item, the structural rule of the writes, which have no unit to judge listing for |
The list, the search, the scan, resolve and admit on one hidden item | all four agree: not listed, not found, not scanned, not_listed at lines[0]; the sibling outlet sells it in all four | test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts › refuses an item with the not-listed vocabulary at the outlet that hides it everywhere at once, while the sibling outlet of the same unit sells it, and sells it again when it is shown |
| One SQL fragment | the probe is one keyed lookup by organization, outlet and item, composed once and found in the list, the search, the scan, the facts and every combo level; without an outlet no statement mentions the table | src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › is one keyed probe of the hidden items by organization, outlet and item, asked for one row; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › adds the hidden probe of the outlet to the assortment of the unit when the scope has an outlet; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › is the assortment of the unit alone when the scope has no outlet; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › judges the hidden set through the one shared listing fragment; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › asks the hidden set of the outlet about the component of every combo level, so a hidden component is unavailable exactly like an unlisted one; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › is the very text bundleComposable counts the choices of a combo with; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › keep the hidden items of the outlet the statement already carries, with no parameter other than the instant of the read; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › keep the scan and the sellable facts on the same fragment; src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts › never write the hidden table |
| An outlet that does not exist, one of another organization, one another unit operates, no outlet at all (resolve and admit) | outlet_not_operated_by_unit for the first three, in the verdict and in the admission, judged in the same statement as the rest and right after business_unit_unavailable; no outlet keeps judging the unit alone, a documented mode of the contract | test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts › refuses, at resolve and at admit, an outlet that does not exist, one of another organization and one another unit operates, and keeps judging the unit alone without an outlet; src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts › fails closed on an outlet the business unit does not operate: one that does not exist, one of another organization and one another unit operates today, while no outlet at all keeps judging the unit alone; src/modules/catalog/domain/sellable/sellability.spec.ts › refuses an outlet the business unit does not operate right after the unit and before any fact about the item, naming the outlet |
variant_is_listed_at_outlet with an outlet that does not exist, one of another organization, one another unit operates, NULL, and a NULL unit | false in every case (fails closed); true for an outlet the unit operates, so the refusals come from the outlet alone; the unit-level function keeps answering without any outlet | database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the unit lists is not listed at an outlet that does not exist: the function fails closed; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the unit lists is not listed at an outlet of another organization: the function fails closed; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the unit lists is not listed at an outlet that another business unit operates today: the function fails closed; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the unit lists is not listed at no outlet at all (NULL): the function fails closed; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the unit lists is not listed at no business unit (NULL): the function fails closed; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › the same item is listed at an outlet the unit operates, so the refusals above come from the outlet alone; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › the unit-level function keeps answering without any outlet |
| An item sent twice | the schema refuses it at the position of the repeat (400, itemIds[1]) and the stored set, the catalog version and the audit stay as they were; the use case, reached by other callers, treats the list as a set: [A, A] over [A] is a no-op and [B, A, B, A] writes and records A,B once | test/e2e/catalog-outlet-hidden-items.e2e-spec.ts › refuses an item sent twice at the position of the repeat and leaves the stored set, the catalog version and the audit as they were; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › treats a set as a set: an item sent twice is the item once, so the same set sent with a repeat is still a no-op that writes and records nothing; src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts › writes and records a repeated item once, in the order first sent; src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts › keeps each item once, in the order it was first sent |
| An outlet id that is not a UUID, sent by a member who holds no permission | 400 foundation.validation_failed on both routes, before any permission is decided; the same member gets 403 catalog.action_forbidden for a well-formed id | test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › answers 400 to an outlet id that is not a UUID on both routes before any permission is decided, to a member who holds none, and 403 to the same member for a well-formed id |
| The generated OpenAPI document | the read and its 304 declare the ETag header (the only answers a conditional read documents; the semantic check refuses it on a write) and the text of the write says the new tag comes in the ETag response header | src/modules/catalog/presentation/controllers/outlet-hidden-items/outlet-hidden-items-route-documentation.spec.ts › declares the ETag header on the read and its 304, the only answers a conditional read documents; src/modules/catalog/presentation/controllers/outlet-hidden-items/outlet-hidden-items-route-documentation.spec.ts › tells in the text of the write, as every write of the catalog does, that the new tag comes in the ETag header of the answer |
| An operated outlet, a closed one, one without a business unit, an unknown id, an outlet of another organization | the presence check, the menus read and the hidden items read give the same answer: known for the first three, unknown for the last two (one shared predicate, outletIsKnown) | src/modules/catalog/infrastructure/outlets/outlet-presence.integration-spec.ts › is the answer of the presence check, of the hidden items read and of the menus read for an operated outlet, a closed one, one without a business unit, an unknown id and an outlet of another organization |
| Statements | GET 7, 304 7; PUT 15 whatever the number of items, 14 without a stored set or without a new item, 13 to empty, 10 for the same set in any order | test/e2e/catalog-outlet-hidden-items-statement-budget.e2e-spec.ts › reads the hidden items of an outlet in the same statements as its menus and answers 304 in the same, whatever the number of items; test/e2e/catalog-outlet-hidden-items-statement-budget.e2e-spec.ts › sets the hidden items in 15 statements whatever their number, 14 without a stored set to delete or without a new item to judge, 13 to empty it and 10 for the same set, which records nothing |
| A write of the menus or the hidden items, a replacement or a clearing, by a caller anchored only at the old unit, after the outlet moved | the plain 403 catalog.action_forbidden; nothing written, no success record, no catalog version move | test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts › refuses $label by a caller anchored only at the old unit once the outlet moved before the first permission decision, and writes nothing |
| The reassignment commits between the first permission decision and the lock | the second decision refuses with the plain 403; nothing written | test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts › refuses $label when the reassignment commits between the first permission decision and the lock, and writes nothing |
| A reassignment starts between the lock and the second decision, or after it | it waits for the write to commit; the write is answered 200 for the unit it started with and the outlet moves afterwards | test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts › holds back a reassignment that starts $label between the lock and the re-check until the write commits; test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts › holds back a reassignment that starts $label after the re-check until the write commits |
| A write, a queued reassignment and a second write behind it | all finish, no deadlock; the first 200, the second the plain 403 | test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts › lets a write, a queued reassignment and a second write behind it all finish without a deadlock, the second one refused |
| A write that waits behind a reassignment longer than the lock timeout | 503 service.unavailable as Problem Details, nothing written | test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts › answers the taught 503 and writes nothing when $label waits behind a reassignment longer than the lock timeout |
| A caller refused after the lock | exactly one denial audit record, in the transaction of the refusal, and nothing else written | test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts › records exactly one denial, in the transaction of the refusal, for a caller refused after the lock |
The GET of the menus and of the hidden items | one read-only snapshot, never a plain transaction | test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts › reads the menus and the hidden items of an outlet in one read-only snapshot |
| Repository | an unknown outlet told apart; one version move per statement; only the items of the organization judged; another organization sees no hidden item | src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.repository.integration-spec.ts › reads an outlet that hides nothing, and tells an unknown outlet apart; src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.repository.integration-spec.ts › replaces the set as one delete and one insert, reads it by name and moves the catalog version once per statement; src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.repository.integration-spec.ts › answers the items of the organization only, and another organization sees no hidden item of this outlet |
| SQL table and function | one row per outlet and item; the primary key leads with the organization and the outlet; no UPDATE for the runtime; one version move per statement; the organization of a row never changes; foreign keys keep items and outlets of one organization; variant_is_listed_at_outlet hides every variant of a hidden item at that outlet only and never adds an item the unit does not list | database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › the same item twice at one outlet is refused; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › the runtime cannot update a hidden item: the set is deleted and inserted again; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › one INSERT of two hidden items moves the catalog version once; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › the primary key leads with the organization and the outlet, so the till probe is a keyed lookup; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › the hidden_items_set audit action is seeded; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the unit does not list is not listed at any outlet; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the unit lists and the outlet does not hide is listed; database/tests/contracts/catalog-outlet-hidden-items-contracts.ts › an item the outlet does not hide stays listed at it |
Menu time windows
Slice S1 (migration 0107, baseline revision 117): the hours in which an outlet shows each of its menus (CATALOG_USE_CASES.md, C31; CATALOG_OPTIONS_V2.md, "S1 as built"). The windows ride on PUT /catalog/outlets/{outletId}/menus and the till read keeps the menus that are open at the instant of the read.
C31 — Choose the hours in which an outlet shows each of its menus
| Case | Expected answer | Proving test |
|---|---|---|
| Windows set with the tag read, read back, revalidated | 200 with each menu carrying windows in canonical order (by hour, then days ascending), a window without days opens every weekday, the tag of the read equals the one written and If-None-Match answers 304; the rows stored with spans_midnight for a window that crosses midnight | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › sets the windows of each menu, reads them back in canonical order with the tag it wrote, and revalidates with 304; src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts › writes the windows of each menu, reads them in canonical order and replaces them with the menus as one set |
| Only a window changes | the tag moves and one catalog.outlet.menus_set record carries the windows before and after as their own change, and the menus only when they moved | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › moves the tag when only a window changes, and records the windows before and after as their own change; src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts › records the windows before and after as their own change, and the menus only when they moved; src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts › records both changes when the menus and their windows move together |
| The same windows in any order, the days in any order | 200, nothing written, no record, the catalog version unchanged | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › answers the same windows, in any order and with the days in any order, with nothing written or recorded; src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts › answers the same windows, sent in another order, with nothing written or recorded |
A body without windows; a menu taken off the outlet | every menu is always shown and the stored windows are replaced; the windows of a menu that leaves do not outlive it | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › treats a body without windows, the form that predates them, as every menu always shown; test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › keeps the windows of a menu that is taken off the outlet from outliving it; src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts › removes the windows of a menu sent without them, replacing the stored ones |
If-Match missing; a stale tag | 428 precondition.required; 412 foundation.precondition_failed; nothing written | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › requires If-Match for a window write, refuses a stale one and writes nothing |
| Overlapping windows of one menu; a malformed hour; from equal to until; no day, a day outside 1 to 7; a window of a menu not in the list | 422 catalog.menu_windows_overlap naming each window that overlaps an earlier one (windows[2]), midnight and Sunday to Monday included; 400 foundation.validation_failed at the field (windows[0].from, windows[0].until, windows[0].days[0], windows[0].menuId); nothing written | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › teaches what is wrong with a window: overlap is 422 at the later window, the rest is 400 at the field; src/modules/catalog/domain/outlet-menus/menu-window.spec.ts › points at each later window that shares a minute with an earlier one; src/modules/catalog/domain/outlet-menus/menu-window.spec.ts › follows a window from Sunday into Monday |
| Windows that touch; windows of different weekdays at the same hours | accepted | src/modules/catalog/domain/outlet-menus/menu-window.spec.ts › lets windows touch: the end of one is the start of the next; src/modules/catalog/domain/outlet-menus/menu-window.spec.ts › lets windows of different weekdays share the same hours |
| More windows than the limit; a window that crosses midnight; no days | 14 per menu, the first one over it refused at its position; a window that crosses midnight is taken; a window without days opens every weekday | src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts › refuses more windows for one menu than the limit, pointing at the first one over it; src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts › takes a window that crosses midnight; src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts › opens a window without days every weekday |
| A window of a menu of another organization | 404 catalog.not_found, nothing written | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › refuses a window of a menu of another organization like any menu of another organization |
| A cashier, and a manager of another outlet; the plan; a closed outlet | the same plain 403 catalog.action_forbidden, audited; 403 platform.capability_not_entitled; 422 authorization.outlet_not_operational; clearing still works | test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › answers a cashier, and a manager of another outlet, the same plain denial for a window write and writes nothing; test/e2e/catalog-outlet-menu-windows.e2e-spec.ts › gates a window write by the plan and refuses it at a closed outlet, but lets the windows be cleared |
| The weekdays and the hours the till shows each menu | before the window, at the exact start (inclusive), one second before the end, at the exact end (exclusive) when the next menu starts, after the last window, on Friday night and Saturday at 01:59 inside the window that crosses midnight, on Saturday at 02:00, a Sunday; menusValidUntil names the next change in each | test/e2e/catalog-till-menu-windows.e2e-spec.ts › %s; src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts › %s |
| A menu without a window; two menus that overlap in time; none open | a menu without a window is always shown and nothing names a next change; both overlapping menus are shown in the outlet order; menus: [] and every item still sold | test/e2e/catalog-till-menu-windows.e2e-spec.ts › shows a menu without a window always and names no next change when nothing is scheduled; test/e2e/catalog-till-menu-windows.e2e-spec.ts › shows two menus whose windows overlap, in the outlet order; test/e2e/catalog-till-menu-windows.e2e-spec.ts › answers menus [] when none is open and still sells every item |
| Touching windows of one menu | no change is named at the instant one ends and the next starts | src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts › does not name a boundary the set does not change at: a window that continues into the next one |
The tag moves with the clock, never answers 304 for a stale set | the same set at two instants answers 304; the old tag no longer does once the clock opens or closes a window, with no write; the same open menus on another day carry another tag | test/e2e/catalog-till-menu-windows.e2e-spec.ts › moves the tag when the clock opens or closes a window, without any write, and answers 304 only for the set it was read at; src/modules/catalog/application/till/sellable-entity-tag.spec.ts › names the menus shown now and the instant they next change, so it moves with the clock and never with a write |
| One instant for the whole read | the clock is read once and both statements (the one that decides a 304 and the one that builds the body) are asked at that instant; their tag segments agree | src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts › asks the clock once and reads the outlet and the menus of the till at that same instant; src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts › answers the same tag segments to the state read that decides a 304 and to the read that builds the body |
| Daylight saving time; a site that changes its time zone | the window follows the wall clock on the day it changes, the autumn hour that happens twice is open twice, a plain day is judged in local time on both sides; menusValidUntil names the clock change itself when the next boundary lies beyond it, in the first pass of the repeated hour, after it and when a window starts in the hour a spring change skips; the same instant in the new zone shows other menus and the tag moves | test/e2e/catalog-till-menu-windows.e2e-spec.ts › follows the wall clock of a site with daylight saving time on the day the clock changes; test/e2e/catalog-till-menu-windows.e2e-spec.ts › reads the same instant in the new local time when the site changes its time zone, and moves the tag with it; src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts › opens a window whose start was skipped by the spring clock change when the clock reaches it, and names the instant of the clock change; src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts › follows the wall clock through the autumn hour that happens twice; src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts › evaluates a plain day in local time on both sides of the change |
menusValidUntil through the autumn hour that happens twice (America/New_York, 2026-11-01), before it, at the start of the window in the first pass, in the last second of the first pass, after the clock went back, in the second pass and after the window | 05:30:00Z, 06:00:00Z, 06:00:00Z, 06:30:00Z, 07:30:00Z and the next day 06:30:00Z, with the menus shown at each instant | src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts › a window of 01:30 to 02:30 %s |
| A window that crosses midnight over the clock change | inside it before the clock goes back the clock change is named (06:00:00Z), then its end at 07:00:00Z and its next start | src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts › a window of 22:00 to 02:00 that crosses midnight and the clock change %s |
| The next window boundary lies after the clock change | the clock change itself, then the exact boundary once the clock went back | src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts › names the clock change itself when the next window boundary lies after it, and the exact boundary once the clock went back |
| A zone without a clock change (America/Guayaquil) on the same date | the boundary of the wall clock, no cap | src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts › a window of 01:30 to 02:30 %s |
| Menus never limit what is sold | the same items are sold at every hour | test/e2e/catalog-till-menu-windows.e2e-spec.ts › never limits what the till sells: the same items are sold at every hour |
| SQL tables | a window is consistent (from <> until, spans_midnight exactly when from > until), numbered 0 to 13, with weekdays 1 to 7 once each; it needs the assignment of its menu at its outlet and its window for its weekdays; no UPDATE for the runtime; one version move per statement; another organization sees and deletes nothing; the operating context names the site time zone | database/tests/contracts/catalog-outlet-menu-windows-contracts.ts › a window that stays inside one day is stored; database/tests/contracts/catalog-outlet-menu-windows-contracts.ts › the operating context of an outlet names the time zone of its site; database/tests/contracts/organization-published-reads-contracts.ts › the context follows a site whose time zone changes |
| Plan | every probe of the schedule is a keyed lookup on the primary keys and no window table is read whole | src/modules/catalog/infrastructure/till/till-menus.plan.integration-spec.ts › decides which menus are open now and when that changes by the key of each assignment, reading no window table whole |
Variant dimensions
Slice K13 (migration 0109, baseline revision 119): structured variant dimensions (CATALOG_USE_CASES.md, "K13 as built"; CATALOG_OPTIONS_V2.md, "K13 as built"). The matrix that writes them is C6; the item read that shows them is C3; the list below offers them as filters.
C32 — List the variant dimensions of the catalog
GET /catalog/variant-dimensions with an optional businessUnitId; needs catalog.items.view organization-wide or at that business unit, never the plan. Cacheable: the strong ETag is a hash of the answer.
| Case | Expected answer | Proving test |
|---|---|---|
| A catalog that never had a matrix | 200 with an empty list, Cache-Control: private, no-cache and a strong tag | test/e2e/catalog-variant-dimensions-read.e2e-spec.ts › answers an empty list for a catalog that never had a matrix, with a tag and private no-cache |
| Two items with matrices that share a dimension and add a value | each active dimension once, with its active values in the order they were first sent and the number of active items that use it | test/e2e/catalog-variant-dimensions-read.e2e-spec.ts › lists each dimension with its active values in the order they were first sent and the number of active items that use it |
| An item that is deactivated; the old tag | the count drops, the tag changes, the old If-None-Match answers 200; the same tag answers 304 with no body | test/e2e/catalog-variant-dimensions-read.e2e-spec.ts › counts only the active items, and its tag follows what it shows: a deactivated item lowers the count and the old tag answers 200 |
| Another organization | it sees only its own dimensions | test/e2e/catalog-variant-dimensions-read.e2e-spec.ts › shows an organization only its own dimensions |
| A cashier | 403 · catalog.action_forbidden, the plain denial | test/e2e/catalog-variant-dimensions-read.e2e-spec.ts › refuses a member without catalog.items.view with the plain denial |
| The statement | binds the organization, reads the default catalog, keeps active dimensions and values in a stable order and counts the dimensions of each active item once, by the key of the item | src/modules/catalog/infrastructure/variant-dimensions/variant-dimension-read.queries.spec.ts › counts the dimensions of each active item of the organization once, at most three by the key of the item, never with EXISTS nor OFFSET |
| The plan | on 2,001 linked items among 20 other tenants with 3,000 dimensions, no table is scanned whole and the cost stays inside its measured bound | src/modules/catalog/infrastructure/variant-dimensions/variant-dimension-read.plan.integration-spec.ts › reads the two dimensions of a catalog of 2,001 linked items by their keys, never scanning a whole table |
| The shape of the answer | codes and names only, in a strict schema | src/modules/catalog/presentation/dtos/variant-dimensions/variant-dimension-response.spec.ts › answers the dimensions as they are, with the codes and names of their values and the count of active items |
Product brands
Slice K15c (migration 0112, baseline revision 122): the manufacturer brand of an item (CATALOG_USE_CASES.md, "K15c as built"; DECISIONS.md D38).
C38 — Manage the product brands and give them to items
The brand routes need catalog.items.manage organization-wide; productBrandId on the item create and edit follows the item permissions.
| Case | Expected answer | Proving test |
|---|---|---|
| A brand created once per key, read, read again with its tag | 201 then replay; strong tag the read repeats and 304 on revalidation | test/e2e/catalog-product-brands.e2e-spec.ts › creates a brand once per key, answers it with a strong tag the read repeats and revalidates it with 304 |
| A name another brand holds in any case, archived ones included; a malformed body | 409/400, nothing written | test/e2e/catalog-product-brands.e2e-spec.ts › refuses a name another brand holds in any case, archived brands included, and a malformed body |
| A brand renamed under its tag; same name; stale tag; taken name | 200 and one audit record; the same name writes nothing; 412; 409 | test/e2e/catalog-product-brands.e2e-spec.ts › renames a brand under its tag, writes and records nothing for the same name, refuses a stale tag and a name that is taken, and lets a brand change the case of its own name |
| A brand archived and restored; renaming an archived brand | 200 per step, a step in effect answers 200; rename refused | test/e2e/catalog-product-brands.e2e-spec.ts › archives and restores a brand, answers a step already in effect with 200, and refuses to rename an archived brand |
| The brands listed, filtered by status; another organization | ordered by lowercase name; another organization sees none | test/e2e/catalog-product-brands.e2e-spec.ts › lists the brands by lowercase name, filters them by status and shows another organization none of them |
| Two parallel creations of one name | one 201, one 409 | test/e2e/catalog-product-brands.e2e-spec.ts › lets one of two parallel creations of one name win and answers the other 409 |
| A cashier, a waiter, a kitchen member, a unit manager | 403, the plain denial; nothing written | test/e2e/catalog-product-brands.e2e-spec.ts › refuses a cashier, a waiter, a kitchen member and a manager of one unit every brand command and read with the plain denial, writing and recording nothing |
| An item created with a brand, replayed, renamed brand | 201 with productBrand { id, name }; replay; the new name and a new tag after the rename; a different brand under the key 409 | test/e2e/catalog-item-brands.e2e-spec.ts › creates an item with a brand, answers it as an id and a name only on an item that has one, and shows a renamed brand on the item at once |
| A brand given, kept, moved and cleared with null | 200 each; the same brand writes nothing; productBrand absent after null | test/e2e/catalog-item-brands.e2e-spec.ts › gives an item a brand, changes it, clears it with null, writes and records nothing for the one it has, and records the ids before and after |
| An unknown or foreign brand; an archived brand; a malformed id | 404 · catalog.not_found; 422 · catalog.product_brand_archived; 400; nothing written | test/e2e/catalog-item-brands.e2e-spec.ts › refuses a brand that does not exist or belongs to another organization with 404 and an archived one with 422, writing nothing |
| A brand archived after the item took it | the item keeps it, is listed under it and can still be edited | test/e2e/catalog-item-brands.e2e-spec.ts › lets an item keep a brand that is archived afterwards, and lists it under that brand |
| The list filtered by productBrandId, with a text, paged | only that brand; the cursor is bound to the brand (400 · collection.cursor_incompatible for another) | test/e2e/catalog-item-brands.e2e-spec.ts › lists the items of one brand, combined with a text, and binds a cursor to the brand it was issued for |
| Two edits of the brand at one version | one 200, one 412 | test/e2e/catalog-item-brands.e2e-spec.ts › lets one of two edits of the brand of an item at the same version win and answers the other 412 |
| Two brands renamed at once to the same name | one 200, one 409 (the unique index decides) | test/e2e/catalog-product-brands.e2e-spec.ts › lets one of two brands renamed at once to the same name win and answers the other 409, by the unique index |
| A brand archived while an item takes it | the archive is 200; the item gets 200 (and keeps the brand) or 422 · catalog.product_brand_archived (and holds none), never an archived brand it did not already have | test/e2e/catalog-item-brands.e2e-spec.ts › never leaves an item holding a brand that was archived before the item took it: the brand is locked while the item reads it |
| Two edits of the regulatory flags at one version | one 200, one 412 | test/e2e/catalog-regulatory-flags.e2e-spec.ts › lets one of two edits of the regulatory flags at the same version win and answers the other 412 |
| Two edits of the net content at one version | one 200, one 412 | test/e2e/catalog-net-content.e2e-spec.ts › lets one of two edits of the net content at the same version win and answers the other 412 |
Variant identifiers
Slice K15d (migration 0113, baseline revision 123): the manufacturer identifiers of a variant (CATALOG_USE_CASES.md, "K15d as built"; DECISIONS.md D39).
C39 — Declare the manufacturer identifiers of a variant
Written inside the existing variant writes (item create, variant add, variant edit) with their permission and plan gates.
| Case | Expected answer | Proving test |
|---|---|---|
| An item created whose default variant holds identifiers; the same request again; another set under the key | 201 with identifiers in the order of the vocabulary; the replay answers the same item; another set 409 | test/e2e/catalog-variant-identifiers.e2e-spec.ts › creates an item whose default variant holds identifiers in the order of the vocabulary, replays the same request and refuses another set under the key |
| A variant added with identifiers, replaced, cleared and sent again with the set it holds | 201, 200, 200; the same set is already in effect (same tag); identifiers is absent once cleared; the audit holds added, updated and updated | test/e2e/catalog-variant-identifiers.e2e-spec.ts › adds a variant with identifiers, replaces the set, clears it, writes nothing for the set it holds and records the set before and after |
| Two variants with the same identifier; a variant that never had any | both accepted (no uniqueness across variants); the read shows no identifiers key on a variant without them | test/e2e/catalog-variant-identifiers.e2e-spec.ts › lets two variants hold the same identifier and shows no identifiers on a variant that never had any |
| An unknown scheme, a repeated scheme, a blank or longer than 100 value, a null set | 400 · foundation.validation_failed, nothing written | test/e2e/catalog-variant-identifiers.e2e-spec.ts › refuses an unknown scheme, a repeated scheme, a blank or long value and a null set with 400, writing nothing |
| Two replacements of the identifiers at one version | one 200, one 412 | test/e2e/catalog-variant-identifiers.e2e-spec.ts › lets one of two replacements of the identifiers at the same version win and answers the other 412 |
| The identifiers as a set | ordered by the vocabulary, equal sets compared by pair, one value per scheme | src/modules/catalog/domain/items/variant-identifiers.spec.ts › judges two sets the same when they hold the same pairs in any order, and different when a value differs |
| The schema | scheme, trimmed value of 1 to 100 characters, no repeated scheme | src/modules/catalog/presentation/dtos/items/variant-identifier-fields.spec.ts › points at an unknown scheme, a repeated scheme, a blank, a long or NUL value and an unknown key |
Change feed
Slice K12a (migration 0114, baseline revision 124): the typed events every catalog write leaves in the outbox, with their tombstones (CATALOG_USE_CASES.md, "K12a as built"; DECISIONS.md D40).
C40 — Announce a catalog change to the devices
Written by triggers in the transaction of the write; no route, no permission and no plan gate of its own.
| Case | Expected answer | Proving test |
|---|---|---|
| An item created over HTTP | every version the create produced is announced, in publication order, with an item event naming the item and a variant event naming its default variant | test/e2e/catalog-change-feed.e2e-spec.ts › announces every version a create produced, with the item and its variant |
| An item archived, then restored | the archive writes a retired item event with status archived and retiredAt; the restoration writes an upserted event with no retiredAt | test/e2e/catalog-change-feed.e2e-spec.ts › tombstones an archived item and announces its restoration as an upsert |
| Two groups offered on an item, then one of them left out | offer events name the new offers; the group left out writes one retired offer event for its offer id | test/e2e/catalog-change-feed.e2e-spec.ts › announces an offer, then tombstones it when the group is no longer offered |
| A modifier group created with two options | every version is announced by catalog.modifier.changed events that name the group | test/e2e/catalog-change-feed.e2e-spec.ts › announces the modifier group a create wrote, with its options |
| The same create sent again with the same key | Idempotent-Replayed: true, no event, no outbox row and no version move; the first answer announced the item once | test/e2e/catalog-change-feed.e2e-spec.ts › writes nothing for a replayed create and its first answer announced the item once |
| Four parallel creates | four 201; distinct increasing versions, every version announced, contiguous publication positions, one item event per item | test/e2e/catalog-change-feed.e2e-spec.ts › gives parallel commands distinct increasing versions and loses no event |
| A business unit registered | the listing event of its default assortment is written when the unit commits, naming the unit | test/e2e/catalog-change-feed.e2e-spec.ts › announces what a newly registered business unit lists, written when the unit commits |
| The offer edit that fails after its write | no offer, audit record, event, outbox row or version move survives; the successful edit before it had announced its changes | test/e2e/catalog-modifier-offer-identity.e2e-spec.ts › leaves the offers, their ids, the audit trail, the announced changes and the version untouched when the record fails after the write |
| An item and its default variant in one transaction | an item event then a variant event, each at the version its statement produced, with the outbox holding one row per event | database/tests/contracts/catalog-change-feed-contracts.ts › each event carries the catalog version its statement produced |
| Archiving an item; delisting it | retired with status, retiredAt and the item id; delisting is an upserted event with status inactive and no tombstone | database/tests/contracts/catalog-change-feed-contracts.ts › archiving an item writes a retired event with a tombstone |
| A category link; an option; a retired offer; an archived barcode; a deleted listing | an item event; a modifier group event; an offer tombstone; a barcode tombstone; a removed listing event with its scope | database/tests/contracts/catalog-change-feed-contracts.ts › deleting a listing writes a removed event with its scope |
| One statement over two categories | two events at the same version | database/tests/contracts/catalog-change-feed-contracts.ts › one statement over two categories writes two events at the same version |
| A privileged write outside a tenant context; a transaction that asked for immediate constraints | the first moves the version and writes no event; the second still writes its event | database/tests/contracts/catalog-change-feed-contracts.ts › a privileged write outside a tenant context moves the version and writes no event |
| A command that fails after its write; a write of another organization | no event, no outbox row, no version move; the other organization's feed is untouched | database/tests/contracts/catalog-change-feed-contracts.ts › a command that fails after its write leaves no event, no outbox row and no version move |
| The event function with an unknown event, another module's event, an unpublished version, a replayed key | refused with 23503 or, for the replay with another payload, refused; the same payload writes one event | database/tests/contracts/catalog-change-feed-contracts.ts › the event api refuses an unknown event, another module and an unpublished version |
| The runtime role against the event tables | refused on every insert and read, and on events.append_domain_event | database/tests/contracts/catalog-change-feed-contracts.ts › the runtime is refused: |
| Every table that bumps the version; the tables that do not | each writes events or is on the exclusion list; the feed trigger fires after the version trigger; the tables without version triggers are the translation, allergen, dietary and nutrition tables | database/tests/contracts/catalog-change-feed-contracts.ts › every table that bumps the version writes events or is on the explicit exclusion list |
| Two commands that lock the version row | distinct increasing versions, events in the same order, the second waits for the first | database/tests/concurrency/catalog-change-feed-concurrency.ts › NINAKU_CATALOG_CHANGE_FEED_CONCURRENCY_OK: two commands that lock the catalog version row write distinct increasing versions and events in the same order, and the later one waits for the first to commit |
| The payload of every event | a closed object per event: schema version 1, a positive integer version, the aggregate id, no unknown key, retiredAt exactly when the change is not an upsert | src/modules/catalog/contracts/catalog-change-event.spec.ts › names nine events, each owned by the catalog |
Offline bootstrap
Slice K12b (no migration, baseline revision 124): the download of what an outlet sells, pinned to a cut, and its composition with the change feed (CATALOG_USE_CASES.md, "K12b as built"; DECISIONS.md D41).
C41 — Bootstrap an outlet for offline work
GET /catalog/outlets/{outletId}/bootstrap and .../bootstrap/items, behind the till permission (catalog.sellables.view at the outlet), with no plan gate of its own and no record.
| Case | Expected answer | Proving test |
|---|---|---|
| An outlet with items that have SKUs and barcodes, one archived item and one archived barcode, walked in pages of two | the same items, in the same order and pages as the search walk, each variant with its sku and only its active barcodes, all at one cut | test/e2e/catalog-bootstrap.e2e-spec.ts › walks every item the outlet sells in the order and the pages of the search, each variant with its SKU and active barcodes, all at one cut |
| The opening call, then the same page asked twice | cut, catalogVersion and the unit of the outlet with private, no-cache; the repeated page is byte-identical | test/e2e/catalog-bootstrap.e2e-spec.ts › answers the cut with the catalog version, the unit and the reference data, and a page of the same cut repeated answers the same page |
| A scan of every barcode in the bootstrap index; a barcode of an item the outlet does not sell | the online scan at the outlet resolves the same variant; the index holds nothing the online scan answers null for | test/e2e/catalog-bootstrap.e2e-spec.ts › resolves a scan from the barcode index of the bootstrap to the variant the online scan at the outlet resolves, and knows nothing the outlet does not scan |
| Two business units, one delisting an item; the cut of one used at the outlet of the other | each outlet bootstraps what its own unit sells; the crossed cut is 409 · catalog.bootstrap_cut_moved | test/e2e/catalog-bootstrap.e2e-spec.ts › keeps the data of two business units apart: each outlet bootstraps what its own unit sells and refuses the cut of the other |
A missing, malformed or unknown cut, a limit of 0 or 101, before, an unknown parameter; a cursor used at another outlet | 400, nothing read | test/e2e/catalog-bootstrap.e2e-spec.ts › refuses a cut that is missing, malformed or of another outlet, a cursor of another cut and an unknown parameter, reading nothing |
| A cursor issued for an earlier cut, sent with the current cut; the same cursor with the earlier cut | 400 for the cursor (it is bound to its cut); 409 · catalog.bootstrap_cut_moved for the earlier cut | test/e2e/catalog-bootstrap.e2e-spec.ts › refuses a cursor issued for an earlier cut with 400 even when the cut sent is the current one, and answers 409 for the earlier cut itself |
| The query of a page | the cut parsed into its parts, the limit 1 to 100 with 50 by default, and before, an unknown parameter, a malformed cut or an empty cursor refused | src/modules/catalog/presentation/dtos/bootstrap/bootstrap-query.spec.ts › parses the cut of an opening call into its three parts and defaults the page to 50 |
A kitchen member, a bar member, a holder of catalog.items.view alone and a member without a role, at an existing, an unknown and a foreign outlet | the same plain 403 on both routes; nothing is recorded | test/e2e/catalog-bootstrap.e2e-spec.ts › refuses a kitchen member, a bar member, a holder of catalog.items.view alone and a member without a role the same plain denial on both routes at an existing, an unknown and a foreign outlet, and records nothing |
| A cashier scoped to the outlet, whose scope then moves to another outlet | reads the outlet and its first page; the next page and a new opening are the plain 403 | test/e2e/catalog-bootstrap.e2e-spec.ts › lets a cashier scoped to an outlet bootstrap it and stops the next page the moment the scope moves to another outlet |
| A member of another organization naming this outlet | 404 · organization.structure_not_found | test/e2e/catalog-bootstrap.e2e-spec.ts › refuses the same to a member of another organization naming this outlet |
| A walk of 3,000 items in pages of 100 with a catalog write between two pages; the walk started again | the next page is 409 · catalog.bootstrap_cut_moved; the new walk has 31 pages, every item once in order, no gap, and ends at the current version | test/e2e/catalog-bootstrap-consistency.e2e-spec.ts › refuses the next page of a walk the moment a catalog write lands in the middle of it, and the walk started again ends at one cut with every one of the 3,000 items once, in order, with no gap |
| The same page requested three times at once | the same body; no row, event, outbox row or version move | test/e2e/catalog-bootstrap-consistency.e2e-spec.ts › reads, retries and repeats a page without writing a row, an event or a version |
| The statements of an opening, a first page, a full page and a refused cut | 7, 8, 8 and 7 | test/e2e/catalog-bootstrap-statement-budget.e2e-spec.ts › opens a bootstrap in 7 statements and reads a page in 8, whatever the page holds |
| The SKU and barcodes of the variants of a sold item; an item the outlet archived; a variant that is not active | only the active barcodes; neither the archived item nor the inactive variant | src/modules/catalog/infrastructure/bootstrap/bootstrap.repository.integration-spec.ts › answers the SKU and the active barcodes of each sold variant, nothing else about identifiers |
| A page of 100 over 2,001 imported items with 2 barcodes each, before the statistics know the import | every variant and barcode read by its key; no sequential scan; rows and buffers within the ceiling per item | src/modules/catalog/infrastructure/bootstrap/bootstrap.plan.integration-spec.ts › pages every imported item with its SKU and barcodes reading each variant and each barcode by its key, never the whole organization per item |
| The use case with a moved catalog, structure or unit, or without the permission | the cut is refused before any item is read; the permission is decided first | src/modules/catalog/application/bootstrap/read-outlet-bootstrap.use-case.spec.ts › refuses a member without the till permission before looking at the outlet or reading anything |
| The cut as a value | one token per catalog version, structure version and unit, parsed back, malformed tokens refused | src/modules/catalog/domain/bootstrap/bootstrap-cut.spec.ts › compares the three parts |
C42 — Apply the change feed to a bootstrap
The device applies the catalog events whose catalogVersion is greater than the cut. Serving the events is Sync's; the rules and the proof live here.
| Case | Expected answer | Proving test |
|---|---|---|
| A bootstrap, then 24 random commands (create, rename, deactivate, archive, restore, add a variant, add a barcode, archive a variant) and a fresh bootstrap, for three seeds | the bootstrap plus the feed events after its cut equals the fresh bootstrap, and every item that differs was announced by an event of the feed | test/e2e/catalog-bootstrap-deltas.e2e-spec.ts › ends with the same items, variants, SKUs and barcodes as a fresh bootstrap, and every item that differs was announced (seed %i) |
| An item archived, an item deactivated and a variant archived after a bootstrap | the device removes the item, the item and the variant with only the tombstones and the ids it holds, and agrees with a fresh bootstrap | test/e2e/catalog-bootstrap-deltas.e2e-spec.ts › removes an archived item, a deactivated item and an archived variant from the device with nothing but the tombstone and the ids it already holds |
| The version of an opened bootstrap against the feed | no event at or below it is missing from the data; every later event is above it | test/e2e/catalog-bootstrap-deltas.e2e-spec.ts › opens a bootstrap whose version is the one every later event must exceed |
HTTP foundation
The first slice of the Catalog API v7 plan (CATALOG_USE_CASES.md, "API v7 foundation as built"): rules every catalog route inherits.
F1 — Replace a collection only at the version read
| Case | Expected answer | Proving test |
|---|---|---|
Each of the seven replace-the-collection PUTs without If-Match, with *, with a stale tag, with the current tag, then the same list with its new tag | 428 · precondition.required with a detail that teaches the fix, twice; 412 · foundation.precondition_failed; nothing written or recorded until the current tag: 200 with the tag GET answers and one record; the replay 200 with the same tag and no record | test/e2e/catalog-required-preconditions.e2e-spec.ts › $route answers 428 without If-Match or with *, 412 with a stale tag and 200 with the current one, and a replayed list with its new tag answers 200 recording nothing |
Each of the seven PUTs without If-Match: no session; another organization; an invalid body | 401 · identity.session_required; 403 · tenant.context_rejected; 428 · precondition.required, never 400 | test/e2e/catalog-required-preconditions.e2e-spec.ts › $route without If-Match checks the session first (401), then the organization (403), then the precondition (428) before the body (never 400) |
| The guard and its contract | a missing, blank or * header refused before the handler, a named tag let through; the header documented required: true with the 412 and the 428 | src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts › refuses a write without If-Match with 428 before the handler runs, teaching the caller to send the ETag it read; src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts › refuses $label with 428; src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts › lets a write that names an entity tag through to the handler, which judges whether it is current; src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts › documents a required If-Match header, the 412 of a stale tag and the 428 of a missing one |
F2 — Answer only the meta a route documents
| Case | Expected answer | Proving test |
|---|---|---|
Create, read, a replacement PUT, the item list, the tree, the group list, the workspace and the till read | each real body passes the schema the route documents, meta included | test/e2e/envelope-meta-contract.e2e-spec.ts › answers single resources, writes, collections and the till read with bodies their documented schema accepts, meta included |
The same bodies with one undeclared meta key | refused by every documented schema | test/e2e/envelope-meta-contract.e2e-spec.ts › refuses, against the same documented schemas, a body whose meta carries one key the route does not declare |
| The composers | a single resource without meta documents a required empty object; declared keys close the object; a collection closes it to pageInfo and its keys | src/foundation/openapi/api-envelope-response.decorator.spec.ts › documents the meta of a single resource that declares none as a required empty object; src/foundation/openapi/api-envelope-response.decorator.spec.ts › closes the meta of a single resource to the entries it declares; src/foundation/openapi/api-envelope-response.decorator.spec.ts › closes the meta of a collection to the page info and the entries it declares |
F3 — Bound the writes one member sends a minute
| Case | Expected answer | Proving test |
|---|---|---|
| A member past the limit; its reads; another member of the organization | 429 · rate_limit.exceeded with a Retry-After of at most 60 seconds and a detail that says to wait; reads 200; the colleague's write 201 | test/e2e/catalog-member-write-throttle.e2e-spec.ts › refuses the write after the limit with 429 rate_limit.exceeded and a Retry-After, keeps answering that member reads, and lets another member of the organization write |
| Ten rapid writes by a member without the permission | the first ones 403 with one denied record each, the rest 429 recording nothing | test/e2e/catalog-member-write-throttle.e2e-spec.ts › bounds the audit records a member without the permission can leave: rapid denied writes turn into 429 after the limit and record nothing more |
| The guard | counts per membership, never a read, falls back to the address without a tenant context | src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts › lets a member write up to the limit and refuses the next write with 429 rate_limit.exceeded and a Retry-After within the window; src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts › never counts nor refuses a read, even once the member is refused; src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts › counts each member apart, so another member of the same organization keeps writing; src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts › falls back to the client address when no tenant context was resolved |
F4 — Accept a bulk body up to its route's ceiling
| Case | Expected answer | Proving test |
|---|---|---|
| The documented maximum of the menu structure, of the bundle groups, of the modifier group set (512 KiB) and of the item offers with 10 variant lists (512 KiB, the same parser), as compact JSON | accepted by the schema, at most the route's ceiling and above the step below it | src/modules/catalog/presentation/controllers/shared/catalog-body-limits.spec.ts › lets $route take its documented maximum as compact JSON under the smallest step that holds it; src/modules/catalog/presentation/controllers/shared/catalog-body-limits.spec.ts › raises the ceiling only on the menu structure, the bundle groups, the modifier group set and the item offers |
| The documented maximum of the bulk images, the variant matrix and the listings | accepted by the schema and under the 100 KB default | src/modules/catalog/presentation/controllers/shared/catalog-body-limits.spec.ts › leaves $route on the 100 KB default, which already holds its documented maximum |
| A maximal menu structure of about 2 MB; one byte over each ceiling; 100 KB plus one byte on the bulk images and the item edit | read and judged by the use case (412 for the tag sent); 413 · payload.too_large; 413 · payload.too_large, while exactly 100 KB reaches validation (400) | test/e2e/catalog-body-limits.e2e-spec.ts › reads a documented-maximum menu structure of about 2 MB to its end and judges it, instead of refusing its size; test/e2e/catalog-body-limits.e2e-spec.ts › refuses a body one byte over the ceiling of $route with 413 payload.too_large; test/e2e/catalog-body-limits.e2e-spec.ts › keeps the 100 KB default on every other route, the bulk image route and the item edit included |
| 100 KB plus one byte on the menu structure and the bundle groups, with and without bearer credentials | with them, read and refused by the schema (400 · foundation.validation_failed), so the ceiling is mounted on the real route; without them 413 · payload.too_large before any guard | test/e2e/catalog-body-limits.e2e-spec.ts › reads a body one byte over the 100 KB default on $route and answers it with the schema (400), so its raised ceiling really is mounted on that route; test/e2e/catalog-body-limits.e2e-spec.ts › never reads more than 100 KB on $route from a caller without bearer credentials: 413 before any guard; src/foundation/http/body-limits/json-body-limits.spec.ts › leaves a body without well-formed bearer credentials to the 100 KB default, so an anonymous caller never makes the server read a raised ceiling |
| The parsers | one JSON parser per ceiling for its method and path, JSON only, then the 100 KB default; mounted under the versioned prefix at bootstrap | src/foundation/http/body-limits/json-body-limits.spec.ts › matches the method and every literal segment, a parameter matching any one segment, and ignores the query; src/foundation/http/body-limits/json-body-limits.spec.ts › mounts one JSON parser per ceiling for its JSON routes only, then the 100 KB default for every other JSON body; src/bootstrap/configure-http-application.spec.ts › mounts the raised JSON ceilings of the catalog bulk routes under the versioned prefix, then the 100 KB default |
F5 — Refuse an oversized menu tree early and answer with safe headers
| Case | Expected answer | Proving test |
|---|---|---|
| 51 top sections; 51 subsections in one section | too_big at sections, and nothing else; too_big at sections[0].subsections, and nothing else (the array check aborts before the tree is walked) | src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts › refuses a fifty-first top section at sections and a fifty-first subsection at its subsections with too_big alone, never walking the oversized tree |
GET /api/v1/catalog/items and its 401 | X-Content-Type-Options: nosniff and no X-Powered-By on both | test/e2e/catalog-security-headers.e2e-spec.ts › answers GET /api/v1/catalog/items, and a refusal of it, with X-Content-Type-Options nosniff and no X-Powered-By; src/bootstrap/configure-http-application.spec.ts › hides the framework and mounts the security headers |
Gaps
None: slice K8 proved the last open row (an archived item refused by admission, C7).