The unit suite runs against a mocked Prisma client. That makes it fast, and it
is the right trade for logic — but a mock accepts every write, so it cannot tell
you whether a migration applies, whether a unique index exists, whether a
$transaction actually rolls back, or what two concurrent writers see. Those
are properties of PostgreSQL, and the only way to test them is to use
PostgreSQL.
This suite does. It applies every production migration to an empty database, then exercises proof issuance and revocation, webhook retry persistence, authentication sessions, payment uniqueness, transaction commit and rollback, constraint violations, and concurrent writes.
npm run test:integration- PostgreSQL 17 running locally (the version CI and
docker-compose.ymluse). - A role that can log in and create databases.
TEST_DATABASE_URLpointing at that role.
Docker is not required. If you already run the Compose stack, the Postgres service it starts works too — it is just a PostgreSQL server.
Run this as a superuser (psql -U postgres):
CREATE ROLE earnproof WITH LOGIN PASSWORD 'earnproof' CREATEDB;If the role already exists without CREATEDB:
ALTER ROLE earnproof WITH CREATEDB;Then set the target, in .env or your shell:
TEST_DATABASE_URL=postgresql://earnproof:earnproof@localhost:5432/earnproof_testPowerShell:
$env:TEST_DATABASE_URL='postgresql://earnproof:earnproof@localhost:5432/earnproof_test'CREATEDB is the only privilege beyond ordinary login that the harness needs.
It never touches a database outside the ones it creates.
earnproof_test in that URL is a naming base, not a target. The harness
never connects to it, and it does not need to exist. It derives two kinds of
database from the name:
| Database | Created by | Contains |
|---|---|---|
earnproof_test_template |
global setup, once per run | the schema, built by prisma migrate deploy |
earnproof_test_w1, _w2, … |
each Jest worker, on first use | a clone of the template |
Both are dropped in global teardown. Pointing TEST_DATABASE_URL at an existing
database therefore cannot destroy it.
Global setup drops the template, recreates it empty, and runs
prisma migrate deploy. It then checks the _prisma_migrations ledger records
every directory under prisma/migrations as applied, and that the resulting
schema matches prisma/schema.prisma with no drift.
prisma db push would have been faster and would have hidden the two failures
this is here to catch: a migration that cannot apply from empty, and a model
edited in schema.prisma with no migration behind it. The second is the
dangerous one — the generated client still works, so every unit test passes
while production is one release away from a column that does not exist.
The template is rebuilt every run rather than reused. A reused template makes the suite depend on whatever the last run left behind, which is the non-determinism this harness exists to remove.
Each Jest worker gets its own database, cloned from the template.
CREATE DATABASE ... TEMPLATE is a file copy inside PostgreSQL, so a worker is
ready in milliseconds without replaying the migration history. Workers never
share a database, which is what makes the suite parallel-safe — and it runs in
parallel by default, so a test that leaks state fails here rather than surviving
until someone drops --runInBand.
Between tests every application table is truncated in one
TRUNCATE … RESTART IDENTITY CASCADE. One statement takes all the locks at
once, so it cannot deadlock against itself and does not need foreign-key order
maintained by hand. The table list is read from the database, so a table added
by a migration is truncated without anyone editing the harness.
_prisma_migrations is excluded: truncating it would leave a database that
looks unmigrated to the next migrate deploy.
Why not wrap each test in a rolled-back transaction? It is faster, but it makes the code under test run inside a transaction it did not open — which silently breaks every test of transaction behaviour, and this suite exists largely to test transaction behaviour.
Every step that talks to PostgreSQL or spawns the Prisma CLI has a deadline.
Without one, an unreachable host, a server that accepts the socket and goes
quiet, or a DROP DATABASE blocked behind a leaked connection all present the
same way: a CI job that hangs until the runner kills it, with no output naming
the step that stalled.
| Setting | Default | Bounds |
|---|---|---|
INTEGRATION_ADMIN_TIMEOUT_MS |
15000 | connect, create, drop, truncate |
INTEGRATION_MIGRATE_TIMEOUT_MS |
120000 | prisma migrate deploy, migrate diff |
INTEGRATION_TEST_TIMEOUT_MS |
30000 | one test |
Teardown never fails the run. The tests have already reported their verdict, and reporting a cleanup problem as a test failure helps nobody; the next run's setup drops the leftovers anyway.
Set INTEGRATION_KEEP_DATABASES=true to keep the databases for inspection after
a failure.
Test output is the least guarded surface in the system. It lands in CI logs, in terminal scrollback, and pasted into issues — and a failure in this suite is assembled from real material: a live connection string, an encrypted amount, a session token minted seconds earlier.
Two redaction points cover the two ways that material escapes:
-
The database client. Every Prisma rejection is redacted at the client boundary, where Prisma attaches the datasource and the failing query. The error object is mutated rather than replaced, so
instanceofanderror.codekeep working and tests can still assert onP2002. -
Assertions.
expect(row).toEqual(expected)prints both objects, so any mismatch on aPaymentprintsamountEncryptedand any mismatch on anAuthSessionprintstokenHash. No care inside a test prevents that, because the value is printed by the matcher.
Covered: connection strings (credentials included), wallet addresses, Stellar
secret seeds, enc:v1: protected amounts, sha256: credential and wallet
hashes, opaque session tokens, webhook signing secrets, and the literal values
of known-sensitive environment variables.
Deliberately preserved: line structure, message length, and counts such as
attempt 3 of 5. A clipped or reflowed diff hides the mismatch it was printed
to show. This is why the harness does not reuse
src/common/observability/redaction.ts,
which collapses whitespace and truncates at 512 characters because it is tuned
for a single log line.
import { integrationDatabase } from "./harness/database";
import { integrationModule } from "./harness/nest";
import { seedUser, seedPayment } from "./harness/fixtures";
import { SessionService } from "../../src/auth/session.service";
const db = integrationDatabase(); // must come first
const module = integrationModule([SessionService]);
it("stores only a hash of the token", async () => {
const user = await seedUser(db.prisma, "example");
const { token } = await module.get(SessionService).create({ ...user });
const row = await db.prisma.authSession.findFirstOrThrow();
expect(JSON.stringify(row)).not.toContain(token);
});integrationDatabase() registers the hooks that create, clean, and disconnect
from the worker database, so it must be called before integrationModule() —
Jest runs beforeAll hooks in registration order, and the module's providers
connect to a database that must already exist.
integrationModule() builds a narrow Nest injector rather than booting
AppModule: the full application starts the scheduler, the anchoring worker and
the webhook delivery queue, which is background work with timers and outbound
HTTP that has nothing to do with the behaviour under test and everything to do
with a suite that hangs on teardown.
Fixtures build on
src/testing/factories — the same
deterministic, unmistakably-synthetic values documented in
docs/test-data.md — and insert them through the application's
own encryption, so a row written by a test is indistinguishable from one written
by a service.
| Path | Role |
|---|---|
jest.integration.config.js |
runner: match, setup, teardown |
test/integration/harness/config.ts |
target resolution and the refusals |
test/integration/harness/global-setup.ts |
build and migrate the template |
test/integration/harness/database.ts |
per-worker database, truncation, client redaction |
test/integration/harness/redaction.ts |
what never reaches test output |
test/integration/harness/fixtures.ts |
persistence helpers and constraint assertions |
test/integration/harness/global-teardown.ts |
drop everything the run created |
TEST_DATABASE_URL is not set — see One-time setup. It is
deliberately separate from DATABASE_URL so a development database can never be
the target.
TEST_DATABASE_URL names a database without "test" in it — the harness
creates, truncates and drops databases derived from this name and refuses any
target that is not obviously disposable. Rename the database.
permission denied to create database — the role lacks CREATEDB:
ALTER ROLE <role> WITH CREATEDB;
Connecting to the maintenance database did not finish within 15000ms —
PostgreSQL is not accepting connections on that host and port, or a firewall is
swallowing them. Check the server is running before raising the timeout.
source database is being accessed by other users — a previous run left a
connection open. Re-running clears it: setup drops stale worker databases before
cloning the template.