Litestore · Developer guide

Integration

Learn what the three integration suites protect, why they need a real database, and how they differ from the unit lane.

Most of the suite runs against stubs. Three files do not, because what they verify is the database's behaviour rather than the application's.

They live in test/integration/ and run under their own Vitest config.

Keeping them in a separate lane is what lets the unit lane stay instant and database-free. A developer running tests in watch mode should not need Postgres on their machine; a test that proves a Postgres constraint has no meaning without it.

Running them

bun run test:integration

The script is:

vitest run --config vitest.integration.config.ts

The database comes from TEST_DATABASE_URL, so the integration lane never points at your development database by accident.

The lane needs the local Postgres from compose.yml, plus a one-time database creation and migration against it. Once that exists, the command is the whole workflow.

The test database is guarded twice

test/integration/setup.ts runs before each test file's imports, and pins the connection before Prisma is constructed:

postgresql://postgres:postgres@localhost:5432/litestore_test

TEST_DATABASE_URL overrides that default, and both the pooled and unpooled URLs are set from it.

Before it does any of that, it refuses a URL that does not match litestore_test and throws instead of running. The config pins the same value a second time, so nothing between config load and setup execution can see the development database URL.

The reason for that paranoia is the next line of the file: every test starts from an empty database. truncateAll issues a single TRUNCATE … RESTART IDENTITY CASCADE over every non-Prisma table in the public schema, before each test.

A truncate-between-tests harness pointed at development data would be catastrophic, so the harness will not start unless the name of the database says what it is.

Files do not run in parallel

The config sets:

fileParallelism: false

The suites share one database and truncate between tests, so concurrent files would truncate each other's rows mid-assertion.

The timeout is 30 seconds rather than the 10-second default, because a real transaction against a real database is not a stubbed call.

The files use the .test.ts suffix deliberately. The critical-tests ratchet counts a source file as covered when a *.test.ts imports it, so an integration suite covering a critical path has to be named like a test to count as one.

The three suites

db-guarantees.test.ts

This suite proves that the CHECK constraints and triggers actually reject the writes they claim to — an over-refund, an edited ledger row, a movement whose arithmetic does not add up.

It seeds a paid order and then attempts writes the database must refuse, in two groups.

The money guarantees cover:

  1. An order with no base-currency amount, attempted even through raw SQL.
  2. An order projection whose refunded amount exceeds its total.
  3. Payment-ledger refunds beyond the order total.
  4. Approved refund requests beyond the order total.
  5. A ledger row whose currency disagrees with its order.
  6. Approving more than the stored refund request amount.

The stock guarantees cover a movement that does not match the current inventory row, and — the other half of the same claim — that the canonical stock writer is allowed through, because its inventory and ledger reconcile.

That second case is what stops the suite from being satisfiable by a database that rejects everything.

Note that the assertions are about the write being rejected, not about which application function rejected it. Raw SQL is used precisely so the application layer is not in the path.

inventory-transactional.test.ts

This suite proves that concurrent checkouts on the last unit serialise correctly under the row lock.

It exercises the transactional inventory primitive against real Postgres: the guarded decrement, the race for the last unit where exactly one racer wins and stock never goes negative, the allowNegative escape hatch for the already-charged order path, and the append-only ledger whose before and after must reconcile with the delta.

It also checks that the variant rollup sums across active locations only, and that the default-location helper returns the highest-priority active location, creating one only when none exist.

A race condition is the clearest example of something a stub cannot test. The behaviour being asserted is what two real transactions do to one real row.

price-resolution.test.ts

This suite proves that a variant resolves the right price for a channel and currency, including the fallback path.

Around that it pins the conserved-value rules of the money core: a percent coupon stacking on top of sale prices is computed on the discounted total, minOrderAmount is enforced against the original subtotal, an amount coupon is capped at its maximum discount, and an exhausted, expired, not-yet-started or deactivated coupon is rejected.

An unknown code returns a message rather than throwing. A shopper mistyping a coupon is an expected event, not an error condition.

Why none of these can be a unit test

A mocked Prisma client will happily accept a row that Postgres would refuse.

A stubbed test of a database guarantee therefore proves only that the stub agrees with itself:

test

mocked client

accepts the invalid row

assertion passes

real database

never consulted

The guarantee under test is the database's refusal. Any test that does not reach the database is testing something else.

The unit lane

bun run test

Runs Vitest in watch mode.

bun run vitest run

The one-shot form, used by bun run verify and the pre-commit hook.

The unit lane aliases server-only to test/server-only-stub.ts so server modules can be imported in a test environment, and excludes **/.claude/**.

The integration config carries the same aliases, for the same reason — the code under test is the real server code either way.

Contract tests are not implementation tests

scripts/check-critical-tests.ts holds a ratchet over the critical paths. A test that fails because a guarantee changed is telling you the guarantee changed — deleting it to make a build green removes the alarm, not the fault.

When a new test belongs here

Put it in the integration lane when the thing being proved is the database's behaviour: a constraint, a trigger, a transaction boundary, or two writers racing for the same row.

Keep it in the unit lane when the behaviour is the application's and a stub cannot lie about it — a calculation, a mapping, a branch on data you pass in.

The cost of the integration lane is real. It needs infrastructure, it runs sequentially, and it takes a 30-second timeout for a reason. That cost is worth paying for a guarantee and wasted on a pure function.

The question to ask is:

Would a mocked client be able to pass this test while production is broken?

On this page