Testing
Testing here is shaped by where the risk actually is. The business logic is not especially complicated; what is complicated is the boundary with three external systems, an identity provider with a counterintuitive claim shape, and a queue whose duplicate-suppression semantics have sharp edges. The suites are weighted accordingly, and they are weighted towards integration rather than unit tests.
The governing principle across all four packages is that nothing that can be run for real is mocked.
There is no Prisma mock, no BullMQ mock, no fetch stub in front of the JWKS endpoint, no headless-browser
double. The suites talk to a real PostgreSQL database, a real Redis, a real in-process SSH server and a
real Chromium binary. A mock of any of those would assert that the code calls a function; the tests as
written assert that the artefact a trading partner, an auditor or a browser would actually see is correct.
Verified counts
The figures below were obtained by running each suite in this repository, not copied from a plan.
| Package | Files | Tests | Runner | State at verification |
|---|---|---|---|---|
packages/types | 6 | 85 | vitest | all passing |
apps/api | 13 | 305 | vitest | all passing |
apps/worker | 16 | 222 | vitest | 206 passing, 14 failing, 2 skipped (below) |
apps/frontend | 20 | 219 | vitest | all passing |
apps/docs | 1 | n/a | Node | check-docs.mjs, passing |
packages/types, apps/api and apps/frontend were fully green. apps/worker was not: its runtime
layer — the BullMQ worker, the per-supplier handlers and the inbound poll job — was being written while
this page was checked, and six of its sixteen files were failing:
| File | State |
|---|---|
tests/handlers/central-kitchen.test.ts | Fails at collection |
tests/handlers/failure.test.ts | 5 failures across the retryable and permanent failure paths |
tests/handlers/costco.test.ts | 2 failures on the recipient address and the captured message list |
tests/handlers/backorder-reroute.test.ts | 2 failures on the no-alternate path |
tests/jobs/sysco-poll.test.ts | 2 failures on the apply-and-archive run and its idempotency |
tests/sftp/client.test.ts | 3 failures on credential fallback and the default ready timeout |
The EDI, email-template, PDF, control-number, handlers/sysco and handlers/idempotency files — 176
tests across ten files — were green.
Treat every count on this page as a dated snapshot rather than a contract, and re-run the suite for the current figure before quoting it. The commands are immediately below.
Running the suites
pnpm test # turbo run test — every package, build-ordered
pnpm --filter @lucille/types test # vitest run
pnpm --filter @lucille/api test # needs Postgres + Redis running
pnpm --filter @lucille/worker test # needs Postgres + Chromium
pnpm --filter @lucille/frontend test # jsdom, no services needed
pnpm --filter @lucille/docs test # check-docs.mjs
turbo run test declares dependsOn: ["^build"], so dependencies are built before their consumers are
tested. That matters in practice: @lucille/worker imports @lucille/api through its package exports
(@lucille/api/db, @lucille/api/config), so the API must be compiled before the worker suite can run.
Add --filter to scope a run, and test:watch exists in every package except @lucille/docs.
The services the suites need are described in local development. There is no
Playwright configuration or end-to-end spec in the repository yet — test:e2e and test:e2e:ui scripts
and the @playwright/test dependency exist in apps/frontend/package.json, but neither
playwright.config.ts nor an e2e/ directory does, so those scripts do not currently run anything. The
three-viewport coverage described in decision D4 is designed, not yet implemented.
packages/types — 85 tests across 6 files
Pure schema tests, no I/O. They exist for the cases where a naive implementation is silently wrong:
| File | Covers |
|---|---|
auth.test.ts (21) | The Zitadel roles claim constant, extractRoles against the nested claim shape, resolveUserRole precedence, and the auth payload schemas |
entities.test.ts (15) | The enum const arrays, chefCatalogItemSchema, the three per-type supplier config schemas and createSupplierInputSchema cross-validation |
order.test.ts (15) | placeOrderInputSchema including the duplicate-item rule, the order response and query schemas, backorderNotificationInputSchema |
common.test.ts (13) | paginationQuerySchema, booleanQueryParamSchema, catalogQuerySchema, paginatedResponseSchema |
queue.test.ts (12) | QUEUE_NAMES, the deduplication id helpers, and every job payload schema |
mapping.test.ts (9) | createMappingInputSchema and updateMappingInputSchema, including the alternate-equals-primary refinement |
The two most valuable assertions are that booleanQueryParamSchema maps the string "false" to false
(where z.coerce.boolean() would produce true), and that a mapping whose alternate equals its primary
is rejected at path altSupplierId — because such a mapping would reroute a back-ordered line straight
back to the supplier that just back-ordered it.
apps/api — 305 tests across 13 files
These are integration tests, not unit tests. Each file builds the production Fastify app with
buildApp() — CORS, cookies, rate limiting, the auth plugin, the error handler and the full route table
all present — and drives it with app.inject(), so no socket is opened but a status code observed in a
test is the status code a client would observe.
| File | Covers |
|---|---|
admin-suppliers.test.ts (36) | Listing, per-type config validation, plaintext-credential rejection, update, soft delete, authorisation |
admin-mappings.test.ts (33) | The full mapping CRUD surface plus the orderability warning on delete |
admin-catalog.test.ts (31) | Catalog CRUD, case-insensitive duplicate names, soft delete |
admin-restaurants.test.ts (31) | Restaurants plus chef assignment and unassignment |
admin-orders.test.ts (25) | Cross-restaurant listing, the audit feed, admin order detail with supplier identity present |
domain/order-number.test.ts (24) | deriveLocationCode, formatOrderDate, formatOrderNumber, dayBounds, collision detection and withOrderNumberRetry |
orders.query.test.ts (23) | Order list and detail scoping, the 403-vs-404 rule, supplier redaction |
orders.place.test.ts (21) | POST /api/orders — fan-out, the three cart failure modes, status transitions |
catalog.test.ts (20) | The chef catalog and categories, the orderable predicate, includeInactive being ignored for chefs |
orders.backorder.test.ts (19) | The backorder hook and the manual reroute, including their idempotency |
auth.test.ts (17) | Token verification end to end against a local JWKS server |
orders.idempotency.test.ts (17) | Replay, the X-Idempotent-Replay header, key misuse, canonical body hashing |
rbac.test.ts (8) | The route-table walk — see below |
The route-table walk
rbac.test.ts is the file that makes the authorisation model enforceable rather than aspirational. It
does not iterate a hand-maintained endpoint list; it registers the real route tree onto a throwaway
Fastify instance, harvests every route from Fastify's own onRoute hook, and identifies each route's
guard by reference equality against app.requireAdmin, app.requireChef and app.requireAuth, so a
renamed function or a look-alike hook cannot masquerade as a guard. It then asserts, across three
describe blocks — route table, unauthenticated access and role separation — that:
- every route not on the
PUBLIC_ROUTESallowlist carries one of the three guards; - every such route answers an unauthenticated request with 401;
- every admin-only route answers a chef token with 403.
A route added later without a guard therefore fails the build, which is the whole point. HEAD and
OPTIONS are skipped because Fastify synthesises them and they carry no authorisation meaning.
The local JWKS server
The auth tests are worth something because verifyAccessToken is never stubbed. tests/helpers/jwks.ts
generates a real RS256 keypair, publishes the public half from a throwaway node:http server, points
AUTH_JWKS_URL at it, and signs tokens carrying a realistic Zitadel payload — including the nested
project roles claim ({ chef: { '<orgId>': '<orgDomain>' } }), which is the shape that actually trips
people up. The production createRemoteJWKSet path then runs verbatim: key fetch, kid match, signature
check, issuer/audience/expiry validation, role extraction. A second, deliberately unpublished keypair
reuses the same kid, so a token signed with it fetches a key successfully and then fails the signature
check — that is the "wrong key" case. This is decision D2: Zitadel itself cannot be run in the build
environment, so the harness reproduces its claim shapes rather than bypassing the code that reads them.
Real Postgres, real Redis
| Dependency | In the API suite |
|---|---|
| PostgreSQL | Real lucille_test database, already migrated and seeded. Transactional data is truncated between tests; the seeded reference data (restaurants, suppliers, catalog items, mappings, users) is restored rather than recreated |
| Redis | Real, under a dedicated QUEUE_PREFIX of lucille-test, obliterated between tests |
| Zitadel | The local JWKS server above |
| Object storage | Not contacted. Presigning is pure local signing, so credentials are set but no request is made |
Three things about the harness are worth knowing before you run it:
- The suite is fully serialised.
pool: 'forks'withsingleFork: trueplusfileParallelism: falsegives one process executing one file at a time. Without both, two files truncate the shared database out from under each other. - A run takes a Postgres session-level advisory lock.
tests/global-setup.tsholds key728301455for the whole run, so a secondvitestrun started from another terminal blocks and then executes cleanly instead of interleaving. It is a database lock rather than a lock file precisely so a killed run cannot leave a stale one behind. retry: 0. Real network and database calls make retries misleading — a flake is a bug.
The rate limit is raised to 100000 in the test environment: the suite fires hundreds of requests from one
address, and the production limit would start answering 429 half way through.
One assertion is implicit but continuous: tests/helpers/db.ts cannot DELETE from audit_events,
because the audit_events_append_only trigger raises on UPDATE and DELETE. It uses TRUNCATE
instead, which a row-level trigger does not fire. Every test that inspects the audit trail therefore
transitively verifies that the append-only guarantee of decision D6 is really in the database.
apps/worker — 222 tests across 16 files
Also integration tests against real services, for the same reason.
| File | Covers |
|---|---|
edi/parse-855.test.ts (32) | Every ACK disposition, the precedence rule when one PO1 mixes codes, quantity reconciliation, non-default separators, garbage input |
edi/x12-writer.test.ts (31) | ISA fixed-width serialisation, separator validation, segment counting, writer guard rails, formatting helpers |
edi/po-850.test.ts (28) | The golden-file comparison, SE01/CTT01, N1 loop optionality, date handling, UOM mapping, PO1/PID detail, and that partner values are injected rather than hardcoded |
sftp/client.test.ts (25) | Key authentication against a real in-process SFTP server, fast failure on corrupt keys, file operations, and handle leakage across many sequential connections |
email/templates.test.ts (23) | Subject format, both email builders, HTML escaping, determinism |
pdf/po-template.test.ts (20) | Document structure, every field, graceful degradation of optional fields, escaping, determinism |
edi/parse-997.test.ts (17) | Accepted and rejected functional acknowledgments, the other acknowledgment codes, malformed input |
pdf/renderer.test.ts (9) | Chromium availability and launch configuration, then real renders |
edi/control-numbers.test.ts (7) | Sequential allocation, and 50 concurrent callers receiving 50 distinct contiguous numbers |
handlers/idempotency.test.ts (5) | Running the same dispatch job twice performs its side effect once — the worker-level Postgres check |
handlers/sysco.test.ts (4) | The whole outbound dispatch: seeded order, real SFTP server, real serialiser, real control-number allocation |
Five more files cover the rest of the runtime layer and were failing at verification time — see the note
above: handlers/central-kitchen.test.ts, handlers/failure.test.ts, handlers/costco.test.ts,
handlers/backorder-reroute.test.ts and jobs/sysco-poll.test.ts. Between them they are the intended
coverage for the PDF-and-email channel, the retryable-versus-permanent failure classification, the Costco
email path, the no-alternate reroute outcome and the inbound poll's apply-and-archive run.
Four substitutions and non-substitutions are worth calling out:
- A real in-process
ssh2server.tests/helpers/sftp-server.tsbinds an ephemeral port and does genuine public-key SFTP round trips, so the key-auth path — the part most likely to break against a real trading partner — is exercised rather than asserted about. - Real Chromium.
renderer.test.tslaunches the system binary at/usr/bin/chromiumand asserts on the%PDF-magic bytes, a plausible file size, a landscape override, and that no orphan browser process survives a render that throws. That single file accounts for most of the suite's wall-clock time. - Golden-file EDI fixtures.
tests/fixtures/holds eleven files, includingexpected-850.edias the golden outbound artefact and ten inbound fixtures covering each 855 disposition (all-accepted, backorder, rejected, quantity change, quantity mismatch, split disposition, pipe separators, garbage) plus an accepted and a rejected 997.vitest.config.tspins the EDI identifiers (EDI_ISA_SENDER_ID,EDI_VERSION, and so on) so the golden comparison is deterministic. - A real Postgres, for atomic control-number allocation. The concurrency test needs the database to
itself to be able to assert contiguous numbers, which is why this suite is also
singleFork.
The worker suite uses its own BullMQ prefix, lucille-worker-test, because queue.obliterate({ force: true }) deletes every key under a prefix and must never reach the API suite's keyspace or local
development's.
apps/frontend — 219 tests across 20 files
vitest with jsdom and Testing Library; no services required, and the whole suite was green at
verification. Coverage spans the API client (buildApiUrl, request headers, error-envelope parsing,
refresh-on-401, proactive refresh scheduling), the order-error mapping, the formatting helpers, the auth
and cart stores, the RequireAuth route guard, shared components (DataTable, StatusBadge), the
debounce hook, the admin mapping dialog and supplier config form, and page-level tests for the catalog,
order review, order detail and orders list plus all five admin screens (catalog, mappings, suppliers,
restaurants, orders).
NODE_ENV is pinned to test in the vitest config, because some CI images export NODE_ENV=production
globally, which would load React's production build and break act() support in every component test.
apps/docs — documentation integrity
apps/docs/scripts/check-docs.mjs is plain Node with no dependencies, wired up as this package's test
script. It asserts that:
- every
.mdpage underdocs/has front-matter withid,title,sidebar_labelanddescription; - every front-matter
idmatches its filename, so sidebar references cannot drift; - every doc id referenced from
sidebars.tsexists on disk; - no page is an orphan — everything on disk is reachable from the sidebar;
- no page is shorter than 300 prose words, with fenced code blocks, inline code, HTML comments and table pipes excluded from the count so a wall of YAML cannot pass for documentation.
Every failure is collected and printed together, then the process exits non-zero. The Docusaurus
production build is the complementary check: onBrokenLinks and onBrokenMarkdownLinks are both
throw, so a bad relative link or a dangling heading anchor fails pnpm --filter @lucille/docs build.
Where test plans live
Written test plans live in the repository at docs/test-plans/, one file per major feature. The
directory's README.md defines the format — feature and the requirement it satisfies, preconditions,
a numbered case table with expected results and the automated coverage for each case, edge and negative
paths including every error status, and an explicit "not covered" section — and _template.md is the
skeleton to copy. A case with no automated coverage must say so in its coverage column; silent gaps are
the thing the format exists to prevent.
At the time of writing, docs/test-plans/ contains the README.md and _template.md only. The
README's index lists eleven plans that have not been written yet, so treat that index as the intended
set rather than as a description of what is on disk.
Note that docs/test-plans/ is the repository root docs/ directory, not this Docusaurus site's
apps/docs/docs/. The root directory also holds docs/research/, the six technical briefings whose
findings are folded into the pages here.
Verification status
The counts, file lists and per-file coverage above were obtained by running
pnpm --filter <package> test for each of the four packages and reading
apps/api/vitest.config.ts, apps/worker/vitest.config.ts, apps/frontend/vite.config.ts,
apps/api/tests/{setup,global-setup}.ts, apps/api/tests/helpers/*,
apps/worker/tests/helpers/* and apps/docs/scripts/check-docs.mjs. The statements about Playwright and
about docs/test-plans/ are absences confirmed by listing those directories.
Where to go next
- API reference — the endpoint list
rbac.test.tswalks. - Local development — bringing up the services the suites need.
- Troubleshooting — the failure modes these tests exist to prevent.
- Queues and jobs — the deduplication semantics the queue tests pin down.