Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,43 @@
# @suaveplan/testing-integration-postgres - PostgreSQL 16 testcontainers fixture for @suaveplan/testing-integration
## Summary
PostgreSQL 16 testcontainers fixture for `@suaveplan/testing-integration`.
Implements `ServiceFixture<PostgresConfig>` — manages a PostgreSQL container lifecycle with env-bridge passthrough and `pg_isready` health checks. Returns a plain config; consumers bring their own `pg` client.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [postgres-fixture](./src/fixture/postgres-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-postgres
```
## Quick Start
```typescript
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
const fixture = new PostgresFixture();
const config = await fixture.start();
// config.url, config.host, config.port, config.database, config.username, config.password
await fixture.stop();
```
## Env-bridge passthrough
Set the env var to skip container startup:
```text
POSTGRES_URL=postgresql://test:test@localhost:5432/test
```
## Docs
- [PostgresFixture](src/fixture/postgres-fixture.md)

View File

@@ -0,0 +1,74 @@
{
"name": "@suaveplan/testing-integration-postgres",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "PostgreSQL 16 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/postgres-fixture": {
"types": "./dist/fixture/postgres-fixture.d.ts",
"default": "./dist/fixture/postgres-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 ."
},
"peerDependencies": {
"@suaveplan/testing-integration": "workspace:^",
"testcontainers": ">=11.0.0"
},
"peerDependenciesMeta": {
"testcontainers": {
"optional": true
}
},
"devDependencies": {
"@suaveplan/testing": "workspace:^",
"@suaveplan/testing-integration": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"testcontainers": "catalog:containers",
"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"
]
}

View File

@@ -0,0 +1,51 @@
/** Default Docker image for PostgreSQL. */
export const POSTGRES_IMAGE = "postgres:16-alpine";
/** Container port exposed by PostgreSQL. */
export const POSTGRES_CONTAINER_PORT = 5432;
/** Default database user. */
export const POSTGRES_DEFAULT_USER = "test";
/** Default database password. */
export const POSTGRES_DEFAULT_PASSWORD = "test";
/** Default database name. */
export const POSTGRES_DEFAULT_DATABASE = "test";
/** Maximum time to wait for PostgreSQL to become healthy. */
export const POSTGRES_HEALTH_TIMEOUT_MS = 60_000;
/** Health check poll interval in milliseconds. */
export const POSTGRES_HEALTH_POLL_INTERVAL_MS = 1_000;
/** Env vars checked for passthrough mode — ALL must be set. */
export const POSTGRES_ENV_VARS = ["POSTGRES_URL"] as const;
/**
* Log line emitted by the `postgres` Docker image once the server is ready
* to accept client connections.
*
* The default testcontainers `Wait.forListeningPorts()` strategy hangs forever
* under the Bun runtime (see oven-sh/bun#21342 — Bun's net layer mishandles
* the half-open probe testcontainers uses to detect a listening port). To
* stay Bun-compatible per CLAUDE.md, we wait on a stable startup log line
* via `Wait.forLogMessage` instead.
*/
export const POSTGRES_READY_LOG =
"database system is ready to accept connections";
/**
* Number of times {@link POSTGRES_READY_LOG} must be observed before the
* container is considered healthy.
*
* Postgres emits this line **twice** during a fresh container boot:
* 1. After the temporary server used to run `/docker-entrypoint-initdb.d`
* init scripts comes up.
* 2. After the final server reload restarts on the public TCP listener.
*
* Waiting for `times: 2` ensures we synchronise on the FINAL ready event
* and avoid races where a test connects to the init-time server moments
* before it's torn down for the real listener.
*/
export const POSTGRES_READY_LOG_TIMES = 2;

View File

@@ -0,0 +1,5 @@
export { PostgresFixture } from "./postgres-fixture.js";
export type {
PostgresConfig,
PostgresFixtureOptions,
} from "./postgres-fixture.types.js";

View File

@@ -0,0 +1,114 @@
# PostgresFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`PostgresFixture` is an integration test fixture that manages the full lifecycle of a PostgreSQL 16 container using testcontainers. It implements the `ServiceFixture<PostgresConfig>` interface from `@suaveplan/testing-integration`, providing a ready-to-use PostgreSQL instance for tests with env-bridge passthrough, in-container health checking via `pg_isready`, and full configuration control over database name, username, and password.
The fixture intentionally excludes the `pg` client from its dependencies — it returns a plain `PostgresConfig` object containing a connection URL and parsed components. Consumers provide their own PostgreSQL client, keeping the fixture lightweight and client-agnostic.
## Features
- **Env-bridge passthrough**: when `POSTGRES_URL` is set and non-empty, the fixture parses the URL into a `PostgresConfig` and returns immediately without starting Docker.
- **`pg_isready` health check**: runs `pg_isready -U postgres` inside the container — no external pg client or TCP connection required.
- **Injectable exec function**: the `execFn` option allows unit tests to stub the pg_isready call without a real PostgreSQL container.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without Docker.
- **Configurable db/user/password**: override defaults via options; values flow into container environment variables and the returned connection URL.
- **URL parsing for passthrough**: `new URL(url)` extracts host, port, database, username, and password from the `POSTGRES_URL` env var.
## Usage: Basic Example
```typescript
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
const fixture = new PostgresFixture();
const config = await fixture.start();
// config.url → "postgresql://test:test@localhost:<randomPort>/test"
// config.host → "localhost"
// config.port → random ephemeral port
// config.database → "test"
// config.username → "test"
// config.password → "test"
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import { PostgresFixture } from "@suaveplan/testing-integration-postgres";
import postgres from "postgres";
const fixture = new PostgresFixture({
database: "myapp",
username: "appuser",
password: "s3cr3t",
});
const config = await fixture.start();
const sql = postgres(config.url);
// Run migrations, seed data, run tests...
await sql`CREATE TABLE users (id serial PRIMARY KEY, name text)`;
await sql.end();
await fixture.stop();
```
## API Reference
### `class PostgresFixture`
Implements `ServiceFixture<PostgresConfig>`.
#### `constructor(options?: PostgresFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"postgres"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<PostgresConfig>`
Starts the service. Returns the runtime configuration. In passthrough mode (when `POSTGRES_URL` env var is set), returns immediately with parsed config. Otherwise starts a testcontainers container with the configured environment variables.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Runs `pg_isready -U postgres` inside the container and returns `true` when exit code is `0`. Returns `false` if no container is active.
### `interface PostgresConfig`
| Field | Type | Description |
|-------|------|-------------|
| `url` | `string` | Full connection URL: `postgresql://user:pass@localhost:<port>/db` |
| `host` | `string` | Hostname |
| `port` | `number` | Port number |
| `database` | `string` | Database name |
| `username` | `string` | Username |
| `password` | `string` | Password |
### `interface PostgresFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `postgres:16-alpine`. |
| `database?` | `string` | Database name override. Defaults to `"test"`. |
| `username?` | `string` | Username override. Defaults to `"test"`. |
| `password?` | `string` | Password override. Defaults to `"test"`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `execFn?` | `(cmd: string[]) => Promise<{ exitCode: number }>` | Injectable executor for health check unit testing. |
## Implementation Notes
- **Why `pg_isready` over TCP**: `pg_isready` is the canonical PostgreSQL health check tool, shipped in the official `postgres:16-alpine` image. It validates that PostgreSQL is accepting connections at the protocol level, which is more reliable than a bare TCP connect. It also avoids requiring a pg client in this fixture.
- **Dynamic import of testcontainers**: `GenericContainer` is deferred to `start()` time so unit tests that provide a `containerFactory` do not trigger testcontainers initialization.
- **URL construction**: the connection URL uses `postgresql://` (the PostgreSQL standard scheme) rather than `postgres://` to ensure compatibility with clients like `postgres` (node-postgres) and Prisma.
- **Passthrough URL parsing**: `pathname.slice(1)` strips the leading `/` from the URL path to extract the database name correctly.

View File

@@ -0,0 +1,214 @@
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { PostgresFixture } from "./postgres-fixture.js";
function makeContainerFake(mappedPort = 5433) {
const stopped: string[] = [];
const execCalls: string[][] = [];
let execExitCode = 0;
const fakeStarted = {
getMappedPort: (_port: number) => mappedPort,
stop: async () => {
stopped.push("stopped");
},
exec: async (cmd: string[]) => {
execCalls.push(cmd);
return {
exitCode: execExitCode,
output: "",
stdout: "",
stderr: "",
};
},
};
const fakeContainer = {
withExposedPorts: function () {
return this;
},
withEnvironment: function () {
return this;
},
withWaitStrategy: function () {
return this;
},
start: async () => fakeStarted,
};
return {
fakeContainer,
fakeStarted,
stopped,
execCalls,
setExecExitCode: (code: number) => {
execExitCode = code;
},
factory: (_img: string) => fakeContainer as never,
};
}
describe("PostgresFixture", () => {
beforeEach(() => {
delete process.env["POSTGRES_URL"];
});
afterEach(() => {
delete process.env["POSTGRES_URL"];
});
it("name property returns postgres", { timeout: 5000 }, () => {
const fixture = new PostgresFixture();
expect(fixture.name).toBe("postgres");
});
it("passthrough mode: parses all config from POSTGRES_URL", {
timeout: 30000,
}, async () => {
process.env["POSTGRES_URL"] =
"postgresql://test:test@localhost:5433/test";
const fixture = new PostgresFixture();
const config = await fixture.start();
expect(config.url).toBe("postgresql://test:test@localhost:5433/test");
expect(config.host).toBe("localhost");
expect(config.port).toBe(5433);
expect(config.database).toBe("test");
expect(config.username).toBe("test");
expect(config.password).toBe("test");
});
it("passthrough mode: stop is a no-op", { timeout: 30000 }, async () => {
process.env["POSTGRES_URL"] =
"postgresql://test:test@localhost:5433/test";
const fixture = new PostgresFixture();
await fixture.start();
await expect(fixture.stop()).resolves.toBeUndefined();
});
it("passthrough mode: uses default port when URL has no port", {
timeout: 30000,
}, async () => {
// URL without explicit port — parseInt("", 10) is NaN → fallback to POSTGRES_CONTAINER_PORT (5432)
process.env["POSTGRES_URL"] = "postgresql://test:test@localhost/test";
const fixture = new PostgresFixture();
const config = await fixture.start();
expect(config.port).toBe(5432);
});
it("container mode happy path: returns correct URL", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(5433);
const fixture = new PostgresFixture({ containerFactory: factory });
const config = await fixture.start();
expect(config.url).toBe("postgresql://test:test@localhost:5433/test");
expect(config.host).toBe("localhost");
expect(config.port).toBe(5433);
expect(config.database).toBe("test");
expect(config.username).toBe("test");
expect(config.password).toBe("test");
});
it("container mode: stop calls container.stop()", {
timeout: 30000,
}, async () => {
const { factory, stopped } = makeContainerFake(5433);
const fixture = new PostgresFixture({ containerFactory: factory });
await fixture.start();
await fixture.stop();
expect(stopped).toContain("stopped");
});
it("healthCheck returns true via injected execFn (exitCode 0)", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(5433);
const fixture = new PostgresFixture({
containerFactory: factory,
execFn: async () => ({ exitCode: 0 }),
});
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(true);
});
it("healthCheck returns false via injected execFn (exitCode 1)", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(5433);
const fixture = new PostgresFixture({
containerFactory: factory,
execFn: async () => ({ exitCode: 1 }),
});
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("healthCheck uses container.exec when no execFn provided", {
timeout: 30000,
}, async () => {
const { factory, execCalls } = makeContainerFake(5433);
const fixture = new PostgresFixture({ containerFactory: factory });
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(true);
expect(execCalls[0]).toEqual(["pg_isready", "-U", "postgres"]);
});
it("healthCheck returns false when no container", {
timeout: 30000,
}, async () => {
const fixture = new PostgresFixture();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("custom database/username/password reflected in config and URL", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(5433);
const fixture = new PostgresFixture({
containerFactory: factory,
database: "mydb",
username: "myuser",
password: "mypass",
});
const config = await fixture.start();
expect(config.database).toBe("mydb");
expect(config.username).toBe("myuser");
expect(config.password).toBe("mypass");
expect(config.url).toBe(
"postgresql://myuser:mypass@localhost:5433/mydb"
);
});
it("stop when no container is a no-op", { timeout: 30000 }, async () => {
const fixture = new PostgresFixture();
await expect(fixture.stop()).resolves.toBeUndefined();
});
it("resolveContainerFactory: falls back to GenericContainer import", {
timeout: 60000,
}, async () => {
class Exposed extends PostgresFixture {
resolveFactory() {
return this.resolveContainerFactory();
}
}
const exposed = new Exposed();
const factory = await exposed.resolveFactory();
expect(typeof factory).toBe("function");
const container = factory("postgres:16-alpine");
expect(container).toBeDefined();
});
});

View File

@@ -0,0 +1,128 @@
/**
* PostgresFixture — testcontainers-backed ServiceFixture for PostgreSQL 16.
*
* @module
*/
import {
applyWaitStrategy,
type ServiceFixture,
} from "@suaveplan/testing-integration";
import type { GenericContainer, StartedTestContainer } from "testcontainers";
import {
POSTGRES_CONTAINER_PORT,
POSTGRES_DEFAULT_DATABASE,
POSTGRES_DEFAULT_PASSWORD,
POSTGRES_DEFAULT_USER,
POSTGRES_ENV_VARS,
POSTGRES_IMAGE,
POSTGRES_READY_LOG,
POSTGRES_READY_LOG_TIMES,
} from "../constants/index.js";
import type {
PostgresConfig,
PostgresFixtureOptions,
} from "./postgres-fixture.types.js";
/**
* Manages the full lifecycle of a PostgreSQL 16 container for integration tests.
*
* Supports env-bridge passthrough mode: when POSTGRES_URL is set and non-empty,
* the fixture parses it and returns the config directly without starting a container.
*
* Uses pg_isready run inside the container for health checking — no pg client
* dependency in this fixture. Consumers bring their own PostgreSQL client library.
*/
export class PostgresFixture implements ServiceFixture<PostgresConfig> {
readonly name = "postgres";
protected readonly options: PostgresFixtureOptions;
protected container: StartedTestContainer | null = null;
protected config: PostgresConfig | null = null;
protected passthrough = false;
constructor(options: PostgresFixtureOptions = {}) {
this.options = options;
}
/**
* Resolves the container factory. Returns the injected factory when provided,
* otherwise dynamically imports testcontainers and wraps GenericContainer.
*/
protected async resolveContainerFactory(): Promise<
(image: string) => GenericContainer
> {
if (this.options.containerFactory) {
return this.options.containerFactory;
}
const { GenericContainer } = await import("testcontainers");
return (img: string) => new GenericContainer(img);
}
async start(): Promise<PostgresConfig> {
// Env-bridge passthrough mode
const [url] = POSTGRES_ENV_VARS.map((v) => process.env[v] ?? "");
if (url) {
this.passthrough = true;
const parsed = new URL(url);
this.config = {
url,
host: parsed.hostname,
port:
Number.parseInt(parsed.port, 10) || POSTGRES_CONTAINER_PORT,
database: parsed.pathname.slice(1),
username: parsed.username,
password: parsed.password,
};
return this.config;
}
const database = this.options.database ?? POSTGRES_DEFAULT_DATABASE;
const username = this.options.username ?? POSTGRES_DEFAULT_USER;
const password = this.options.password ?? POSTGRES_DEFAULT_PASSWORD;
const image = this.options.image ?? POSTGRES_IMAGE;
const factory = await this.resolveContainerFactory();
const builder = factory(image)
.withExposedPorts(POSTGRES_CONTAINER_PORT)
.withEnvironment({
POSTGRES_USER: username,
POSTGRES_PASSWORD: password,
POSTGRES_DB: database,
});
const raw = await applyWaitStrategy(builder, {
readyLog: POSTGRES_READY_LOG,
times: POSTGRES_READY_LOG_TIMES,
});
this.container = await raw.start();
const port = this.container.getMappedPort(POSTGRES_CONTAINER_PORT);
this.config = {
url: `postgresql://${username}:${password}@localhost:${port}/${database}`,
host: "localhost",
port,
database,
username,
password,
};
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 container = this.container;
const runExec =
this.options.execFn ??
((cmd: string[]) =>
container.exec(cmd).then((r) => ({
exitCode: r.exitCode,
})));
const result = await runExec(["pg_isready", "-U", "postgres"]);
return result.exitCode === 0;
}
}

View File

@@ -0,0 +1,29 @@
import type { GenericContainer } from "testcontainers";
export interface PostgresConfig extends Record<string, unknown> {
/** Full connection URL: "postgresql://test:test@localhost:<port>/test" */
url: string;
host: string;
port: number;
database: string;
username: string;
password: string;
}
export interface PostgresFixtureOptions {
/** Override the Docker image. Defaults to POSTGRES_IMAGE constant. */
image?: string;
/** Override database name. Defaults to "test". */
database?: string;
/** Override username. Defaults to "test". */
username?: string;
/** Override password. Defaults to "test". */
password?: string;
/** Injectable container factory for unit testing. */
containerFactory?: (image: string) => GenericContainer;
/**
* Injectable in-container command executor for healthCheck unit testing.
* Returns exit code. Default uses container.exec() to run pg_isready.
*/
execFn?: (cmd: string[]) => Promise<{ exitCode: number }>;
}

View File

@@ -0,0 +1,6 @@
export * from "./constants/index.js";
export { PostgresFixture } from "./fixture/postgres-fixture.js";
export type {
PostgresConfig,
PostgresFixtureOptions,
} from "./fixture/postgres-fixture.types.js";

View File

@@ -0,0 +1,23 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto; File?: unknown };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}
// testcontainers/undici requires File global in Node environments
if (g.File === undefined) {
g.File = class File extends Blob {
readonly name: string;
readonly lastModified: number;
constructor(
fileBits: BlobPart[],
fileName: string,
options?: FilePropertyBag
) {
super(fileBits, options);
this.name = fileName;
this.lastModified = options?.lastModified ?? Date.now();
}
};
}

View File

@@ -0,0 +1,16 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.test.ts",
"dist",
"node_modules"
]
}

View File

@@ -0,0 +1,31 @@
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",
entryFileNames: "[name].js",
},
},
},
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
rollupTypes: false,
}),
],
});

View File

@@ -0,0 +1,29 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
},
});