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

59 KiB
Raw Blame History

@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":

// 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

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

{
  "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

/** 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

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".
// 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:

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

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

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

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

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

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:

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

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

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

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

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

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

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:

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

export { assertMail } from "./assert-mail.js";
export type { AssertMailCriteria, AssertMailOptions } from "./assert-mail.js";

4.11 src/index.ts

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

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

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

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.
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

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

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

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:

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:

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:

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():

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:

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():

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:

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):

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)

{
  "extends": "@suaveplan/typescript-config/library.json",
  "compilerOptions": {
    "rootDir": "src",
    "outDir": "dist"
  },
  "include": ["src"]
}

vite.config.ts (per package — substitute entry path for each plugin)

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)

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:

// 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:

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:

- **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`.