Files
suaveplan/docs/superpowers/plans/2026-04-24-testing-integration-plugins.md
2026-05-27 23:29:53 +01:00

1567 lines
59 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `@suaveplan/testing-integration-*` Plugin Packages Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship all six `@suaveplan/testing-integration-*` plugin packages — SurrealDB, Redis, Postgres, Mailpit, Typesense, and Garage S3 — each implementing the `ServiceFixture<TConfig>` interface from `@suaveplan/testing-integration`, complete with env-bridge passthrough mode, health checks, 100% Istanbul coverage via constructor-injected fakes, co-located `.md` docs, and a `skipIfUnavailable`-guarded integration smoke test.
**Architecture:** Each plugin package lives at `universal/dev/testing-integration-<name>/`. Every package is a thin wrapper: a `ServiceFixture<TConfig>` implementation using testcontainers `GenericContainer`, a constants file for defaults, and (for Mailpit) a `helpers/assert-mail.ts` utility. The fixture injects `GenericContainer` via constructor so unit tests can swap in a fake without Docker. Env-bridge passthrough eliminates container startup when a developer's live services are already running.
**Tech Stack:** Bun, TypeScript, Vite (`vite-plugin-dts`, `preserveModules`), Vitest + Istanbul, `testcontainers` (`catalog:` — must be added to the monorepo catalog before these chunks run), `@suaveplan/testing-integration` (`workspace:^`).
**Spec:** `docs/superpowers/specs/2026-04-24-enterprise-testbed-design.md` — Sections 4.4–4.10.
**Critical pre-condition:** `@suaveplan/testing-integration` (Plan 1) MUST be published to Verdaccio at `https://npmjs.fq.io/` and be resolvable as `workspace:^` before any chunk below runs. Confirm with: `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration`.
**Catalog addition required (one-time, done before Chunk 1):** Add `testcontainers` to `package.json` root catalog so all plugin packages can reference it as `"testcontainers": "catalog:integration"`:
```json
// in package.json → workspaces → catalogs → add a new "integration" catalog:
"integration": {
"testcontainers": "^10.21.0"
}
```
---
## Chunk 1 — `@suaveplan/testing-integration-surrealdb`
**Package path:** `universal/dev/testing-integration-surrealdb/`
**Image:** `surrealdb/surrealdb:v3.0.4`
**Container port:** `8000` (HTTP + WebSocket on the same port)
**Health check:** `HTTP GET http://localhost:<mappedPort>/health` → 200
### 1.1 File Map
```text
universal/dev/testing-integration-surrealdb/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
constants/
index.ts ← all defaults inline
fixture/
index.ts ← barrel
surrealdb-fixture.ts ← SurrealDBFixture implements ServiceFixture<SurrealDBConfig>
surrealdb-fixture.types.ts ← SurrealDBConfig, SurrealDBFixtureOptions
surrealdb-fixture.md
surrealdb-fixture.test.ts
index.ts ← root barrel
```
### 1.2 `package.json`
```json
{
"name": "@suaveplan/testing-integration-surrealdb",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "SurrealDB v3 testcontainers fixture for @suaveplan/testing-integration",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"require": { "types": "./dist/index.d.ts", "default": "./dist/index.js" }
},
"./fixture/surrealdb-fixture": {
"types": "./dist/fixture/surrealdb-fixture.d.ts",
"default": "./dist/fixture/surrealdb-fixture.js"
},
"./constants": {
"types": "./dist/constants/index.d.ts",
"default": "./dist/constants/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/testing-integration": "workspace:^",
"testcontainers": "catalog:integration"
},
"devDependencies": {
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}
```
### 1.3 `src/constants/index.ts`
```typescript
/** Default Docker image for SurrealDB. */
export const SURREALDB_IMAGE = "surrealdb/surrealdb:v3.0.4";
/** Container port exposed by SurrealDB (HTTP + WebSocket). */
export const SURREALDB_CONTAINER_PORT = 8000;
/** Default namespace for test databases. */
export const SURREALDB_DEFAULT_NAMESPACE = "test";
/** Default database name for test databases. */
export const SURREALDB_DEFAULT_DATABASE = "test";
/** Default username. */
export const SURREALDB_DEFAULT_USERNAME = "root";
/** Health check poll interval in milliseconds. */
export const SURREALDB_HEALTH_POLL_INTERVAL_MS = 1_000;
/** Maximum time to wait for SurrealDB to become healthy. */
export const SURREALDB_HEALTH_TIMEOUT_MS = 60_000;
/** Env vars checked for passthrough mode — ALL must be set. */
export const SURREALDB_ENV_VARS = [
"SURREALDB_URL",
"SURREALDB_USERNAME",
"SURREALDB_PASSWORD",
] as const;
```
### 1.4 `src/fixture/surrealdb-fixture.types.ts`
```typescript
import type { GenericContainer } from "testcontainers";
export interface SurrealDBConfig {
/** WebSocket URL: "ws://localhost:<port>" */
url: string;
namespace: string;
database: string;
username: string;
/** Generated per run via crypto.randomUUID() */
password: string;
}
export interface SurrealDBFixtureOptions {
/** Override the Docker image. Defaults to SURREALDB_IMAGE constant. */
image?: string;
/** Override namespace. Defaults to "test". */
namespace?: string;
/** Override database. Defaults to "test". */
database?: string;
/**
* Injectable GenericContainer constructor for unit testing.
* When provided, the fixture uses this instead of importing from testcontainers.
* This is the primary seam for unit tests — pass a fake container factory.
*/
containerFactory?: (image: string) => GenericContainer;
}
```
### 1.5 `src/fixture/surrealdb-fixture.ts`
Full implementation. Key points:
- Constructor receives `SurrealDBFixtureOptions`; stores `containerFactory` as a protected field.
- `start()`: check env vars first (`SURREALDB_URL`, `SURREALDB_USERNAME`, `SURREALDB_PASSWORD` all set + non-empty → passthrough). Otherwise: generate `password = crypto.randomUUID()`, build container with `GenericContainer(image).withCommand(["start", "--auth", "--user", "root", "--pass", password]).withExposedPorts(SURREALDB_CONTAINER_PORT)`, call `.start()`, map host port, return config.
- `healthCheck()`: `fetch(`<http://localhost:${port}/health`)`> → `res.ok`.
- `stop()`: call `container.stop()` if not in passthrough mode.
- `name` getter returns `"surrealdb"`.
```typescript
// src/fixture/surrealdb-fixture.ts
import type { StartedTestContainer } from "testcontainers";
import type { ServiceFixture } from "@suaveplan/testing-integration";
import {
SURREALDB_IMAGE,
SURREALDB_CONTAINER_PORT,
SURREALDB_DEFAULT_NAMESPACE,
SURREALDB_DEFAULT_DATABASE,
SURREALDB_DEFAULT_USERNAME,
SURREALDB_ENV_VARS,
} from "../constants/index.js";
import type { SurrealDBConfig, SurrealDBFixtureOptions } from "./surrealdb-fixture.types.js";
export class SurrealDBFixture implements ServiceFixture<SurrealDBConfig> {
readonly name = "surrealdb";
protected readonly options: SurrealDBFixtureOptions;
protected container: StartedTestContainer | null = null;
protected config: SurrealDBConfig | null = null;
protected passthrough = false;
constructor(options: SurrealDBFixtureOptions = {}) {
this.options = options;
}
async start(): Promise<SurrealDBConfig> {
// Env-bridge passthrough mode
const [url, username, password] = SURREALDB_ENV_VARS.map(
(v) => process.env[v] ?? ""
);
if (url && username && password) {
this.passthrough = true;
this.config = {
url,
namespace: this.options.namespace ?? SURREALDB_DEFAULT_NAMESPACE,
database: this.options.database ?? SURREALDB_DEFAULT_DATABASE,
username,
password,
};
return this.config;
}
const generatedPassword = crypto.randomUUID();
const image = this.options.image ?? SURREALDB_IMAGE;
const { GenericContainer } = await import("testcontainers");
const factory =
this.options.containerFactory ??
((img: string) => new GenericContainer(img));
const raw = factory(image)
.withCommand([
"start",
"--auth",
"--user",
SURREALDB_DEFAULT_USERNAME,
"--pass",
generatedPassword,
])
.withExposedPorts(SURREALDB_CONTAINER_PORT);
this.container = await raw.start();
const port = this.container.getMappedPort(SURREALDB_CONTAINER_PORT);
this.config = {
url: `ws://localhost:${port}`,
namespace: this.options.namespace ?? SURREALDB_DEFAULT_NAMESPACE,
database: this.options.database ?? SURREALDB_DEFAULT_DATABASE,
username: SURREALDB_DEFAULT_USERNAME,
password: generatedPassword,
};
return this.config;
}
async stop(): Promise<void> {
if (this.passthrough || !this.container) return;
await this.container.stop();
this.container = null;
}
async healthCheck(): Promise<boolean> {
if (!this.container) return false;
const port = this.container.getMappedPort(SURREALDB_CONTAINER_PORT);
try {
const res = await fetch(`http://localhost:${port}/health`);
return res.ok;
} catch {
return false;
}
}
}
```
### 1.6 `src/fixture/surrealdb-fixture.test.ts`
TDD approach — write all tests FIRST, then run (red), then implement (green).
Tests to cover for 100% Istanbul:
1. **Passthrough mode:** set all three env vars → `start()` returns config from env, `stop()` is no-op, `healthCheck()` returns `false` when no container.
2. **Container mode - happy path:** fake `containerFactory` returns a fake `StartedTestContainer` with `getMappedPort(8000) → 9999`, `stop()` resolves. `start()` returns correct config with `ws://localhost:9999`. `stop()` calls `container.stop()`.
3. **Container mode - healthCheck returns true:** fake container, mock `globalThis.fetch` to return `{ ok: true }`.
4. **Container mode - healthCheck returns false on fetch error:** fake container, make fetch throw → returns `false`.
5. **`name` property:** `fixture.name === "surrealdb"`.
6. **Image override:** pass `image: "custom:tag"` — verify factory called with custom image string.
7. **Namespace/database override:** pass `namespace: "ns1", database: "db1"` — verify in returned config.
8. **Generated password is UUID format:** regex `/^[0-9a-f-]{36}$/`.
All tests: `{ timeout: 5000 }` for sync/constructor tests, `{ timeout: 30000 }` for async `start()`/`stop()`.
The fake container factory pattern:
```typescript
function makeContainerFake(mappedPort = 9999) {
const stopped: string[] = [];
const fakeStarted = {
getMappedPort: (_port: number) => mappedPort,
stop: async () => { stopped.push("stopped"); },
};
const fakeContainer = {
withCommand: function() { return this; },
withExposedPorts: function() { return this; },
start: async () => fakeStarted,
};
return {
fakeContainer,
fakeStarted,
stopped,
factory: (_img: string) => fakeContainer as any,
};
}
```
One `skipIfUnavailable`-guarded integration test (requires Docker) verifies a real SurrealDB HTTP health endpoint returns 200.
### 1.7 `src/fixture/surrealdb-fixture.md`
Minimum 200 words. Sections: Purpose (SurrealDB integration fixture for testcontainers), Features (env-bridge, health check, random password, injectable container factory), Usage: Basic Example, Usage: Advanced Example (custom image + namespace), API Reference for `SurrealDBFixture`, `SurrealDBConfig`, `SurrealDBFixtureOptions`, Implementation Notes (why HTTP health over WS, why random password per run, testcontainers port mapping).
### 1.8 `src/index.ts`
```typescript
export { SurrealDBFixture } from "./fixture/surrealdb-fixture.js";
export type { SurrealDBConfig, SurrealDBFixtureOptions } from "./fixture/surrealdb-fixture.types.js";
export * from "./constants/index.js";
```
### 1.9 `src/fixture/index.ts`
```typescript
export { SurrealDBFixture } from "./surrealdb-fixture.js";
export type { SurrealDBConfig, SurrealDBFixtureOptions } from "./surrealdb-fixture.types.js";
```
### 1.10 Config files
**`tsconfig.lib.json`** — extends `@suaveplan/typescript-config/library.json`, sets `rootDir: "src"`, `outDir: "dist"`.
**`vite.config.ts`** — `lib` mode, `preserveModules: true`, `entry: "src/index.ts"`, `external: ["testcontainers", "@suaveplan/testing-integration"]`, `plugins: [dts()]`.
**`vitest.config.ts`** — `pool: "forks"`, `provider: "istanbul"`, `passWithNoTests: true`.
**`README.md`** — package description, install instructions, quick start, links to `src/fixture/surrealdb-fixture.md`.
### 1.11 Implementation sequence
- [ ] Create directory `universal/dev/testing-integration-surrealdb/`
- [ ] Write `package.json`
- [ ] Write `tsconfig.lib.json`
- [ ] Write `vite.config.ts`
- [ ] Write `vitest.config.ts`
- [ ] Write `src/constants/index.ts`
- [ ] Write `src/fixture/surrealdb-fixture.types.ts`
- [ ] Write `src/fixture/surrealdb-fixture.test.ts` (TDD: tests first)
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-surrealdb` → expect failures
- [ ] Write `src/fixture/surrealdb-fixture.ts`
- [ ] Write `src/fixture/surrealdb-fixture.md`
- [ ] Write `src/fixture/index.ts`
- [ ] Write `src/index.ts`
- [ ] Write `README.md`
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-surrealdb` → expect green
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration-surrealdb` → must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/testing-integration-surrealdb` → must be 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/testing-integration-surrealdb`
---
## Chunk 2 — `@suaveplan/testing-integration-redis`
**Package path:** `universal/dev/testing-integration-redis/`
**Image:** `redis:7-alpine`
**Container port:** `6379`
**Health check:** TCP connect to `localhost:<mappedPort>`, send `PING\r\n`, expect response starting with `+PONG`
### 2.1 File Map
```text
universal/dev/testing-integration-redis/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
constants/
index.ts
fixture/
index.ts
redis-fixture.ts
redis-fixture.types.ts
redis-fixture.md
redis-fixture.test.ts
index.ts
```
### 2.2 `package.json`
Same shape as Chunk 1. Name: `@suaveplan/testing-integration-redis`. No additional runtime deps beyond `testcontainers` and `@suaveplan/testing-integration`. The health check uses Node.js `net` module — no `ioredis` in this package (the fixture returns a plain config; consumers bring their own client).
### 2.3 `src/constants/index.ts`
```typescript
export const REDIS_IMAGE = "redis:7-alpine";
export const REDIS_CONTAINER_PORT = 6379;
export const REDIS_HEALTH_TIMEOUT_MS = 60_000;
export const REDIS_HEALTH_POLL_INTERVAL_MS = 1_000;
export const REDIS_ENV_VARS = ["REDIS_URL"] as const;
```
### 2.4 `src/fixture/redis-fixture.types.ts`
```typescript
import type { GenericContainer } from "testcontainers";
export interface RedisConfig {
/** Full Redis URL: "redis://localhost:<port>" */
url: string;
host: string;
port: number;
}
export interface RedisFixtureOptions {
image?: string;
/**
* Injectable container factory for unit testing.
*/
containerFactory?: (image: string) => GenericContainer;
/**
* Injectable TCP ping function for healthCheck unit testing.
* Returns true when Redis responds with PONG.
* Default: real Node.js net.createConnection-based implementation.
*/
tcpPingFn?: (port: number, host: string) => Promise<boolean>;
}
```
### 2.5 `src/fixture/redis-fixture.ts`
Key implementation details:
- `name = "redis"`.
- **Passthrough:** `REDIS_URL` set + non-empty → parse host/port from URL using `new URL(url)`, return `RedisConfig`.
- **Container:** `GenericContainer(image).withExposedPorts(REDIS_CONTAINER_PORT).start()`. No custom command needed — default Redis image starts the server automatically.
- **`healthCheck()`:** calls `this.options.tcpPingFn` if provided, else runs the built-in TCP PING helper. The built-in connects via `node:net`, writes `"PING\r\n"`, reads the response, and checks that it starts with `+PONG`.
TCP health check using Node `net`:
```typescript
async function defaultTcpPing(port: number, host: string): Promise<boolean> {
const net = await import("node:net");
return new Promise((resolve) => {
const socket = net.createConnection({ port, host }, () => {
socket.write("PING\r\n");
});
socket.on("data", (data) => {
socket.destroy();
resolve(data.toString().startsWith("+PONG"));
});
socket.on("error", () => resolve(false));
socket.setTimeout(2000, () => {
socket.destroy();
resolve(false);
});
});
}
```
### 2.6 `src/fixture/redis-fixture.test.ts`
Tests to cover:
1. **Passthrough mode:** set `REDIS_URL=redis://127.0.0.1:6380` → returns `{ url, host: "127.0.0.1", port: 6380 }`, `stop()` no-op.
2. **Container mode happy path:** fake container factory → `start()` returns correct URL and port.
3. **`healthCheck()` returns true:** fake `tcpPingFn` returns `true`.
4. **`healthCheck()` returns false:** fake `tcpPingFn` returns `false`.
5. **`healthCheck()` when no container:** returns `false`.
6. **`stop()` calls container.stop():** verify via spy on fake container.
7. **`name` property:** `"redis"`.
8. **Image override:** verify factory receives custom image.
All async tests: `{ timeout: 30000 }`.
### 2.7 `src/fixture/redis-fixture.md`
Purpose: Redis 7 fixture using testcontainers. Features: env-bridge, TCP PING health check, injectable TCP ping function for unit tests, random port allocation. Basic example, advanced example, API reference, implementation notes on why TCP PING over Redis client (no client dep in fixture), URL parsing for passthrough.
### 2.8 Implementation sequence
- [ ] Create directory `universal/dev/testing-integration-redis/`
- [ ] Write `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
- [ ] Write `src/constants/index.ts`
- [ ] Write `src/fixture/redis-fixture.types.ts`
- [ ] Write `src/fixture/redis-fixture.test.ts` (TDD: tests first)
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-redis` → expect failures
- [ ] Write `src/fixture/redis-fixture.ts`
- [ ] Write `src/fixture/redis-fixture.md`
- [ ] Write `src/fixture/index.ts`
- [ ] Write `src/index.ts`
- [ ] Write `README.md`
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-redis` → expect green
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration-redis` → must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/testing-integration-redis` → must be 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/testing-integration-redis`
---
## Chunk 3 — `@suaveplan/testing-integration-postgres`
**Package path:** `universal/dev/testing-integration-postgres/`
**Image:** `postgres:16-alpine`
**Container port:** `5432`
**Health check:** `container.exec(["pg_isready", "-U", "postgres"])` → exit code `0`
### 3.1 File Map
```text
universal/dev/testing-integration-postgres/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
constants/
index.ts
fixture/
index.ts
postgres-fixture.ts
postgres-fixture.types.ts
postgres-fixture.md
postgres-fixture.test.ts
index.ts
```
### 3.2 `package.json`
Same shape as Chunk 1. Name: `@suaveplan/testing-integration-postgres`. No `pg` in dependencies — the fixture returns a plain config string; consumers bring their own `pg` client.
### 3.3 `src/constants/index.ts`
```typescript
export const POSTGRES_IMAGE = "postgres:16-alpine";
export const POSTGRES_CONTAINER_PORT = 5432;
export const POSTGRES_DEFAULT_USER = "test";
export const POSTGRES_DEFAULT_PASSWORD = "test";
export const POSTGRES_DEFAULT_DATABASE = "test";
export const POSTGRES_HEALTH_TIMEOUT_MS = 60_000;
export const POSTGRES_HEALTH_POLL_INTERVAL_MS = 1_000;
export const POSTGRES_ENV_VARS = ["POSTGRES_URL"] as const;
```
### 3.4 `src/fixture/postgres-fixture.types.ts`
```typescript
import type { GenericContainer } from "testcontainers";
export interface PostgresConfig {
/** Full connection URL: "postgresql://test:test@localhost:<port>/test" */
url: string;
host: string;
port: number;
database: string;
username: string;
password: string;
}
export interface PostgresFixtureOptions {
image?: string;
database?: string;
username?: string;
password?: string;
containerFactory?: (image: string) => GenericContainer;
/**
* Injectable in-container command executor for healthCheck unit testing.
* Returns exit code. Default uses container.exec().
*/
execFn?: (cmd: string[]) => Promise<{ exitCode: number }>;
}
```
### 3.5 `src/fixture/postgres-fixture.ts`
Key implementation details:
- `name = "postgres"`.
- **Passthrough:** `POSTGRES_URL` set + non-empty → parse URL for host/port/db/user/pass, return `PostgresConfig`.
- **Container:** `GenericContainer(image).withExposedPorts(5432).withEnvironment({ POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB }).start()`.
- **`healthCheck()`:** calls `execFn(["pg_isready", "-U", "postgres"])` or uses the stored container's `.exec()` method. Returns `result.exitCode === 0`.
Passthrough URL parsing: `new URL(url)` — host from `parsed.hostname`, port from `parseInt(parsed.port)`, database from `parsed.pathname.slice(1)`, username from `parsed.username`, password from `parsed.password`.
### 3.6 `src/fixture/postgres-fixture.test.ts`
Tests to cover:
1. **Passthrough mode:** set `POSTGRES_URL=postgresql://test:test@localhost:5433/test` → verify all parsed config fields.
2. **Container mode happy path:** fake container factory + fake `execFn`. `start()` returns correct URL. `stop()` clears container.
3. **`healthCheck()` returns true:** fake `execFn` returns `{ exitCode: 0 }`.
4. **`healthCheck()` returns false:** fake `execFn` returns `{ exitCode: 1 }`.
5. **`healthCheck()` when no container:** returns `false`.
6. **`stop()` when no container:** no-op, no throw.
7. **Custom database/username/password options:** verify env vars passed to container, URL contains the overrides.
8. **`name` property:** `"postgres"`.
### 3.7 `src/fixture/postgres-fixture.md`
Purpose: PostgreSQL 16 fixture using testcontainers, using `pg_isready` inside the container for health. No external `pg` client dependency. Features: env-bridge, in-container health check, injectable exec function, configurable db/user/password. API reference. Implementation notes on why `pg_isready` over TCP (avoids `pg` dep, uses the binary shipping in the official image, more reliable for auth-gated health checks).
### 3.8 Implementation sequence
- [ ] Create directory `universal/dev/testing-integration-postgres/`
- [ ] Write `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
- [ ] Write `src/constants/index.ts`
- [ ] Write `src/fixture/postgres-fixture.types.ts`
- [ ] Write `src/fixture/postgres-fixture.test.ts` (TDD: tests first)
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-postgres` → expect failures
- [ ] Write `src/fixture/postgres-fixture.ts`
- [ ] Write `src/fixture/postgres-fixture.md`
- [ ] Write `src/fixture/index.ts`
- [ ] Write `src/index.ts`
- [ ] Write `README.md`
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-postgres` → expect green
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration-postgres` → must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/testing-integration-postgres` → must be 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/testing-integration-postgres`
---
## Chunk 4 — `@suaveplan/testing-integration-mailpit`
**Package path:** `universal/dev/testing-integration-mailpit/`
**Image:** `axllent/mailpit:latest`
**Container ports:** SMTP `1025` + Web/API `8025`
**Health check:** `HTTP GET <apiUrl>/api/v1/messages` → 200
### 4.1 File Map
```text
universal/dev/testing-integration-mailpit/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
constants/
index.ts
fixture/
index.ts
mailpit-fixture.ts
mailpit-fixture.types.ts
mailpit-fixture.md
mailpit-fixture.test.ts
helpers/
index.ts
assert-mail.ts
assert-mail.md
assert-mail.test.ts
index.ts
```
### 4.2 `package.json`
Same shape as Chunk 1. Name: `@suaveplan/testing-integration-mailpit`. Two ports to bind; no additional runtime deps.
### 4.3 `src/constants/index.ts`
```typescript
export const MAILPIT_IMAGE = "axllent/mailpit:latest";
export const MAILPIT_SMTP_PORT = 1025;
export const MAILPIT_API_PORT = 8025;
export const MAILPIT_HEALTH_TIMEOUT_MS = 60_000;
export const MAILPIT_HEALTH_POLL_INTERVAL_MS = 1_000;
/** Default poll interval when waiting for an email in assertMail(). */
export const ASSERT_MAIL_POLL_INTERVAL_MS = 500;
/** Default total timeout when waiting for an email in assertMail(). */
export const ASSERT_MAIL_TIMEOUT_MS = 10_000;
export const MAILPIT_ENV_VARS = ["MAILPIT_SMTP_URL", "MAILPIT_API_URL"] as const;
```
### 4.4 `src/fixture/mailpit-fixture.types.ts`
```typescript
import type { GenericContainer } from "testcontainers";
export interface MailpitConfig {
/** SMTP URL: "smtp://localhost:<smtpPort>" */
smtpUrl: string;
/** HTTP API URL: "http://localhost:<apiPort>" */
apiUrl: string;
}
export interface MailpitFixtureOptions {
image?: string;
containerFactory?: (image: string) => GenericContainer;
/** Injectable fetch for healthCheck unit testing. */
fetchFn?: typeof fetch;
}
```
### 4.5 `src/fixture/mailpit-fixture.ts`
Key implementation details:
- `name = "mailpit"`.
- **Passthrough:** both `MAILPIT_SMTP_URL` and `MAILPIT_API_URL` set + non-empty → return as-is.
- **Partial passthrough (only one var set):** falls through to container mode — both must be set.
- **Container:** `GenericContainer(image).withExposedPorts(MAILPIT_SMTP_PORT, MAILPIT_API_PORT).start()`. No special command — image defaults work.
- **`healthCheck()`:** `fetchFn(`${apiUrl}/api/v1/messages`)` → `res.ok`. `fetchFn` defaults to `globalThis.fetch`.
### 4.6 `src/fixture/mailpit-fixture.test.ts`
Tests to cover:
1. **Passthrough mode:** set both env vars → returns config from env, stop is no-op.
2. **Partial passthrough (only SMTP set):** falls through to container mode.
3. **Container mode happy path:** fake factory, two ports mapped. Verify both URLs contain correct ports.
4. **`healthCheck()` returns true:** fake `fetchFn` returns `{ ok: true }`.
5. **`healthCheck()` returns false:** fake `fetchFn` returns `{ ok: false }`.
6. **`healthCheck()` when no container:** returns `false`.
7. **`stop()` calls container.stop().**
8. **`name` property:** `"mailpit"`.
### 4.7 `src/helpers/assert-mail.ts`
Full implementation:
```typescript
import {
ASSERT_MAIL_POLL_INTERVAL_MS,
ASSERT_MAIL_TIMEOUT_MS,
} from "../constants/index.js";
export interface AssertMailCriteria {
/** Recipient address to match in the "To" field (exact match). */
to: string;
/** Subject substring to match. */
subject: string;
}
export interface AssertMailOptions {
/** Total time in milliseconds to wait for a matching message. Default: 10_000. */
timeoutMs?: number;
/** Polling interval. Default: 500. */
pollIntervalMs?: number;
/** Injectable fetch for unit tests. Defaults to globalThis.fetch. */
fetchFn?: typeof fetch;
}
/**
* Polls the Mailpit REST API until a message matching the criteria is found,
* or throws an Error with details if timeoutMs elapses.
*/
export async function assertMail(
apiUrl: string,
criteria: AssertMailCriteria,
options: AssertMailOptions = {}
): Promise<void> {
const {
timeoutMs = ASSERT_MAIL_TIMEOUT_MS,
pollIntervalMs = ASSERT_MAIL_POLL_INTERVAL_MS,
fetchFn = globalThis.fetch,
} = options;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const res = await fetchFn(`${apiUrl}/api/v1/messages`);
if (!res.ok) {
throw new Error(`Mailpit API returned ${res.status}`);
}
const data = (await res.json()) as {
messages?: Array<{
To: Array<{ Address: string }>;
Subject: string;
}>;
};
const messages = data.messages ?? [];
const found = messages.some(
(m) =>
m.To.some((t) => t.Address === criteria.to) &&
m.Subject.includes(criteria.subject)
);
if (found) return;
await new Promise((r) => setTimeout(r, pollIntervalMs));
}
throw new Error(
`assertMail: no message matching to="${criteria.to}" subject="${criteria.subject}" ` +
`found within ${timeoutMs}ms`
);
}
```
### 4.8 `src/helpers/assert-mail.test.ts`
Tests using constructor-injected `fetchFn` (no live Mailpit):
1. **Found on first poll:** `fetchFn` returns matching message → resolves.
2. **Found on second poll:** first call returns empty list, second returns match → resolves.
3. **Times out:** all polls return empty list → throws with message containing `to=` and `subject=`.
4. **API error (non-200):** throws with status code in message.
5. **Subject is substring match:** `"Welcome to"` matches `"Welcome to SuavePlan"`.
6. **Address exact match — no match:** `"user@example.com"` does not match `"other@example.com"`.
7. **Custom timeoutMs and pollIntervalMs:** short timeout resolves quickly without real delays.
All async tests: `{ timeout: 30000 }`.
### 4.9 `src/helpers/assert-mail.md`
Purpose: `assertMail()` is an assertion helper that polls the Mailpit REST API and fails tests that do not receive an expected email within a timeout. Features: configurable timeout and poll interval, injectable fetch for unit tests, partial subject matching, exact address matching. Basic example (poll for welcome email), advanced example (custom timeout + fetch override). API reference for `assertMail`, `AssertMailCriteria`, `AssertMailOptions`. Implementation notes on polling strategy, Mailpit API shape (`/api/v1/messages` response structure — `messages` array with `To[].Address` and `Subject`).
### 4.10 `src/helpers/index.ts`
```typescript
export { assertMail } from "./assert-mail.js";
export type { AssertMailCriteria, AssertMailOptions } from "./assert-mail.js";
```
### 4.11 `src/index.ts`
```typescript
export { MailpitFixture } from "./fixture/mailpit-fixture.js";
export type { MailpitConfig, MailpitFixtureOptions } from "./fixture/mailpit-fixture.types.js";
export { assertMail } from "./helpers/assert-mail.js";
export type { AssertMailCriteria, AssertMailOptions } from "./helpers/assert-mail.js";
export * from "./constants/index.js";
```
### 4.12 Implementation sequence
- [ ] Create directory `universal/dev/testing-integration-mailpit/`
- [ ] Write `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
- [ ] Write `src/constants/index.ts`
- [ ] Write `src/fixture/mailpit-fixture.types.ts`
- [ ] Write `src/helpers/assert-mail.test.ts` (TDD: helpers tests first)
- [ ] Write `src/fixture/mailpit-fixture.test.ts` (TDD: fixture tests second)
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-mailpit` → expect failures
- [ ] Write `src/helpers/assert-mail.ts`
- [ ] Write `src/helpers/assert-mail.md`
- [ ] Write `src/helpers/index.ts`
- [ ] Write `src/fixture/mailpit-fixture.ts`
- [ ] Write `src/fixture/mailpit-fixture.md`
- [ ] Write `src/fixture/index.ts`
- [ ] Write `src/index.ts`
- [ ] Write `README.md`
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-mailpit` → expect green
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration-mailpit` → must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/testing-integration-mailpit` → must be 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/testing-integration-mailpit`
---
## Chunk 5 — `@suaveplan/testing-integration-typesense`
**Package path:** `universal/dev/testing-integration-typesense/`
**Image:** `typesense/typesense:27.1`
**Container port:** `8108`
**Health check:** `HTTP GET <url>/health` → `{ ok: true }`
**Container command:** `["--data-dir", "/data", "--api-key=<generated>", "--enable-cors"]`
### 5.1 File Map
```text
universal/dev/testing-integration-typesense/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
constants/
index.ts
fixture/
index.ts
typesense-fixture.ts
typesense-fixture.types.ts
typesense-fixture.md
typesense-fixture.test.ts
index.ts
```
### 5.2 `package.json`
Same shape as Chunk 1. Name: `@suaveplan/testing-integration-typesense`. No Typesense client in deps — fixture returns plain config.
### 5.3 `src/constants/index.ts`
```typescript
export const TYPESENSE_IMAGE = "typesense/typesense:27.1";
export const TYPESENSE_CONTAINER_PORT = 8108;
export const TYPESENSE_HEALTH_TIMEOUT_MS = 60_000;
export const TYPESENSE_HEALTH_POLL_INTERVAL_MS = 1_000;
export const TYPESENSE_ENV_VARS = ["TYPESENSE_URL", "TYPESENSE_API_KEY"] as const;
```
### 5.4 `src/fixture/typesense-fixture.types.ts`
```typescript
import type { GenericContainer } from "testcontainers";
export interface TypesenseConfig {
/** HTTP URL: "http://localhost:<port>" */
url: string;
/** Generated per run via crypto.randomUUID() */
apiKey: string;
}
export interface TypesenseFixtureOptions {
image?: string;
containerFactory?: (image: string) => GenericContainer;
/** Injectable fetch for healthCheck unit testing. */
fetchFn?: typeof fetch;
}
```
### 5.5 `src/fixture/typesense-fixture.ts`
Key implementation details:
- `name = "typesense"`.
- **Passthrough:** both `TYPESENSE_URL` and `TYPESENSE_API_KEY` set + non-empty → return as-is.
- **Container:** generate `apiKey = crypto.randomUUID()`. Start with `.withCommand(["--data-dir", "/data",`--api-key=${apiKey}`, "--enable-cors"]).withExposedPorts(TYPESENSE_CONTAINER_PORT)`.
- **`healthCheck()`:** fetch `${url}/health`, parse JSON, check `data.ok === true`. Uses injectable `fetchFn`.
```typescript
async healthCheck(): Promise<boolean> {
if (!this.container) return false;
const port = this.container.getMappedPort(TYPESENSE_CONTAINER_PORT);
const url = `http://localhost:${port}`;
try {
const fetchFn = this.options.fetchFn ?? globalThis.fetch;
const res = await fetchFn(`${url}/health`);
if (!res.ok) return false;
const data = (await res.json()) as { ok?: boolean };
return data.ok === true;
} catch {
return false;
}
}
```
### 5.6 `src/fixture/typesense-fixture.test.ts`
Tests to cover:
1. **Passthrough mode:** both env vars set → config from env.
2. **Partial passthrough (only URL set):** falls through to container mode.
3. **Container mode happy path:** fake factory → `start()` returns correct URL and apiKey.
4. **`healthCheck()` returns true:** fake `fetchFn` returns `{ ok: true }` JSON.
5. **`healthCheck()` returns false:** `{ ok: false }` JSON.
6. **`healthCheck()` returns false on fetch throw:** `fetchFn` throws.
7. **`healthCheck()` returns false on non-200 HTTP response.**
8. **`healthCheck()` when no container:** returns `false`.
9. **`stop()` calls container.stop().**
10. **`name` property:** `"typesense"`.
11. **Generated apiKey is UUID format:** regex `/^[0-9a-f-]{36}$/`.
### 5.7 `src/fixture/typesense-fixture.md`
Purpose: Typesense 27 fixture using testcontainers. Features: env-bridge, JSON health check (`{ ok: true }`), random API key generation, injectable fetch, `--enable-cors` flag. Basic example with `typesense` client, advanced example, API reference, implementation notes (why UUID for API key, why parse `{ ok: true }` not just HTTP 200, CORS flag for browser-based consumers).
### 5.8 Implementation sequence
- [ ] Create directory `universal/dev/testing-integration-typesense/`
- [ ] Write `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
- [ ] Write `src/constants/index.ts`
- [ ] Write `src/fixture/typesense-fixture.types.ts`
- [ ] Write `src/fixture/typesense-fixture.test.ts` (TDD: tests first)
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-typesense` → expect failures
- [ ] Write `src/fixture/typesense-fixture.ts`
- [ ] Write `src/fixture/typesense-fixture.md`
- [ ] Write `src/fixture/index.ts`
- [ ] Write `src/index.ts`
- [ ] Write `README.md`
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-typesense` → expect green
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration-typesense` → must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/testing-integration-typesense` → must be 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/testing-integration-typesense`
---
## Chunk 6 — `@suaveplan/testing-integration-garage-s3`
**Package path:** `universal/dev/testing-integration-garage-s3/`
**Image:** `ghcr.io/deuxfleurs/garage:v2.0.0`
**Container ports:** S3 API `3900`, Admin API `3902`
**Health check:** `HTTP GET http://localhost:<adminPort>/health` → 200 (admin port ONLY — S3 port `3900` does NOT expose `/health`)
**Init sequence:** 7-step sequence using `container.exec()` after health check passes
### 6.1 File Map
```text
universal/dev/testing-integration-garage-s3/
package.json
tsconfig.lib.json
vite.config.ts
vitest.config.ts
README.md
src/
constants/
index.ts
fixture/
index.ts
garage-s3-fixture.ts
garage-s3-fixture.types.ts
garage-s3-fixture.md
garage-s3-fixture.test.ts
index.ts
```
### 6.2 `package.json`
Same shape as Chunk 1. Name: `@suaveplan/testing-integration-garage-s3`. No AWS SDK in deps — fixture returns a plain config; consumers bring `@aws-sdk/client-s3` as needed.
### 6.3 `src/constants/index.ts`
```typescript
export const GARAGE_S3_IMAGE = "ghcr.io/deuxfleurs/garage:v2.0.0";
export const GARAGE_S3_API_PORT = 3900;
export const GARAGE_S3_ADMIN_PORT = 3902;
export const GARAGE_S3_DEFAULT_BUCKET = "test-bucket";
export const GARAGE_S3_DEFAULT_REGION = "garage";
export const GARAGE_S3_KEY_NAME = "test-key";
export const GARAGE_S3_DEFAULT_CAPACITY = "1G";
export const GARAGE_S3_DEFAULT_ZONE = "default";
export const GARAGE_S3_LAYOUT_VERSION = "1";
export const GARAGE_S3_HEALTH_TIMEOUT_MS = 120_000;
export const GARAGE_S3_HEALTH_POLL_INTERVAL_MS = 1_000;
export const GARAGE_S3_ENV_VARS = [
"GARAGE_S3_ENDPOINT",
"GARAGE_S3_ADMIN_ENDPOINT",
"GARAGE_S3_ACCESS_KEY_ID",
"GARAGE_S3_SECRET_ACCESS_KEY",
] as const;
```
### 6.4 `src/fixture/garage-s3-fixture.types.ts`
```typescript
import type { GenericContainer } from "testcontainers";
export interface GarageS3Config {
/** S3 endpoint URL: "http://localhost:<s3Port>" */
endpoint: string;
/** Admin endpoint URL: "http://localhost:<adminPort>" */
adminEndpoint: string;
/** Always "garage" */
region: string;
/** Bucket name created during init */
bucket: string;
/** Parsed from `garage key info` output */
accessKeyId: string;
/** Parsed from `garage key info` output */
secretAccessKey: string;
}
export interface GarageS3FixtureOptions {
/** Override the Docker image. Defaults to GARAGE_S3_IMAGE constant. */
image?: string;
/** Bucket name to create. Defaults to "test-bucket". */
bucket?: string;
/** Injectable container factory for unit testing. */
containerFactory?: (image: string) => GenericContainer;
/**
* Injectable command executor for unit testing the 7-step init sequence.
* Returns stdout and exitCode. When provided, replaces container.exec().
*/
execFn?: (cmd: string[]) => Promise<{ stdout: string; exitCode: number }>;
/** Injectable fetch for healthCheck unit testing. */
fetchFn?: typeof fetch;
/**
* Override health check timeout (ms). Defaults to GARAGE_S3_HEALTH_TIMEOUT_MS.
* Provided to allow short timeouts in unit tests without waiting 120 seconds.
*/
healthTimeoutMs?: number;
/**
* Override health check poll interval (ms). Defaults to GARAGE_S3_HEALTH_POLL_INTERVAL_MS.
*/
healthPollIntervalMs?: number;
}
```
### 6.5 `src/fixture/garage-s3-fixture.ts`
This is the most complex fixture. The implementation is split into smaller protected methods for testability.
**Class structure:**
```typescript
export class GarageS3Fixture implements ServiceFixture<GarageS3Config> {
readonly name = "garage-s3";
protected readonly options: GarageS3FixtureOptions;
protected container: StartedTestContainer | null = null;
protected config: GarageS3Config | null = null;
protected passthrough = false;
constructor(options: GarageS3FixtureOptions = {}) {
this.options = options;
}
async start(): Promise<GarageS3Config>;
async stop(): Promise<void>;
async healthCheck(): Promise<boolean>;
protected async waitForHealth(): Promise<void>;
protected async runInitSequence(bucket: string): Promise<{ accessKeyId: string; secretAccessKey: string }>;
protected parseNodeId(statusOutput: string): string;
protected parseKeyInfo(keyInfoOutput: string): { accessKeyId: string; secretAccessKey: string };
protected getExecFn(): (cmd: string[]) => Promise<{ stdout: string; exitCode: number }>;
}
```
**`start()` full flow:**
```typescript
async start(): Promise<GarageS3Config> {
// 1. Env-bridge passthrough
const [endpoint, adminEndpoint, accessKeyId, secretAccessKey] =
GARAGE_S3_ENV_VARS.map((v) => process.env[v] ?? "");
if (endpoint && adminEndpoint && accessKeyId && secretAccessKey) {
this.passthrough = true;
this.config = {
endpoint,
adminEndpoint,
region: GARAGE_S3_DEFAULT_REGION,
bucket: this.options.bucket ?? GARAGE_S3_DEFAULT_BUCKET,
accessKeyId,
secretAccessKey,
};
return this.config;
}
// 2. Start container
const image = this.options.image ?? GARAGE_S3_IMAGE;
const { GenericContainer } = await import("testcontainers");
const factory =
this.options.containerFactory ??
((img: string) => new GenericContainer(img));
this.container = await factory(image)
.withExposedPorts(GARAGE_S3_API_PORT, GARAGE_S3_ADMIN_PORT)
.withEnvironment({ GARAGE_ALLOW_WORLD_READABLE_CONFIG: "true" })
.start();
// 3. Wait for health before running init
await this.waitForHealth();
// 4. Run 7-step init sequence
const bucket = this.options.bucket ?? GARAGE_S3_DEFAULT_BUCKET;
const { accessKeyId: keyId, secretAccessKey: keySecret } =
await this.runInitSequence(bucket);
const s3Port = this.container.getMappedPort(GARAGE_S3_API_PORT);
const adminPort = this.container.getMappedPort(GARAGE_S3_ADMIN_PORT);
this.config = {
endpoint: `http://localhost:${s3Port}`,
adminEndpoint: `http://localhost:${adminPort}`,
region: GARAGE_S3_DEFAULT_REGION,
bucket,
accessKeyId: keyId,
secretAccessKey: keySecret,
};
return this.config;
}
```
**`waitForHealth()`:** inline poll using `this.healthCheck()`. Uses `options.healthTimeoutMs` and `options.healthPollIntervalMs` for testable timeouts:
```typescript
protected async waitForHealth(): Promise<void> {
const timeout =
this.options.healthTimeoutMs ?? GARAGE_S3_HEALTH_TIMEOUT_MS;
const interval =
this.options.healthPollIntervalMs ?? GARAGE_S3_HEALTH_POLL_INTERVAL_MS;
const deadline = Date.now() + timeout;
while (!(await this.healthCheck())) {
if (Date.now() >= deadline) {
throw new Error(
`GarageS3Fixture: health check timed out after ${timeout}ms`
);
}
await new Promise((r) => setTimeout(r, interval));
}
}
```
**`healthCheck()`:**
```typescript
async healthCheck(): Promise<boolean> {
if (!this.container) return false;
const adminPort = this.container.getMappedPort(GARAGE_S3_ADMIN_PORT);
try {
const fetchFn = this.options.fetchFn ?? globalThis.fetch;
const res = await fetchFn(`http://localhost:${adminPort}/health`);
return res.ok;
} catch {
return false;
}
}
```
**`runInitSequence(bucket)` — 7-step sequence:**
```typescript
protected async runInitSequence(
bucket: string
): Promise<{ accessKeyId: string; secretAccessKey: string }> {
const run = this.getExecFn();
// Step 1: Get node ID
const statusResult = await run(["garage", "status"]);
const nodeId = this.parseNodeId(statusResult.stdout);
// Step 2: Assign layout
await run([
"garage", "layout", "assign",
"-z", GARAGE_S3_DEFAULT_ZONE,
"-c", GARAGE_S3_DEFAULT_CAPACITY,
nodeId,
]);
// Step 3: Apply layout
await run(["garage", "layout", "apply", "--version", GARAGE_S3_LAYOUT_VERSION]);
// Step 4: Create bucket
await run(["garage", "bucket", "create", bucket]);
// Step 5: Create access key
await run(["garage", "key", "create", GARAGE_S3_KEY_NAME]);
// Step 6: Grant bucket access
await run([
"garage", "bucket", "allow",
"--read", "--write",
bucket,
"--key", GARAGE_S3_KEY_NAME,
]);
// Step 7: Read key credentials
const keyInfoResult = await run(["garage", "key", "info", GARAGE_S3_KEY_NAME]);
return this.parseKeyInfo(keyInfoResult.stdout);
}
```
**`parseNodeId(statusOutput)`:** splits output into lines, filters out blank lines and header lines (starting with `=` or `ID`), takes the first data row, splits on whitespace, returns `parts[0]`. Throws a descriptive error if no data rows are found or `parts[0]` is empty.
**`parseKeyInfo(keyInfoOutput)`:** uses regex `/Key ID:\s+(\S+)/` and `/Secret key:\s+(\S+)/`. Throws a descriptive error listing which line was missing if either match fails.
**`getExecFn()`:** returns `options.execFn` if provided, otherwise wraps `this.container!.exec(cmd)` into a function returning `{ stdout: result.output, exitCode: result.exitCode }`.
Note on testcontainers v10 `exec()` return type: `ExecResult` has `.output` (combined stdout+stderr) and `.exitCode`. If the running version uses `.stdout` instead of `.output`, adjust the field name. Always check the installed version's types.
**`stop()`:**
```typescript
async stop(): Promise<void> {
if (this.passthrough || !this.container) return;
await this.container.stop();
this.container = null;
}
```
### 6.6 `src/fixture/garage-s3-fixture.test.ts`
This is the most complex test file. All paths must be covered for 100% Istanbul.
**Fake container setup helper:**
```typescript
type GarageCommand = string[];
type ExecResponse = { stdout: string; exitCode: number };
function makeGarageContainerFake(
portMap: Record<number, number> = { 3900: 9900, 3902: 9902 },
execResponses?: Partial<Record<string, ExecResponse>>
) {
const callLog: GarageCommand[] = [];
const stopped: boolean[] = [];
function defaultExec(cmd: GarageCommand): ExecResponse {
const sub = cmd[1];
if (sub === "status") {
return {
stdout: "ID\tStatus\tAddr\nabc123def456 alive 127.0.0.1:3901\n",
exitCode: 0,
};
}
if (sub === "key" && cmd[2] === "info") {
return {
stdout: "Key ID: GK1234567890\nSecret key: sk_abcdefghij\n",
exitCode: 0,
};
}
return { stdout: "", exitCode: 0 };
}
const fakeStarted = {
getMappedPort: (port: number) => portMap[port] ?? port,
stop: async () => void stopped.push(true),
exec: async (cmd: GarageCommand) => {
callLog.push(cmd);
const key = cmd.join(" ");
return execResponses?.[key] ?? defaultExec(cmd);
},
};
const fakeContainer = {
withExposedPorts: function() { return this; },
withEnvironment: function() { return this; },
start: async () => fakeStarted,
};
return {
fakeContainer,
fakeStarted,
callLog,
stopped,
factory: (_img: string) => fakeContainer as any,
execFn: async (cmd: GarageCommand): Promise<ExecResponse> => {
callLog.push(cmd);
return execResponses?.[cmd.join(" ")] ?? defaultExec(cmd);
},
};
}
```
**Tests to cover (numbered — 100% coverage required):**
1. **Passthrough mode (all 4 vars set):** returns config from env. `stop()` is no-op.
2. **Partial passthrough (3 of 4 vars):** falls through to container mode.
3. **Container mode — full init sequence happy path:** fake container + fake `execFn` + fake `fetchFn` returning `{ ok: true }`. Verify all 7 commands run in order. Verify returned config has `accessKeyId: "GK1234567890"` and `secretAccessKey: "sk_abcdefghij"`.
4. **Init sequence order verification:** check `callLog` sequence:
- `callLog[0][1] === "status"`
- `callLog[1][1] === "layout"` and `callLog[1][2] === "assign"`
- `callLog[2][1] === "layout"` and `callLog[2][2] === "apply"`
- `callLog[3][1] === "bucket"` and `callLog[3][2] === "create"`
- `callLog[4][1] === "key"` and `callLog[4][2] === "create"`
- `callLog[5][1] === "bucket"` and `callLog[5][2] === "allow"`
- `callLog[6][1] === "key"` and `callLog[6][2] === "info"`
5. **Node ID passed to layout assign:** `callLog[1]` includes the parsed node ID `"abc123def456"`.
6. **Bucket name in bucket create and bucket allow:** verify correct bucket name in commands.
7. **`parseNodeId()` — valid output:** returns `"abc123def456"`.
8. **`parseNodeId()` — empty string:** throws with "no data rows found".
9. **`parseNodeId()` — only header lines:** throws with "no data rows found".
10. **`parseKeyInfo()` — valid output:** returns correct credentials.
11. **`parseKeyInfo()` — missing Key ID line:** throws mentioning Key ID.
12. **`parseKeyInfo()` — missing Secret key line:** throws mentioning Secret key.
13. **`healthCheck()` returns true:** fake `fetchFn` returns `{ ok: true }`.
14. **`healthCheck()` returns false:** fake `fetchFn` returns `{ ok: false }`.
15. **`healthCheck()` when fetch throws:** returns `false`.
16. **`healthCheck()` when no container:** returns `false`.
17. **`stop()` calls container.stop():** verify via `stopped` array.
18. **`stop()` when passthrough mode:** no-op.
19. **`stop()` when no container:** no-op.
20. **Health check timeout in `waitForHealth()`:** pass `healthTimeoutMs: 10, healthPollIntervalMs: 5` and fake `fetchFn` always returning `{ ok: false }`. Expect `start()` to throw timeout error.
21. **`name` property:** `"garage-s3"`.
22. **Image override:** factory called with custom image string.
23. **Bucket override:** `options.bucket` reflected in init commands and returned config.
24. **`getExecFn()` uses injected `execFn` when provided:** use the `execFn` option directly to confirm it is called.
25. **`getExecFn()` falls back to `container.exec()`:** when no `execFn` provided, container's `.exec()` is called.
All async tests: `{ timeout: 30000 }`.
### 6.7 `src/fixture/garage-s3-fixture.md`
Purpose: Garage S3 v2.0.0 fixture — manages the full lifecycle including the 7-step initialization sequence. The most complex fixture in the package, covering container startup, health polling (admin port only), bucket creation, and key provisioning.
Features:
- Env-bridge passthrough (all 4 vars must be set)
- Dual-port binding (S3 port 3900 + admin port 3902)
- 7-step init sequence: `garage status` → layout assign → layout apply → bucket create → key create → bucket allow → key info
- Injectable command executor (`execFn`) for full unit test coverage without Docker
- Injectable fetch (`fetchFn`) for healthCheck unit tests
- `parseNodeId()` and `parseKeyInfo()` exposed as protected methods for subclass testing
- Configurable bucket name, image, and health-check timeouts
- `healthTimeoutMs` and `healthPollIntervalMs` options for fast-failing in tests
Basic example (start fixture, use with `@aws-sdk/client-s3`):
```typescript
import { GarageS3Fixture } from "@suaveplan/testing-integration-garage-s3";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
const fixture = new GarageS3Fixture({ bucket: "test-bucket" });
const config = await fixture.start();
const s3 = new S3Client({
endpoint: config.endpoint,
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
forcePathStyle: true,
});
await s3.send(new PutObjectCommand({
Bucket: config.bucket,
Key: "hello.txt",
Body: "world",
}));
await fixture.stop();
```
Advanced example (custom image + injected execFn for unit test).
API reference: `GarageS3Fixture`, `GarageS3Config`, `GarageS3FixtureOptions`, all constants.
Implementation notes:
- **Why admin port for health check:** the Garage S3 API port (`3900`) serves S3 protocol only; it does not expose `/health`. Only the admin API port (`3902`) does.
- **Why inline health poll in `start()`:** the `ServiceFixture` contract requires `start()` to return only after the service is ready. The 7-step init sequence requires a live Garage instance, so health must be confirmed before init runs.
- **testcontainers `exec()` return shape:** `ExecResult.output` contains combined stdout. In testcontainers v10, the field is `.output`; adjust to `.stdout` if you see type errors.
- **Garage node ID format:** the `garage status` output contains a row per node. The node ID is the first whitespace-separated token on the first non-header data line.
- **Key info output format:** `garage key info <name>` prints `Key ID: <id>` and `Secret key: <secret>` on separate lines. The regex uses `\s+` to tolerate variable spacing.
### 6.8 Implementation sequence
- [ ] Create directory `universal/dev/testing-integration-garage-s3/`
- [ ] Write `package.json`, `tsconfig.lib.json`, `vite.config.ts`, `vitest.config.ts`
- [ ] Write `src/constants/index.ts`
- [ ] Write `src/fixture/garage-s3-fixture.types.ts`
- [ ] Write `src/fixture/garage-s3-fixture.test.ts` (TDD: all test cases first)
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-garage-s3` → expect failures
- [ ] Write `src/fixture/garage-s3-fixture.ts`
- [ ] Write `src/fixture/garage-s3-fixture.md`
- [ ] Write `src/fixture/index.ts`
- [ ] Write `src/index.ts`
- [ ] Write `README.md`
- [ ] Run `bunx turbo test --filter=@suaveplan/testing-integration-garage-s3` → expect green
- [ ] Run `bunx turbo lint typecheck test --filter=@suaveplan/testing-integration-garage-s3` → must be green
- [ ] Run `bunx turbo test:coverage --filter=@suaveplan/testing-integration-garage-s3` → must be 100%
- [ ] Run `bun scripts/publish.ts --package @suaveplan/testing-integration-garage-s3`
---
## Shared Config Files (apply to all 6 chunks)
### `tsconfig.lib.json` (per package)
```json
{
"extends": "@suaveplan/typescript-config/library.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}
```
### `vite.config.ts` (per package — substitute entry path for each plugin)
```typescript
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
build: {
lib: {
entry: "src/index.ts",
formats: ["es"],
},
rollupOptions: {
external: [
"testcontainers",
"@suaveplan/testing-integration",
"node:net",
"node:crypto",
],
output: {
preserveModules: true,
preserveModulesRoot: "src",
},
},
},
plugins: [dts({ rollupTypes: false })],
});
```
### `vitest.config.ts` (per package)
```typescript
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
pool: "forks",
coverage: {
provider: "istanbul",
reporter: ["text", "lcov"],
thresholds: {
lines: 100,
functions: 100,
branches: 100,
statements: 100,
},
},
},
});
```
---
## Catalog Update (root `package.json`)
Before Chunk 1, add the `integration` catalog to `/mnt/s/web/genesis/package.json`:
```json
// Inside workspaces.catalogs — add:
"integration": {
"testcontainers": "^10.21.0"
}
```
`ioredis` and `pg` are NOT added to the catalog — fixtures do not depend on client libraries. They return plain config objects; consumers add those as their own direct deps.
---
## Cross-Cutting Quality Gates
After all 6 chunks are complete, run the following to confirm the full picture:
```bash
bunx turbo lint typecheck test \
--filter=@suaveplan/testing-integration-surrealdb \
--filter=@suaveplan/testing-integration-redis \
--filter=@suaveplan/testing-integration-postgres \
--filter=@suaveplan/testing-integration-mailpit \
--filter=@suaveplan/testing-integration-typesense \
--filter=@suaveplan/testing-integration-garage-s3
bunx turbo test:coverage \
--filter=@suaveplan/testing-integration-surrealdb \
--filter=@suaveplan/testing-integration-redis \
--filter=@suaveplan/testing-integration-postgres \
--filter=@suaveplan/testing-integration-mailpit \
--filter=@suaveplan/testing-integration-typesense \
--filter=@suaveplan/testing-integration-garage-s3
```
All six must be green at 100% Istanbul coverage before any chunk is published.
---
## CLAUDE.md Updates
After all chunks ship, add the following to the `### Universal` packages section of `/mnt/s/web/genesis/CLAUDE.md`:
```text
- **testing-integration-surrealdb** — SurrealDB v3.0.4 `ServiceFixture`: env-bridge (SURREALDB_URL/USERNAME/PASSWORD), HTTP health check on `/health`, random password per run, injectable container factory. Depends on `@suaveplan/testing-integration`, `testcontainers`.
- **testing-integration-redis** — Redis 7 `ServiceFixture`: env-bridge (REDIS_URL), TCP PING health check, injectable TCP ping function, URL parsing for passthrough. Depends on `@suaveplan/testing-integration`, `testcontainers`.
- **testing-integration-postgres** — PostgreSQL 16 `ServiceFixture`: env-bridge (POSTGRES_URL), `pg_isready` in-container health check, injectable exec function, configurable db/user/password. Depends on `@suaveplan/testing-integration`, `testcontainers`.
- **testing-integration-mailpit** — Mailpit `ServiceFixture` + `assertMail()` helper: env-bridge (MAILPIT_SMTP_URL + MAILPIT_API_URL), API health check, injectable fetch, partial subject matching, exact address matching. Depends on `@suaveplan/testing-integration`, `testcontainers`.
- **testing-integration-typesense** — Typesense 27 `ServiceFixture`: env-bridge (TYPESENSE_URL + TYPESENSE_API_KEY), JSON health check `{ ok: true }`, random API key, injectable fetch. Depends on `@suaveplan/testing-integration`, `testcontainers`.
- **testing-integration-garage-s3** — Garage S3 v2.0.0 `ServiceFixture`: env-bridge (4 vars), dual-port (3900/3902), 7-step init sequence (status→layout assign→layout apply→bucket create→key create→bucket allow→key info), admin-port health check, injectable execFn + fetchFn, configurable health timeouts. Depends on `@suaveplan/testing-integration`, `testcontainers`.
```