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-redis - Redis 7 testcontainers fixture for @suaveplan/testing-integration
## Summary
Redis 7 testcontainers fixture for `@suaveplan/testing-integration`.
Implements `ServiceFixture<RedisConfig>` — manages a Redis container lifecycle with env-bridge passthrough and TCP PING health checks. Returns a plain config; consumers bring their own Redis client.
<!-- begin:auto-docs -->
## Documentation tree
- [fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_ <!-- end:auto-docs -->
<!-- end:auto-docs --> - [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_- [redis-fixture](./src/fixture/redis-fixture.md) — <!-- begin:auto-docs --> _(no submodules)_
## Install
```bash
bun add -D @suaveplan/testing-integration-redis
```
## Quick Start
```typescript
import { RedisFixture } from "@suaveplan/testing-integration-redis";
const fixture = new RedisFixture();
const config = await fixture.start();
// config.url, config.host, config.port
await fixture.stop();
```
## Env-bridge passthrough
Set the env var to skip container startup:
```text
REDIS_URL=redis://localhost:6379
```
## Docs
- [RedisFixture](src/fixture/redis-fixture.md)

View File

@@ -0,0 +1,74 @@
{
"name": "@suaveplan/testing-integration-redis",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Redis 7 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/redis-fixture": {
"types": "./dist/fixture/redis-fixture.d.ts",
"default": "./dist/fixture/redis-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,29 @@
/** Default Docker image for Redis. */
export const REDIS_IMAGE = "redis:7-alpine";
/** Container port exposed by Redis. */
export const REDIS_CONTAINER_PORT = 6379;
/** Maximum time to wait for Redis to become healthy. */
export const REDIS_HEALTH_TIMEOUT_MS = 60_000;
/** Health check poll interval in milliseconds. */
export const REDIS_HEALTH_POLL_INTERVAL_MS = 1_000;
/** Env vars checked for passthrough mode — ALL must be set. */
export const REDIS_ENV_VARS = ["REDIS_URL"] as const;
/**
* Log line emitted by Redis 7 once it is ready to accept connections.
*
* Used with testcontainers' `Wait.forLogMessage` because the default
* `Wait.forListeningPorts()` strategy hangs under Bun (oven-sh/bun#21342).
* Redis 7 emits this line exactly once on startup.
*/
export const REDIS_READY_LOG = "Ready to accept connections";
/**
* Number of times {@link REDIS_READY_LOG} must appear before the container
* is considered healthy. Redis 7 emits the ready line once.
*/
export const REDIS_READY_LOG_TIMES = 1;

View File

@@ -0,0 +1,5 @@
export { RedisFixture } from "./redis-fixture.js";
export type {
RedisConfig,
RedisFixtureOptions,
} from "./redis-fixture.types.js";

View File

@@ -0,0 +1,117 @@
# RedisFixture
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`RedisFixture` is an integration test fixture that manages the full lifecycle of a Redis 7 container using testcontainers. It implements the `ServiceFixture<RedisConfig>` interface from `@suaveplan/testing-integration`, enabling consistent startup, health checking via TCP PING, and teardown as part of a `TestEnvironment` or standalone usage.
The fixture intentionally keeps `ioredis` and other Redis clients out of its dependencies — it returns a plain `RedisConfig` object with `url`, `host`, and `port`. Consumers bring their own Redis client library, keeping the fixture lightweight and framework-agnostic.
## Features
- **Env-bridge passthrough**: when `REDIS_URL` is set and non-empty, container startup is bypassed and the URL is parsed into a `RedisConfig` directly.
- **TCP PING health check**: connects to Redis via `node:net`, sends `PING\r\n`, and verifies the response starts with `+PONG`. No Redis client dependency required.
- **Injectable TCP ping function**: the `tcpPingFn` option allows unit tests to substitute the health check without a real network connection.
- **Injectable container factory**: the `containerFactory` option allows unit tests to swap in a fake container without starting Docker.
- **URL parsing for passthrough**: `new URL(url)` extracts `hostname` and `port` from the `REDIS_URL` env var, ensuring `RedisConfig.host` and `RedisConfig.port` are always consistent with `RedisConfig.url`.
- **Idempotent stop**: calling `stop()` on an already-stopped or passthrough fixture is safe and does not throw.
## Usage: Basic Example
```typescript
import { RedisFixture } from "@suaveplan/testing-integration-redis";
const fixture = new RedisFixture();
const config = await fixture.start();
// config.url → "redis://localhost:<randomPort>"
// config.host → "localhost"
// config.port → random ephemeral port
// Use with ioredis:
// const redis = new Redis(config.url);
await fixture.stop();
```
## Usage: Advanced Example
```typescript
import { RedisFixture } from "@suaveplan/testing-integration-redis";
import { createClient } from "redis";
const fixture = new RedisFixture({
image: "redis:7-alpine",
tcpPingFn: async (port, host) => {
// Custom health check — connect and verify
const client = createClient({ socket: { host, port } });
try {
await client.connect();
const pong = await client.ping();
return pong === "PONG";
} catch {
return false;
} finally {
await client.disconnect();
}
},
});
const config = await fixture.start();
const client = createClient({ url: config.url });
await client.connect();
await client.set("key", "value");
await client.disconnect();
await fixture.stop();
```
## API Reference
### `class RedisFixture`
Implements `ServiceFixture<RedisConfig>`.
#### `constructor(options?: RedisFixtureOptions)`
Creates a new fixture instance. All options are optional.
#### `name: string`
Always `"redis"`. Used by `TestEnvironment` as the registry key.
#### `start(): Promise<RedisConfig>`
Starts the service. Returns the runtime configuration. In passthrough mode (when `REDIS_URL` env var is set), returns immediately with parsed config. Otherwise starts a testcontainers container.
#### `stop(): Promise<void>`
Stops the container. No-op in passthrough mode or when not started.
#### `healthCheck(): Promise<boolean>`
Returns `true` when a TCP PING to `localhost:<mappedPort>` returns `+PONG`. Returns `false` if no container is active or the TCP connection fails.
### `interface RedisConfig`
| Field | Type | Description |
|-------|------|-------------|
| `url` | `string` | Full Redis URL: `redis://localhost:<port>` |
| `host` | `string` | Hostname parsed from the URL |
| `port` | `number` | Port number parsed from the URL |
### `interface RedisFixtureOptions`
| Field | Type | Description |
|-------|------|-------------|
| `image?` | `string` | Docker image override. Defaults to `redis:7-alpine`. |
| `containerFactory?` | `(image: string) => GenericContainer` | Injectable factory for unit testing. |
| `tcpPingFn?` | `(port: number, host: string) => Promise<boolean>` | Injectable TCP ping for health check unit testing. |
## Implementation Notes
- **Why TCP PING over Redis client**: including `ioredis` or `redis` in this fixture's dependencies would force consumers to use a specific client version. The TCP PING is protocol-level and works with any Redis server, requiring only Node.js's built-in `node:net` module.
- **Dynamic import of testcontainers**: the `GenericContainer` import is deferred to `start()` time so unit tests that provide a `containerFactory` do not trigger testcontainers initialization.
- **URL parsing for passthrough**: `new URL(url)` provides consistent host/port extraction. If the URL omits a port, the fixture defaults to `REDIS_CONTAINER_PORT` (6379).
- **Socket timeout**: the built-in `defaultTcpPing` uses a 2-second socket timeout to prevent health check hangs during container startup race conditions.

View File

@@ -0,0 +1,230 @@
import * as net from "node:net";
import {
afterEach,
beforeEach,
describe,
expect,
it,
} from "@suaveplan/testing/runner";
import { RedisFixture } from "./redis-fixture.js";
function makeContainerFake(mappedPort = 7379) {
const stopped: string[] = [];
const fakeStarted = {
getMappedPort: (_port: number) => mappedPort,
stop: async () => {
stopped.push("stopped");
},
};
const fakeContainer = {
withExposedPorts: function () {
return this;
},
withWaitStrategy: function () {
return this;
},
start: async () => fakeStarted,
};
return {
fakeContainer,
fakeStarted,
stopped,
factory: (_img: string) => fakeContainer as never,
};
}
describe("RedisFixture", () => {
beforeEach(() => {
delete process.env["REDIS_URL"];
});
afterEach(() => {
delete process.env["REDIS_URL"];
});
it("name property returns redis", { timeout: 5000 }, () => {
const fixture = new RedisFixture();
expect(fixture.name).toBe("redis");
});
it("passthrough mode: parses config from REDIS_URL", {
timeout: 30000,
}, async () => {
process.env["REDIS_URL"] = "redis://127.0.0.1:6380";
const fixture = new RedisFixture();
const config = await fixture.start();
expect(config.url).toBe("redis://127.0.0.1:6380");
expect(config.host).toBe("127.0.0.1");
expect(config.port).toBe(6380);
});
it("passthrough mode: uses default port when URL has no port", {
timeout: 30000,
}, async () => {
// redis:// URL without explicit port — fallback to REDIS_CONTAINER_PORT (6379)
process.env["REDIS_URL"] = "redis://127.0.0.1";
const fixture = new RedisFixture();
const config = await fixture.start();
expect(config.port).toBe(6379);
});
it("passthrough mode: stop is a no-op", { timeout: 30000 }, async () => {
process.env["REDIS_URL"] = "redis://127.0.0.1:6380";
const fixture = new RedisFixture();
await fixture.start();
await expect(fixture.stop()).resolves.toBeUndefined();
});
it("container mode happy path: returns correct config", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(7379);
const fixture = new RedisFixture({ containerFactory: factory });
const config = await fixture.start();
expect(config.url).toBe("redis://localhost:7379");
expect(config.host).toBe("localhost");
expect(config.port).toBe(7379);
});
it("container mode: stop calls container.stop()", {
timeout: 30000,
}, async () => {
const { factory, stopped } = makeContainerFake(7379);
const fixture = new RedisFixture({ containerFactory: factory });
await fixture.start();
await fixture.stop();
expect(stopped).toContain("stopped");
});
it("healthCheck returns true via injected tcpPingFn", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(7379);
const fixture = new RedisFixture({
containerFactory: factory,
tcpPingFn: async () => true,
});
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(true);
});
it("healthCheck returns false via injected tcpPingFn", {
timeout: 30000,
}, async () => {
const { factory } = makeContainerFake(7379);
const fixture = new RedisFixture({
containerFactory: factory,
tcpPingFn: async () => false,
});
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("healthCheck returns false when no container", {
timeout: 30000,
}, async () => {
const fixture = new RedisFixture();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("image override: factory receives custom image", {
timeout: 30000,
}, async () => {
const calledWith: string[] = [];
const { fakeContainer } = makeContainerFake(7379);
const fixture = new RedisFixture({
image: "redis:6-alpine",
containerFactory: (img) => {
calledWith.push(img);
return fakeContainer as never;
},
});
await fixture.start();
expect(calledWith[0]).toBe("redis:6-alpine");
});
it("stop when no container is a no-op", { timeout: 30000 }, async () => {
const fixture = new RedisFixture();
await expect(fixture.stop()).resolves.toBeUndefined();
});
it("healthCheck defaultTcpPing: returns false when nothing listening", {
timeout: 30000,
}, async () => {
// Socket error path (connection refused on a non-listening port)
const { factory } = makeContainerFake(19994);
const fixture = new RedisFixture({ containerFactory: factory });
await fixture.start();
const healthy = await fixture.healthCheck();
expect(healthy).toBe(false);
});
it("healthCheck defaultTcpPing: returns true when server replies +PONG", {
timeout: 30000,
}, async () => {
// Spin up a tiny TCP server that replies +PONG to any data
const server = net.createServer((sock) => {
sock.on("data", () => {
sock.write("+PONG\r\n");
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const port = (server.address() as net.AddressInfo).port;
const { factory } = makeContainerFake(port);
const fixture = new RedisFixture({ containerFactory: factory });
await fixture.start();
const healthy = await fixture.healthCheck();
server.close();
expect(healthy).toBe(true);
});
it("healthCheck defaultTcpPing: returns false when server replies non-PONG", {
timeout: 30000,
}, async () => {
// Spin up a TCP server that sends garbage
const server = net.createServer((sock) => {
sock.on("data", () => {
sock.write("-ERR unknown\r\n");
});
});
await new Promise<void>((resolve) =>
server.listen(0, "127.0.0.1", resolve)
);
const port = (server.address() as net.AddressInfo).port;
const { factory } = makeContainerFake(port);
const fixture = new RedisFixture({ containerFactory: factory });
await fixture.start();
const healthy = await fixture.healthCheck();
server.close();
expect(healthy).toBe(false);
});
it("resolveContainerFactory: falls back to GenericContainer import", {
timeout: 60000,
}, async () => {
class Exposed extends RedisFixture {
resolveFactory() {
return this.resolveContainerFactory();
}
}
const exposed = new Exposed();
const factory = await exposed.resolveFactory();
expect(typeof factory).toBe("function");
// Call the factory to exercise the inner arrow function
const container = factory("redis:7-alpine");
expect(container).toBeDefined();
});
});

View File

@@ -0,0 +1,131 @@
/**
* RedisFixture — testcontainers-backed ServiceFixture for Redis 7.
*
* @module
*/
import {
applyWaitStrategy,
type ServiceFixture,
} from "@suaveplan/testing-integration";
import type { GenericContainer, StartedTestContainer } from "testcontainers";
import {
REDIS_CONTAINER_PORT,
REDIS_ENV_VARS,
REDIS_IMAGE,
REDIS_READY_LOG,
REDIS_READY_LOG_TIMES,
} from "../constants/index.js";
import type {
RedisConfig,
RedisFixtureOptions,
} from "./redis-fixture.types.js";
/**
* Performs a TCP-level PING to verify Redis is accepting connections.
*
* Connects to `host:port`, sends `PING\r\n`, and checks that the response
* starts with `+PONG`. Uses a 2-second socket timeout.
*/
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", /* istanbul ignore next */ () => resolve(false));
socket.setTimeout(
2000,
/* istanbul ignore next */
() => {
socket.destroy();
resolve(false);
}
);
});
}
/**
* Manages the full lifecycle of a Redis 7 container for integration tests.
*
* Supports env-bridge passthrough mode: when `REDIS_URL` is set and non-empty,
* the fixture returns it directly without starting a container.
*
* The health check uses a raw TCP PING to avoid requiring an `ioredis` dependency
* in this fixture — consumers bring their own Redis client.
*/
export class RedisFixture implements ServiceFixture<RedisConfig> {
readonly name = "redis";
protected readonly options: RedisFixtureOptions;
protected container: StartedTestContainer | null = null;
protected config: RedisConfig | null = null;
protected passthrough = false;
constructor(options: RedisFixtureOptions = {}) {
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<RedisConfig> {
// Env-bridge passthrough mode
const [url] = REDIS_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) || REDIS_CONTAINER_PORT,
};
return this.config;
}
const image = this.options.image ?? REDIS_IMAGE;
const factory = await this.resolveContainerFactory();
const builder = factory(image).withExposedPorts(REDIS_CONTAINER_PORT);
const raw = await applyWaitStrategy(builder, {
readyLog: REDIS_READY_LOG,
times: REDIS_READY_LOG_TIMES,
});
this.container = await raw.start();
const port = this.container.getMappedPort(REDIS_CONTAINER_PORT);
this.config = {
url: `redis://localhost:${port}`,
host: "localhost",
port,
};
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(REDIS_CONTAINER_PORT);
const pingFn = this.options.tcpPingFn ?? defaultTcpPing;
return pingFn(port, "localhost");
}
}

View File

@@ -0,0 +1,23 @@
import type { GenericContainer } from "testcontainers";
export interface RedisConfig extends Record<string, unknown> {
/** Full Redis URL: "redis://localhost:<port>" */
url: string;
host: string;
port: number;
}
export interface RedisFixtureOptions {
/** Override the Docker image. Defaults to REDIS_IMAGE constant. */
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>;
}

View File

@@ -0,0 +1,6 @@
export * from "./constants/index.js";
export { RedisFixture } from "./fixture/redis-fixture.js";
export type {
RedisConfig,
RedisFixtureOptions,
} from "./fixture/redis-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,
},
});