# Capability Spec Delta: testing-integration-redis (testing-integration-spec-and-generics-hardening) **Package**: `@suaveplan/testing-integration-redis` **Status**: PROPOSED ## ADDED Requirements ### Requirement: RedisFixture implements ServiceFixture The package SHALL export `RedisFixture` implementing `ServiceFixture` 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:", host: "localhost", port: }`. `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:` 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(, "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).