| name | e2e-sdk |
| description | Author and extend broad regression vitest E2E tests against the AtomicFi API using a typed SDK generated from the OpenAPI spec via `@hey-api/openapi-ts`. Distinct from `usecase-vitest` (5 demo use-cases with recordings) — this skill covers full-API resource CRUD + RLS isolation + screening fixtures and runs against multiple environments. Use when scaffolding or extending E2E coverage for one or more controllers in `lib/atomic_fi_api/controllers/`. |
| when_to_use | ["Adding regression coverage for a new controller / API resource","Extending CRUD coverage of an existing resource (pagination, filters, validation)","Adding RLS isolation tests for a resource","Refreshing the SDK after OpenAPI spec changes","Running E2E against a non-local environment (e.g. `atomicfi-hh.alvera.ai`)"] |
| related_artifacts | ["integration-tests/spec/openapi.yaml (committed snapshot of /api/openapi)","integration-tests/generated/ (hey-api SDK output — gitignored)","integration-tests/tests/e2e/<resource>.test.ts","integration-tests/src/sdk.ts (buildSdk helper)","integration-tests/src/fixtures.ts (sanctions / known-hit fixtures)"] |
E2E SDK Test Suite
Broad regression coverage of the AtomicFi API using a typed TypeScript SDK generated from the OpenAPI spec. Models on work/alvera-ai/platform-sdk/integration-tests/ — same harness, same runId discipline, same single-fork sequencer — but the surface is full API CRUD per resource, not the 5 demo use-cases.
This skill is for regression coverage. Bug fixing happens elsewhere: if a test reveals a bug, the human runs usecase-vitest (interactive fix loop) or fixes manually. This skill stays out of lib/.
Hard distinction from usecase-vitest
| usecase-vitest | e2e-sdk |
|---|
| Surface | 5 fixed business use-cases | Full API, one spec per controller |
| Test style | State-machine (§N transitions) | Conventional CRUD per resource |
| HTTP layer | recordingFetch (records jsonl) | @hey-api/client-fetch typed SDK |
| Outputs feed | MDX + Bruno + (future) React agents | Nothing — it's a regression suite |
| Bug protocol | Investigate-and-fix in-session | Run-and-report only |
| When run | Authoring a demo / cookbook | CI on every PR + release smoke-tests |
Both share integration-tests/ — tests/cookbook/ (cookbook flows) and tests/e2e/ (this skill) live side-by-side under one harness, one runId, one state directory.
Architecture
integration-tests/
package.json # @hey-api/openapi-ts, @hey-api/client-fetch, vitest, valibot
openapi-ts.config.ts # input: spec/openapi.yaml, output: generated/, plugins: client-fetch + valibot + sdk
vitest.config.ts # singleFork, fileParallelism: false, ManifestSequencer
vitest.sequencer.ts
spec/
openapi.yaml # COMMITTED snapshot, regenerated by `pnpm spec:fetch`
generated/ # gitignored — hey-api output, regenerated by `pnpm sdk:gen`
scripts/
fetch-spec.ts # GET <baseUrl>/api/openapi → spec/openapi.yaml
patch-generated.ts # post-codegen fixes (mirrors platform-sdk pattern)
src/
env.ts # multi-env: local | hh | prod (see below)
sdk.ts # buildSdk(bearer) → typed client with auth interceptor
state.ts # loadBase / loadSpec / saveSpec — same as cookbook
fixtures.ts # SANCTIONS_HIT / SANCTIONS_CLEAN / known-watchman strings
helpers.ts # waitForScreeningResult, expectRlsIsolation, etc.
tests/
cookbook/ # owned by usecase-vitest
e2e/
_order.json # ["bootstrap", ...] — only bootstrap is order-sensitive
bootstrap.test.ts # ROOT_EMAIL/PASSWORD → bearer → tenant + apiKey saved to runId state
account-holders.test.ts
counterparties.test.ts
legal-entities.test.ts
ledgers.test.ts
ledger-accounts.test.ts
ledger-entries.test.ts
transactions.test.ts
payment-accounts.test.ts
kyc-requirements.test.ts
compliance-screenings.test.ts
documents.test.ts
blocklist-entries.test.ts
api-keys.test.ts
users.test.ts
roles.test.ts
tenants.test.ts
... # one per controller in lib/atomic_fi_api/controllers/
vitest-state/
<runId>/
bootstrap.state.json # bearer + tenantId + apiKey + slug
<spec>.state.json # per-spec resource IDs
Multi-environment
integration-tests/src/env.ts supports multiple environments selected via TARGET_ENV:
const ENVS = {
local: { baseUrl: 'http://localhost:4000' },
hh: { baseUrl: 'https://atomicfi-hh.alvera.ai' },
prod: { baseUrl: 'https://atomicfi.alvera.ai' },
}
export const config = {
...ENVS[process.env.TARGET_ENV ?? 'local'],
rootEmail: process.env.ROOT_EMAIL ?? 'admin@dev.local',
rootPassword: process.env.ROOT_PASSWORD ?? 'devpassword',
}
Run with:
TARGET_ENV=local pnpm vitest run tests/e2e
TARGET_ENV=hh pnpm vitest run tests/e2e
Per-env credential files: integration-tests/.env.local, .env.hh, .env.prod (gitignored). Loaded via dotenv in vitest.setup.ts based on TARGET_ENV.
SDK generation (hey-api, mirrors platform-sdk)
openapi-ts.config.ts:
import { defineConfig } from '@hey-api/openapi-ts'
export default defineConfig({
input: './spec/openapi.yaml',
output: 'generated',
plugins: [
'@hey-api/client-fetch',
{ name: 'valibot', definitions: true, requests: true, responses: true },
{ name: '@hey-api/sdk', validator: 'valibot' },
],
})
Scripts in package.json:
{
"spec:fetch": "tsx scripts/fetch-spec.ts",
"sdk:gen": "openapi-ts && tsx scripts/patch-generated.ts",
"sdk:refresh": "pnpm spec:fetch && pnpm sdk:gen",
"test": "vitest run",
"test:e2e": "vitest run tests/e2e",
"test:cookbook": "vitest run tests/cookbook"
}
The OpenAPI spec is committed as spec/openapi.yaml. Refresh via pnpm sdk:refresh (which fetches from <baseUrl>/api/openapi, then regenerates the SDK). Both spec/openapi.yaml changes and generated/ regeneration land in the same PR — the spec diff is the reviewable artifact, generated/ is gitignored.
Authentication — Bearer only (current scope)
Auth is POST /api/sessions → bearer token. API key transport is out of scope for this skill (cookbook uses it; e2e doesn't, for now).
bootstrap.test.ts runs first (per _order.json):
POST /api/sessions with ROOT_EMAIL/ROOT_PASSWORD → bearer
- Verify with
GET /api/sessions/verify
- Save bearer + (optionally) create a fresh tenant for this
runId → save tenantId, tenantSlug
- All other specs
loadSpec('bootstrap') to read the bearer and tenantId
buildSdk(bearer) wires the bearer into the hey-api client via the auth option:
import { client } from './generated/client.gen.ts'
export function buildSdk(bearer: string) {
return client.setConfig({
baseUrl: config.baseUrl,
headers: { authorization: `Bearer ${bearer}` },
})
}
runId discipline (mirrors platform-sdk)
One runId per CI run / local invocation, generated once at the start by pnpm state:create:
pnpm state:create
- Every spec reads the runId from
vitest-state/current-runid.
- Every spec writes only its own slice:
vitest-state/<runId>/<spec>.state.json.
- Re-runs short-circuit completed
it() blocks via ctx.skip() checking own state.
pnpm state:clean removes stale runId directories.
pnpm state:create:clean clears + creates fresh.
CI wires this: pnpm state:create:clean && pnpm test:e2e.
Per-resource spec template
Every tests/e2e/<resource>.test.ts covers, at minimum:
- Index — empty state — fresh tenant returns
data: [] and correct meta
- Create — happy path — minimal valid body; assert 201, valid UUID, schema
- Create — invalid — known-bad body; assert 422 with
errors field
- Show — found — created resource fetched by id
- Show — 404 — random UUID returns 404
- Update — happy path — patch one field; assert 200 + updated value
- Update — invalid — bad value; assert 422
- Delete — assert 204; subsequent show returns 404
- Index — pagination — create N, page through; assert
meta.total_count + data length
- RLS isolation — same as platform: bootstrap a second tenant, create resource as tenant A, verify tenant B's index/show don't see it
Template skeleton:
import { beforeAll, describe, expect, it } from 'vitest'
import { buildSdk } from '../../src/sdk'
import { loadSpec, saveSpec, requireBootstrap } from '../../src/state'
import { expectRlsIsolation } from '../../src/helpers'
const SPEC = '<resource>'
describe(`e2e/${SPEC}`, () => {
let sdk: ReturnType<typeof buildSdk>
let s = loadSpec(SPEC) ?? { createdId: null }
beforeAll(() => {
const { bearer } = requireBootstrap()
sdk = buildSdk(bearer)
})
it('lists empty by default', async () => { })
it('creates valid resource', async (ctx) => {
if (s.createdId) { ctx.skip(); return }
const { data } = await sdk.<resource>.create({ body: VALID_BODY })
expect(data.id).toMatch(UUID_RE)
s.createdId = data.id
saveSpec(SPEC, s)
})
it('rejects invalid body', async () => { })
it('shows by id', async () => { })
it('returns 404 for unknown id', async () => { })
it('updates a field', async () => { })
it('paginates', async () => { })
it('isolates across tenants (RLS)', async () => {
await expectRlsIsolation({ resource: SPEC, createdId: s.createdId! })
})
it('deletes', async () => { })
})
Resources without a full CRUD surface (e.g. screenings are POST-only, blocklist refresh) get a tailored template — start from the controller's ExUnit test file under test/atomic_fi_api/controllers/ for ground truth.
Sanctions fixtures (hardcoded — Watchman is a prereq)
integration-tests/src/fixtures.ts:
export const SANCTIONS_HIT = {
name: 'Vladimir Putin',
}
export const SANCTIONS_CLEAN = {
name: 'Roger Federer',
}
Pre-flight check in bootstrap.test.ts: GET /api/info must report watchman: { status: "ready" } (or whatever the actual health field is). If Watchman isn't running, fail bootstrap with a clear message: "Start Watchman: docker compose up watchman (see guides/getting-started.md)".
Async screening tests: assert 202 Accepted on enqueue, then use waitForScreeningResult(screeningId, { timeoutMs: 10_000 }) helper that polls GET /api/compliance-screenings/<id> until status transitions out of pending. Don't sleep blindly.
CRITICAL RULES
- Spec is committed. Every PR that changes the API includes the regenerated
spec/openapi.yaml. Reviewers read the spec diff.
- Generated SDK is gitignored. Never commit
generated/. Always regenerate via pnpm sdk:gen.
- One bearer per runId.
bootstrap.test.ts mints it once; every other spec reuses. Don't sign in per-test.
- Tenant isolation is mandatory. Every resource spec MUST have an RLS test. No exceptions — RLS regressions are silent and catastrophic.
- Hardcoded sanctions fixtures.
Vladimir Putin (hit) and Roger Federer (clean). Watchman must be running.
- No source edits. This skill never modifies
lib/. If a test reveals a bug, hand off to usecase-vitest or fix manually.
- All commits GPG-signed, no
Co-Authored-By trailers.
Phase 1: Initialization
1.1 Bootstrap integration-tests/ if missing
Same scaffold as usecase-vitest. If both skills are run on a fresh repo, whichever runs first creates the harness; the other extends it. Add this skill's pieces:
openapi-ts.config.ts
spec/ directory + .gitkeep
scripts/fetch-spec.ts, scripts/patch-generated.ts
src/env.ts with the multi-env block
src/sdk.ts, src/fixtures.ts, src/helpers.ts
tests/e2e/ directory + _order.json
package.json deps: @hey-api/openapi-ts, @hey-api/client-fetch, valibot, dotenv
package.json scripts: spec:fetch, sdk:gen, sdk:refresh, test:e2e, state:create, state:create:clean, state:clean
.gitignore: integration-tests/generated/, integration-tests/vitest-state/, integration-tests/.env.*
Commit: chore: bootstrap e2e-sdk harness (hey-api + multi-env)
1.2 Snapshot the OpenAPI spec
TARGET_ENV=local pnpm spec:fetch
pnpm sdk:gen
Verify generated/ builds (pnpm tsc --noEmit).
1.3 Bootstrap the auth + tenant
Author tests/e2e/bootstrap.test.ts (if not present):
§1 sign in → bearer
§2 create tenant (or reuse if state exists)
§3 health-check Watchman
Run it: pnpm state:create:clean && TARGET_ENV=local pnpm vitest run tests/e2e/bootstrap.test.ts. Must be green before any resource specs are scaffolded.
Phase 2: Resource scaffolding
2.1 Identify the resource
mix phx.routes | grep "/api/<resource>" lists endpoints. Read the controller test under test/atomic_fi_api/controllers/<resource>_controller_test.exs for ground-truth fixtures and assertions.
2.2 Ask the human
"Scaffold tests/e2e/<resource>.test.ts from <resource>_controller_test.exs?
Endpoints: index, show, create, update, delete.
RLS test: yes (mandatory).
Special handling: <note any async / multi-tenant / encrypted-field surprises>."
One resource at a time. Do not bulk-scaffold.
2.3 Write the spec
Use the per-resource template above. Port assertions from the ExUnit test verbatim where possible — same fixtures, same edge cases, same status codes.
2.4 Run
TARGET_ENV=local pnpm vitest run tests/e2e/<resource>.test.ts
Must be green before moving on.
2.5 If reds
- SDK type error → spec might be stale.
pnpm sdk:refresh. If still broken, the spec is wrong — file it as a bug for the human and stop.
- Schema mismatch (valibot) → response shape changed. Either the API regressed or the spec is stale. Don't paper over — report and stop.
- Auth / RLS failure → likely a real bug. Stop and hand off (don't fix in-session — that's
usecase-vitest's job).
- Flaky async (screening) → bump
waitForScreeningResult timeout once; if still flaky, surface it.
2.6 Commit
git add integration-tests/tests/e2e/<resource>.test.ts \
integration-tests/tests/e2e/_order.json \
integration-tests/spec/openapi.yaml
git commit -S -m "test(e2e): add <resource> E2E spec
- CRUD + RLS isolation coverage
- Sourced from test/atomic_fi_api/controllers/<resource>_controller_test.exs
- Verified: TARGET_ENV=local pnpm vitest run tests/e2e/<resource>.test.ts"
Phase 3: Refreshing after a spec change
When the OpenAPI spec changes (controller / schema edits in lib/):
TARGET_ENV=local pnpm sdk:refresh
git diff integration-tests/spec/openapi.yaml
TARGET_ENV=local pnpm test:e2e
If type errors surface in tests/e2e/, the SDK contract changed in a breaking way — fix the call sites in tests, not the SDK. Commit:
chore(sdk): refresh SDK + spec snapshot
<one-line summary of the API surface change>
Phase 4: Running against a non-local environment
TARGET_ENV=hh pnpm sdk:refresh
TARGET_ENV=hh pnpm state:create:clean
TARGET_ENV=hh pnpm test:e2e
Caveats:
- Don't run against
prod without explicit human confirmation. Default to local or hh.
- The
hh and prod envs need real credentials in .env.hh / .env.prod — the human supplies these once; this skill never asks.
- Specs that mutate state (create/delete) on a shared env can collide with concurrent runs. The
runId-suffixed names from state.ts mitigate this.
Tool Reference
| Tool | When to use |
|---|
Bash (pnpm spec:fetch / sdk:gen) | Refresh SDK against current API |
Bash (pnpm vitest run tests/e2e/<spec>) | Run a single resource spec |
Bash (mix phx.routes) | List endpoints to scope a new spec |
Read | Read controller ExUnit test for ground-truth fixtures |
Write / Edit | Author / extend tests + helpers |
Not used by this skill: tidewave (no in-session debugging), source edits in lib/, GH issue creation. If you find yourself wanting any of those, hand off to usecase-vitest or to the human.
Quality Checklist (before commit)