Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions .drive/projects/hex-composition/slices/reusable-system-testing/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# Slice spec: reusable auth System + the testing seam, proven live

The last slice of the system-composition project (H3). It turns the storefront's
auth from an inline service into a **reusable System that owns its database**,
and ships the **testing utilities** that make any app built on the framework
testable at two altitudes — proven by a unit test, an integration test, and the
existing live deploy.

Design contract: [`docs/design/10-domains/testing.md`](../../../../../docs/design/10-domains/testing.md)
(the testing model) + [`docs/design/10-domains/system-composition.md`](../../../../../docs/design/10-domains/system-composition.md)
(the System boundary). Deviations amend the docs first.

## Why

The composition machinery (H1 boundary + ADR-0017 control plane) is built and
merged, but nothing yet proves the value it exists for: that a System can be
**published, reused, and faked**. Today `examples/storefront-auth/systems/auth`
is a bare service whose Postgres is provisioned by the root and wired into its
`db` input. That is not a reusable unit — a consumer would have to know to
provision auth's storage. H3 makes auth a self-contained System and proves an
app composing it can be tested without a cloud.

## Deliverables

### 1. The reusable auth System (owns its db)

`auth` becomes a **System**, not a service: its body provisions its own Postgres
and its own compute service, wires the db in, and exposes the RPC contract as
the System's output. Its boundary has **no `db` input** — it exposes only
`{ rpc: authContract }`. The package declares `@prisma/*` as **peer
dependencies** (as a published reusable System would) and builds via its own
turbo `build` (standing in for publish-time build). The root `system.ts` no
longer provisions the database; it provisions the auth System and storefront,
wiring auth's exposed `rpc` into storefront's `auth` dependency.

### 2. `@prisma/app/testing` → `mockService` (unit seam)

Core, target-agnostic. `mockService(service, overrides)` returns a service node
whose `load()` yields `overrides` merged with the service's param defaults,
**typed against the service's `deps`** (a double not assignable to the dep's
hydrated type is a compile error). New export `@prisma/app/testing`
(`packages/app/src/testing.ts`; add `./testing` to the manifest + its tsdown
entry). It performs no module mocking itself — that stays in the test.

### 3. `bootstrapService` (integration seam)

