Skip to main content

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.

PackageFilesTestsRunnerState at verification
packages/types685vitestall passing
apps/api13305vitestall passing
apps/worker16222vitest206 passing, 14 failing, 2 skipped (below)
apps/frontend20219vitestall passing
apps/docs1n/aNodecheck-docs.mjs, passing
The worker suite was mid-development when this page was verified

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:

FileState
tests/handlers/central-kitchen.test.tsFails at collection
tests/handlers/failure.test.ts5 failures across the retryable and permanent failure paths
tests/handlers/costco.test.ts2 failures on the recipient address and the captured message list
tests/handlers/backorder-reroute.test.ts2 failures on the no-alternate path
tests/jobs/sysco-poll.test.ts2 failures on the apply-and-archive run and its idempotency
tests/sftp/client.test.ts3 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:

FileCovers
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.

FileCovers
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:

  1. every route not on the PUBLIC_ROUTES allowlist carries one of the three guards;
  2. every such route answers an unauthenticated request with 401;
  3. 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

DependencyIn the API suite
PostgreSQLReal 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
RedisReal, under a dedicated QUEUE_PREFIX of lucille-test, obliterated between tests
ZitadelThe local JWKS server above
Object storageNot 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' with singleFork: true plus fileParallelism: false gives 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.ts holds key 728301455 for the whole run, so a second vitest run 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.

FileCovers
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 ssh2 server. tests/helpers/sftp-server.ts binds 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.ts launches the system binary at /usr/bin/chromium and 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, including expected-850.edi as 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.ts pins 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 .md page under docs/ has front-matter with id, title, sidebar_label and description;
  • every front-matter id matches its filename, so sidebar references cannot drift;
  • every doc id referenced from sidebars.ts exists 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