Initial Commit
This commit is contained in:
43
packages/testkit/testing/testing-integration-redis/README.md
Normal file
43
packages/testkit/testing/testing-integration-redis/README.md
Normal 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)
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
export { RedisFixture } from "./redis-fixture.js";
|
||||
export type {
|
||||
RedisConfig,
|
||||
RedisFixtureOptions,
|
||||
} from "./redis-fixture.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user