The in-process counterpart of the deploy bootstrap. `bootstrapService(service,
config, boot?)` lives in **`@prisma/app-cloud/testing`** (target-specific —
writing the environment is the serializer's job) and returns a handle
`{ url, fetch }`. It writes the chosen config with the target's own `stash` +
`configOf`, boots the real entry, and hands back a driveable server. **No test
code on the production node** — the `compute()` runtime ships only `run`/`load`.
**`server.ts` is not modified.** No `close()`: the entry owns its `Bun.serve`
handle, so teardown rides on bun-test's per-file process isolation (a single
boot per test file, cleaned up when the file's process ends). This is the
accepted trade for leaving the entry untouched.

### 4. The fake auth (ships from the auth package)

A `/fake` export on the auth package: an in-memory `verify` (`serve(fakeAuth, {
rpc: { verify: async ({ token }) => ({ ok: token.length > 0 }) } })`), no
Postgres, sharing the real `authContract` so its handler map is typed against
the same contract. Used by both proof tests.

## Proof

- **Unit test** — renders storefront's `page.tsx` with `load()` mocked via
`mockService` to a fake `auth`; asserts the rendered output. No server, no env,
no cloud. (vitest — the storefront's runner.)
- **Integration test** — runs the fake auth on a loopback port, boots storefront
via `bootstrapService` with `auth.url` pointed at it, drives the page over
HTTP, asserts the round trip. No cloud.
- **Live e2e** — the existing "Deploy, verify, destroy" job, unchanged in shape,
now deploys the composed **auth System + storefront** to real Prisma Cloud and
verifies the round trip. This is the reusable-System-deployed-for-real proof.
- All repo gates green (typecheck, test, lint, build, casts delta ≤ 0).

## Out of scope

- Running a whole composed graph locally (multi-service `dev` orchestration) —
a separate capability the testing doc lists as a non-goal.
- A runner-agnostic module-mock abstraction — `mockService` ships the typed
payload; the `vi.mock`/`mock.module` wiring stays in the tests.
- The post-merge cleanups (LoadedControl-style lookup dedup; folding
`@prisma/alchemy` into `@prisma/app-cloud`) — tracked separately.

## Decisions (resolved)

- **Teardown:** option (a) — no `close()`; bun-test's per-file process isolation
cleans up. `server.ts` stays untouched. (A cleaner "target owns the listen"
refactor is explicitly out of scope for H3.)
- **Both paths ship, not either/or.** The unit test (`mockService`) AND the
integration test (`bootstrapService`) are both deliverables. The integration
test boots the **full Next storefront** in-process against a loopback fake auth
— the real round trip. If Next-in-process boot proves genuinely intractable,
fall back to driving a minimal RPC consumer through `bootstrapService` and flag
it in the final report — do not drop the integration path.

## Notes for implementation

- `mockService`'s override type is the service's hydrated deps
(`Client<C>` for rpc, the resource binding for resources) plus optional param
overrides — derive it from the node's `Deps`/params, do not hand-roll.
- `bootstrapService` (`@prisma/app-cloud/testing`) reuses `stash`
(serializer.ts) + `configOf` — it must not add a second serialize path, and
must add nothing to the production `compute()` node; writer/reader parity with
deploy is the whole point.
- Keep the auth System's `authContract` and the fake in one package so the
contract cannot drift.
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ jobs:
env:
STATE_TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres
run: pnpm test
- name: Type-only tests (vitest --typecheck)
run: pnpm turbo run test:types
- name: Test scripts (cast-ratchet unit tests)
run: pnpm test:scripts

Expand Down
185 changes: 185 additions & 0 deletions docs/design/10-domains/testing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
# Testing an app built on the framework

You test a Prisma App by controlling one function: `service.load()`, the single
call through which application code gets its dependencies. You never change the
code under test to make it testable — you decide what `load()` hands it. Two
tools cover the two situations you'll meet:

- **`mockService`** — unit-test a piece of code (a page, a server action, a
helper) with fake dependencies.
- **`bootstrapService`** — integration-test the real request path: the service
actually boots and serves, talking to stand-ins you run yourself.

## A worked example

The `storefront` app has a page that depends on an `auth` service:

```tsx
// storefront/app/page.tsx — ordinary application code
import service from '../src/service.ts';

export default async function Page() {
const { auth } = service.load();
const { ok } = await auth.verify({ token: 'demo' });
return <p>Signed in: {String(ok)}</p>;
}
```

The page gets `auth` by calling `service.load()`. To test it without a real auth
service — no database, no deployment, no cloud account — you replace what
`load()` returns:

```tsx
// storefront/app/page.test.tsx
import { mockService } from '@prisma/app/testing';

vi.mock('../src/service.ts', () => ({
default: mockService(realService, {
auth: { verify: async () => ({ ok: true }) },
}),
}));

import Page from './page.tsx';

expect(renderToString(await Page())).toContain('Signed in: true');
```

The page runs its real logic; only `auth` is a stand-in. Everything below
expands on this one move.

## Why one function is enough

A service declares the dependencies it needs and the ports it exposes. Its code
— a page, a server action, an RPC handler, a plain function — never receives
those dependencies as arguments and never reaches for a global. It calls
`service.load()`.

`load()` does three things: it reads the service's configuration (which a
deployment places in the process environment), turns each dependency into the
concrete client the code will call (ADR-0015), and returns them with their real
types. Because this is the *only* way application code reaches a dependency, it
is the only place a test has to intervene — which is what lets the tests leave
the application code completely untouched.

The two tools intervene at the same point from opposite directions.
`mockService` decides what `load()` **returns**; `bootstrapService` decides what
`load()` **reads**. Which you want depends on how much of the real path you're
testing.

## Unit tests: `mockService`

When you want to test a piece of code in isolation — call it directly, assert on
what it returns or renders — use `mockService`. You mock the service module so
`load()` yields doubles, then exercise the code with no server and no
environment. That is the worked example above.

`mockService(service, doubles)` returns a copy of the service whose `load()`
returns your doubles merged with the service's own parameter defaults. The
doubles are typed against the service's declared dependencies: a fake `auth`
must be a valid `authContract` client, so a wrong-shaped fake is a compile
error, not a test that passes by accident.

It works for any service — every service has a `load()` — so it lives in the
framework core, `@prisma/app/testing`. The one part that depends on your test
runner is *how* you substitute the module (`vi.mock` in Vitest, `mock.module`
in bun test). The framework gives you the typed value to substitute; wiring it
into the runner stays in your test.

## Integration tests: `bootstrapService`

When you want the real request path — the actual server boot, the real network
client, the real wire format — use `bootstrapService`. It starts your service's
real entry point in a configuration you choose, in-process, and hands you
something you can send HTTP requests to. You point one of its dependencies at a
stand-in you run yourself, and drive the round trip.

```ts
// storefront/app/page.integration.test.ts
import { bootstrapService } from '@prisma/app-cloud/testing';
import fakeAuth from '@storefront-auth/auth/fake'; // an in-memory auth handler, no database
import storefront from '../src/service.ts';

// run the fake auth on a loopback port
const fake = Bun.serve({ port: 0, fetch: fakeAuth });

const app = await bootstrapService(storefront, {
service: { port: 4310 },
inputs: { auth: { url: fake.url.href } }, // point storefront's auth dependency at the fake
});

const res = await app.fetch(new Request(app.url));
expect(await res.text()).toContain('Signed in: true');
```

The point is what *doesn't* change: `storefront`'s server code is untouched. It
boots and listens exactly as it does in production; the test only chooses the
configuration it boots with. `load()` reads that configuration the same way a
deployed process would, so pointing `auth` at `http://localhost:…` is the same
mechanism a deployment uses to point it at the real service. You exercise the
production code path, not a rewrite of it.

Starting a service the way a deployment does is specific to the platform you
deploy to, so `bootstrapService` ships in that platform's testing entry
(`@prisma/app-cloud/testing`), not the core. (`mockService` only substitutes a
return value, so it needs to know nothing about deployment and stays in core.)

Three practical notes:

- **You choose the port.** The service listens on it and never reports an
OS-assigned one back, so pass a concrete number.
- **There is no `close()`.** Run each integration-test file in its own process
(bun test does), and the server it started is cleaned up when the file ends.
- **A Next.js service needs one extra argument.** `bootstrapService` finds most
services' entry points automatically, but a Next.js app's built entry lives
inside Next's standalone output directory, so you pass a small function that
imports it:

```ts
import { standaloneEntryPath } from '@prisma/app-nextjs/control';

await bootstrapService(storefront, config, async () => {
await import(standaloneEntryPath(storefront.build));
});
```

## The stand-in: same contract, checked by the compiler

What do you pass as the fake? A dependency's type *is* its contract. An RPC
dependency on `authContract` becomes a client with a `verify(input) =>
Promise<output>` method, so any value of that shape is a valid double and the
compiler rejects one that isn't. You choose how realistic to make it:

- **A bare object** — `{ verify: async () => ({ ok: true }) }`. Fastest; no
network, no serialization. Right when only the return value matters.
- **The real client over an in-memory handler** — the framework's own client
talking to your fake through an in-process function instead of the network.
JSON encoding and both schema validations still run; there is just no socket.
- **A real local server** — the fake served on a loopback port and reached over
real HTTP. This is what `bootstrapService` drives.

A dependency's package can ship its own fake as a separate entry point, kept out
of the deployed code. Because that fake is written against the same contract the
real service exposes, the two cannot drift apart.

## Alternatives considered

- **Add injection points to the code under test** (constructor or parameter
injection, so a test passes fakes in directly). Rejected: application code
would carry test-only seams, and there is nothing to add — `load()` already
*is* the single point every dependency flows through.
- **Boot the whole composed app locally**, several services wired together in
one process. Rejected as a *different* capability: `bootstrapService` boots
one service. Running a full graph locally (a local `dev`) is worth building on
its own terms, but it is not this seam.
- **A runner-agnostic mock wrapper** hiding `vi.mock`/`mock.module` behind one
API. Rejected: runners differ in module-mock mechanics (hoisting, ESM
handling) in ways not worth papering over. The framework supplies the typed
value and documents the per-runner pattern instead.

## Related

- [`core-model.md`](core-model.md) — the `run`/`load` split these tools drive.
- [`deploy-cli.md`](deploy-cli.md) — the deployment boot that `bootstrapService`
mirrors.
- [`../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md`](../90-decisions/ADR-0015-dependencies-resolve-to-bindings-clients-are-app-side.md)
— why a hydrated dependency is a client a test can stand in for.
23 changes: 8 additions & 15 deletions examples/storefront-auth/system.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
import { system } from '@prisma/app';
import { postgres } from '@prisma/app-cloud';
import authService from '@storefront-auth/auth';
import authSystem from '@storefront-auth/auth';
import storefrontService from '@storefront-auth/storefront';

/**
* The storefront-auth app: two services and their shared Postgres in one system.
* The system owns the database and wires it into auth's `db` slot; `auth` exposes
* an RPC contract; `storefront` consumes it (auth's `rpc` port → storefront's
* `auth` slot, compat-checked). Transparent wiring, executed at Load.
*
* The provision id is `database`, not `db`: the prisma-cloud target passes it
* through as the Prisma resource name, and the Connection API rejects names
* shorter than 3 characters. The wiring key stays `db` (auth's input name), so
* the deployed env key is still `AUTH_DB_URL` — it derives from the input
* name, not the provision id.
* The storefront-auth app: the reusable auth System (owns its own Postgres)
* and the storefront service, composed in one root. The root provisions
* nothing of auth's internals — it wires auth's exposed `rpc` port into
* storefront's `auth` slot exactly as it would for any other producer of
* that contract.
*
* A closed root: empty boundary (no inputs, no outputs) — nothing wires into
* or out of this system from the outside.
*/
export default system('storefront-auth', {}, ({ provision }) => {
const db = provision('database', postgres({ name: 'database' }));
const authRef = provision('auth', authService, { db });
provision('storefront', storefrontService, { auth: authRef.rpc });
const auth = provision('auth', authSystem);
provision('storefront', storefrontService, { auth: auth.rpc });
return {};
});
15 changes: 11 additions & 4 deletions examples/storefront-auth/systems/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,29 @@
"private": true,
"type": "module",
"exports": {
".": "./src/service.ts",
"./contract": "./src/contract.ts"
".": "./src/system.ts",
"./contract": "./src/contract.ts",
"./fake": "./testing/fake.ts"
},
"scripts": {
"dev": "bun run scripts/dev.ts",
"build": "tsdown",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"arktype": "^2.2.3"
},
"peerDependencies": {
"@prisma/app": "workspace:0.2.0",
"@prisma/app-node": "workspace:0.2.0",
"@prisma/app-cloud": "workspace:0.2.0",
"@prisma/app-rpc": "workspace:0.2.0",
"arktype": "^2.2.3"
"@prisma/app-rpc": "workspace:0.2.0"
},
"devDependencies": {
"@prisma/app": "workspace:0.2.0",
"@prisma/app-node": "workspace:0.2.0",
"@prisma/app-cloud": "workspace:0.2.0",
"@prisma/app-rpc": "workspace:0.2.0",
"@types/bun": "^1.3.13",
"tsdown": "^0.22.3",
"typescript": "^6.0.3"
Expand Down
23 changes: 23 additions & 0 deletions examples/storefront-auth/systems/auth/src/system.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* The auth System: a reusable unit that owns its own Postgres. Provisions the
* database and the auth compute service (service.ts), wires the db into the
* service's `db` input, and exposes the service's `rpc` port as the System's
* own output. A consumer never provisions auth's storage itself — it wires
* only the exposed `rpc` contract (system-composition.md).
*
* The provision id for the database is "database", not "db": the
* prisma-cloud target passes it through as the Prisma resource name, and the
* Connection API rejects names shorter than 3 characters. The wiring key
* (the service's own input name) stays "db" — the deployed env key derives
* from that, not the provision id.
*/
import { system } from '@prisma/app';
import { postgres } from '@prisma/app-cloud';
import { authContract } from './contract.ts';
import authService from './service.ts';

export default system('auth', { expose: { rpc: authContract } }, ({ provision }) => {
const db = provision('database', postgres({ name: 'database' }));
const service = provision('service', authService, { db });
return { rpc: service.rpc };
});
Loading
Loading