Files
2026-05-27 23:29:53 +01:00

147 lines
7.2 KiB
Markdown

# testing-integration-redis
## Purpose
Redis 7 `ServiceFixture` for integration tests. Provides env-bridge passthrough (`REDIS_URL`), TCP `PING` health checking, injectable TCP-ping function, and URL parsing for passthrough mode. Enables real Redis integration tests without mocks. Depends on `@suaveplan/testing-integration` and `testcontainers`.
## Requirements
### Requirement: RedisFixture implements ServiceFixture<RedisConfig>
The package SHALL export `RedisFixture` implementing `ServiceFixture<RedisConfig>` with `name === "redis"`. `RedisConfig` SHALL be `{ url: string; host: string; port: number }`. The fixture MUST default to the `REDIS_IMAGE` constant and expose `REDIS_CONTAINER_PORT`.
#### Scenario: name property is "redis"
- **WHEN** an instance's `.name` is read
- **THEN** the value is the literal `"redis"`
**Verified by:** `src/fixture/redis-fixture.test.ts::"name property returns redis"`
---
### Requirement: Env-bridge passthrough mode
When `REDIS_URL` is set and non-empty, `start()` MUST parse it via `new URL(...)` and return `{ url, host, port }` directly without starting a container. The fallback port (when the URL omits one) MUST be `REDIS_CONTAINER_PORT`. In passthrough mode `stop()` MUST be a no-op.
#### Scenario: parses config from REDIS_URL
- **WHEN** `REDIS_URL=redis://h:1234` is set
- **THEN** `start()` returns `{ url, host: "h", port: 1234 }` without invoking testcontainers
#### Scenario: uses default port when URL has no port
- **WHEN** `REDIS_URL=redis://h` is set
- **THEN** `start()` returns `port: REDIS_CONTAINER_PORT`
#### Scenario: stop is a no-op in passthrough mode
- **WHEN** `start()` ran in passthrough mode and `stop()` is called
- **THEN** no container is touched
**Verified by:** `src/fixture/redis-fixture.test.ts::"passthrough mode: parses config from REDIS_URL"` AND `src/fixture/redis-fixture.test.ts::"passthrough mode: uses default port when URL has no port"` AND `src/fixture/redis-fixture.test.ts::"passthrough mode: stop is a no-op"`
---
### Requirement: Container mode
When no `REDIS_URL` is set, `start()` MUST start a Redis container via the injected `containerFactory` (or testcontainers `GenericContainer` when no factory is injected), expose `REDIS_CONTAINER_PORT`, and return `{ url: "redis://localhost:<mappedPort>", host: "localhost", port: <mappedPort> }`. `stop()` MUST call `container.stop()` exactly once. Container image MUST be overridable via `options.image`.
#### Scenario: returns correct config from mapped port
- **WHEN** `start()` boots a container that maps the Redis port
- **THEN** `result.url`, `result.host`, and `result.port` reflect the mapped port
#### Scenario: stop calls container.stop()
- **WHEN** `stop()` is called after `start()` in container mode
- **THEN** the container's `stop()` is invoked exactly once
#### Scenario: image option overrides default
- **WHEN** `new RedisFixture({ image: "x" })` is started
- **THEN** the container factory is invoked with `"x"`
#### Scenario: factory falls back to dynamic GenericContainer import
- **WHEN** no `containerFactory` is injected
- **THEN** the fixture dynamically imports `testcontainers` and uses `GenericContainer`
**Verified by:** `src/fixture/redis-fixture.test.ts::"container mode happy path: returns correct config"` AND `src/fixture/redis-fixture.test.ts::"container mode: stop calls container.stop()"` AND `src/fixture/redis-fixture.test.ts::"image override: factory receives custom image"` AND `src/fixture/redis-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"`
---
### Requirement: TCP-PING health check
In container mode, `healthCheck()` MUST connect to `localhost:<mappedPort>` over TCP, send `PING\r\n`, and return `true` only when the response begins with `+PONG`. The TCP function MUST be injectable as `options.tcpPingFn`. In passthrough mode and when no container has been started, `healthCheck()` MUST return `false`.
#### Scenario: returns true when server replies +PONG
- **WHEN** the injected ping function resolves to `true`
- **THEN** `healthCheck()` resolves to `true`
#### Scenario: returns false on non-PONG / no listener
- **WHEN** the injected ping function resolves to `false`
- **THEN** `healthCheck()` resolves to `false`
#### Scenario: returns false when no container
- **WHEN** `healthCheck()` is called before `start()` (or after passthrough mode)
- **THEN** the result is `false`
#### Scenario: defaultTcpPing returns false when nothing listening
- **WHEN** `defaultTcpPing(<unbound port>, "localhost")` is called
- **THEN** it resolves to `false`
#### Scenario: defaultTcpPing returns true on +PONG response
- **WHEN** the server replies `+PONG\r\n`
- **THEN** `defaultTcpPing` resolves to `true`
#### Scenario: defaultTcpPing returns false on non-PONG response
- **WHEN** the server replies anything other than `+PONG`
- **THEN** `defaultTcpPing` resolves to `false`
**Verified by:** `src/fixture/redis-fixture.test.ts::"healthCheck returns true via injected tcpPingFn"` AND `src/fixture/redis-fixture.test.ts::"healthCheck returns false via injected tcpPingFn"` AND `src/fixture/redis-fixture.test.ts::"healthCheck returns false when no container"` AND `src/fixture/redis-fixture.test.ts::"healthCheck defaultTcpPing: returns false when nothing listening"` AND `src/fixture/redis-fixture.test.ts::"healthCheck defaultTcpPing: returns true when server replies +PONG"` AND `src/fixture/redis-fixture.test.ts::"healthCheck defaultTcpPing: returns false when server replies non-PONG"`
---
### Requirement: stop() is idempotent
`stop()` MUST be safe to call when no container has been started.
#### Scenario: stop without start is a no-op
- **WHEN** `stop()` is called on a freshly-constructed fixture
- **THEN** no error is thrown
**Verified by:** `src/fixture/redis-fixture.test.ts::"stop when no container is a no-op"`
---
### Requirement: passthrough healthCheck semantics — documented
In passthrough mode the fixture has no container to probe; `healthCheck()` returns `false`. Consumers using passthrough mode MUST NOT poll passthrough fixtures via `HealthPoller` — readiness is the operator's responsibility (the env-bridge URL is assumed to point at an already-healthy service). The `TestEnvironment.setup()` flow does not poll fixtures, so passthrough remains compatible with the standard lifecycle.
#### Scenario: passthrough mode does not call container.stop()
- **WHEN** the fixture started in passthrough mode and `stop()` is called
- **THEN** `container.stop()` is never invoked (there is no container)
**Verified by:** `src/fixture/redis-fixture.test.ts::"passthrough mode: stop is a no-op"`
---
### Requirement: testing-integration is a peer dependency
The package's `package.json` MUST list `@suaveplan/testing-integration` and `testcontainers` in `peerDependencies`, not in `dependencies`. They MUST also appear in `devDependencies` so the workspace symlink resolves for the package's own tests.
#### Scenario: variant does not bundle the core or testcontainers
- **WHEN** the published `package.json` is inspected
- **THEN** `dependencies` does not contain `@suaveplan/testing-integration` and does not contain `testcontainers`
**Verified by:** `src/fixture/redis-fixture.test.ts::"name property returns redis"` (proves the variant resolves the peer at test time).