Initial Commit
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
# Capability Spec Delta: testing-integration-garage-s3 (testing-integration-spec-and-generics-hardening)
|
||||
|
||||
**Package**: `@suaveplan/testing-integration-garage-s3`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: GarageS3Fixture implements ServiceFixture<GarageS3Config>
|
||||
|
||||
The package SHALL export `GarageS3Fixture` with `name === "garage-s3"`. `GarageS3Config` SHALL be `{ endpoint: string; adminEndpoint: string; region: string; bucket: string; accessKeyId: string; secretAccessKey: string }`.
|
||||
|
||||
#### Scenario: name property is "garage-s3"
|
||||
|
||||
- **WHEN** an instance's `.name` is read
|
||||
- **THEN** the value is the literal `"garage-s3"`
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"name property returns garage-s3"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Env-bridge passthrough mode (4 vars, all required)
|
||||
|
||||
When ALL FOUR vars in `GARAGE_S3_ENV_VARS` are set and non-empty, `start()` MUST return them directly without starting a container. A partial set (3 of 4) MUST fall through to container mode. In passthrough mode `stop()` is a no-op.
|
||||
|
||||
#### Scenario: returns config from env vars
|
||||
|
||||
- **WHEN** all four env vars are set
|
||||
- **THEN** `start()` returns the config without invoking testcontainers
|
||||
|
||||
#### Scenario: stop is a no-op in passthrough mode
|
||||
|
||||
- **WHEN** `stop()` follows passthrough start
|
||||
- **THEN** no container is touched
|
||||
|
||||
#### Scenario: partial passthrough falls through to container mode
|
||||
|
||||
- **WHEN** only 3 of 4 env vars are set
|
||||
- **THEN** `start()` boots a container
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"passthrough mode (all 4 vars set): returns config from env"` AND `src/fixture/garage-s3-fixture.test.ts::"stop when passthrough mode is a no-op"` AND `src/fixture/garage-s3-fixture.test.ts::"partial passthrough (3 of 4 vars): falls through to container mode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Container mode + 7-step init sequence
|
||||
|
||||
When env vars are partial or unset, `start()` MUST start a Garage S3 v2.0.0 container, expose `GARAGE_S3_API_PORT` and `GARAGE_S3_ADMIN_PORT`, set `GARAGE_ALLOW_WORLD_READABLE_CONFIG=true`, wait for health, then run the 7-step init sequence in order: (1) `garage status`, (2) `garage layout assign -z <zone> -c <capacity> <nodeId>`, (3) `garage layout apply --version <v>`, (4) `garage bucket create <bucket>`, (5) `garage key create <name>`, (6) `garage bucket allow --read --write <bucket> --key <name>`, (7) `garage key info <name>`. The returned config MUST contain endpoint URLs, region, bucket, and parsed access credentials.
|
||||
|
||||
#### Scenario: full init sequence happy path
|
||||
|
||||
- **WHEN** `start()` runs end-to-end with the injected execFn
|
||||
- **THEN** the returned config contains a populated `accessKeyId` and `secretAccessKey`
|
||||
|
||||
#### Scenario: init sequence executes 7 commands in correct order
|
||||
|
||||
- **WHEN** the init sequence completes
|
||||
- **THEN** the 7 garage commands appear in the documented order
|
||||
|
||||
#### Scenario: node ID is passed to layout assign
|
||||
|
||||
- **WHEN** `garage status` returns a node ID
|
||||
- **THEN** that ID appears as the trailing positional arg of `garage layout assign`
|
||||
|
||||
#### Scenario: bucket name is used in bucket create + bucket allow
|
||||
|
||||
- **WHEN** the fixture is constructed with a custom bucket
|
||||
- **THEN** that bucket name appears in both `bucket create` and `bucket allow` commands and in the returned config
|
||||
|
||||
#### Scenario: image override flows to factory
|
||||
|
||||
- **WHEN** the fixture is constructed with `image: "x"`
|
||||
- **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`
|
||||
|
||||
#### Scenario: getExecFn uses injected execFn when provided
|
||||
|
||||
- **WHEN** `options.execFn` is provided
|
||||
- **THEN** the init sequence runs through that function
|
||||
|
||||
#### Scenario: getExecFn falls back to container.exec
|
||||
|
||||
- **WHEN** no `execFn` is provided
|
||||
- **THEN** the init sequence runs through `container.exec`
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"container mode: full init sequence happy path"` AND `src/fixture/garage-s3-fixture.test.ts::"container mode: init sequence runs 7 commands in order"` AND `src/fixture/garage-s3-fixture.test.ts::"node ID passed to layout assign"` AND `src/fixture/garage-s3-fixture.test.ts::"bucket name used in bucket create and bucket allow"` AND `src/fixture/garage-s3-fixture.test.ts::"bucket override: reflected in init commands and returned config"` AND `src/fixture/garage-s3-fixture.test.ts::"image override: factory called with custom image"` AND `src/fixture/garage-s3-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"` AND `src/fixture/garage-s3-fixture.test.ts::"getExecFn: uses injected execFn when provided"` AND `src/fixture/garage-s3-fixture.test.ts::"getExecFn: falls back to container.exec when no execFn provided"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Admin-port HTTP /health probe
|
||||
|
||||
In container mode, `healthCheck()` MUST `GET http://localhost:<adminPort>/health` via the injected `fetchFn` (default `globalThis.fetch`) and return `res.ok`. Network errors MUST return `false`. Without a started container, `healthCheck()` MUST return `false`.
|
||||
|
||||
#### Scenario: returns true via injected fetchFn
|
||||
|
||||
- **WHEN** the injected fetch returns `{ ok: true }`
|
||||
- **THEN** `healthCheck()` resolves to `true`
|
||||
|
||||
#### Scenario: returns false on ok=false
|
||||
|
||||
- **WHEN** the injected fetch returns `{ ok: false }`
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false when fetch throws
|
||||
|
||||
- **WHEN** the injected fetch throws
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false when no container
|
||||
|
||||
- **WHEN** `healthCheck()` is called before `start()`
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"healthCheck returns true via injected fetchFn"` AND `src/fixture/garage-s3-fixture.test.ts::"healthCheck returns false via injected fetchFn (ok: false)"` AND `src/fixture/garage-s3-fixture.test.ts::"healthCheck returns false when fetch throws"` AND `src/fixture/garage-s3-fixture.test.ts::"healthCheck returns false when no container"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: waitForHealth delegates to HealthPoller
|
||||
|
||||
The fixture's `waitForHealth()` MUST delegate to `new HealthPoller({ timeoutMs, intervalMs }).poll(this)` (where defaults are `GARAGE_S3_HEALTH_TIMEOUT_MS` and `GARAGE_S3_HEALTH_POLL_INTERVAL_MS`). On timeout, the rejection MUST be a `TestEnvironmentError` whose message includes `"Health check timed out for fixture \"garage-s3\""`. On health-check failure during `start()`, the container MUST be stopped and the reference cleared before the error propagates.
|
||||
|
||||
#### Scenario: throws on timeout when health never passes
|
||||
|
||||
- **WHEN** `healthCheck()` returns `false` past the timeout
|
||||
- **THEN** `start()` rejects with a `TestEnvironmentError` containing `Health check timed out for fixture "garage-s3"`
|
||||
|
||||
#### Scenario: container is stopped when waitForHealth throws inside start()
|
||||
|
||||
- **WHEN** `waitForHealth()` throws
|
||||
- **THEN** `container.stop()` is invoked exactly once and the reference is cleared
|
||||
|
||||
#### Scenario: defaults flow through when no options provided
|
||||
|
||||
- **WHEN** the fixture is constructed without `healthTimeoutMs` / `healthPollIntervalMs`
|
||||
- **THEN** the constants `GARAGE_S3_HEALTH_TIMEOUT_MS` and `GARAGE_S3_HEALTH_POLL_INTERVAL_MS` are used
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"waitForHealth: throws on timeout when health never passes"` AND `src/fixture/garage-s3-fixture.test.ts::"start(): stops container and clears reference when waitForHealth throws"` AND `src/fixture/garage-s3-fixture.test.ts::"waitForHealth: uses GARAGE_S3_HEALTH_TIMEOUT_MS and GARAGE_S3_HEALTH_POLL_INTERVAL_MS constants when no options provided"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: parseNodeId parses garage status output
|
||||
|
||||
`parseNodeId(output)` MUST extract the first whitespace-delimited token from the first non-header data line. Empty / header-only / blank-leading inputs MUST throw with `"GarageS3Fixture.parseNodeId: no data rows found in garage status output"`.
|
||||
|
||||
#### Scenario: parses valid output
|
||||
|
||||
- **WHEN** the output contains a valid data row
|
||||
- **THEN** the first token is returned
|
||||
|
||||
#### Scenario: throws on empty / header-only / blank-leading inputs
|
||||
|
||||
- **WHEN** the output is empty, header-only, or starts with whitespace
|
||||
- **THEN** `parseNodeId` throws
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture-parse.test.ts::"parses valid status output"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws on empty string"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when only header lines present"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when data line starts with spaces (empty first token)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: parseKeyInfo extracts access credentials
|
||||
|
||||
`parseKeyInfo(output)` MUST match `Key ID:\s+(\S+)` and `Secret key:\s+(\S+)`. Either match missing MUST throw a descriptive error.
|
||||
|
||||
#### Scenario: parses valid key info
|
||||
|
||||
- **WHEN** both regexes match
|
||||
- **THEN** `{ accessKeyId, secretAccessKey }` is returned
|
||||
|
||||
#### Scenario: throws when Key ID missing
|
||||
|
||||
- **WHEN** the output omits the Key ID line
|
||||
- **THEN** `parseKeyInfo` throws
|
||||
|
||||
#### Scenario: throws when Secret key missing
|
||||
|
||||
- **WHEN** the output omits the Secret key line
|
||||
- **THEN** `parseKeyInfo` throws
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture-parse.test.ts::"parses valid key info output"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when Key ID missing"` AND `src/fixture/garage-s3-fixture-parse.test.ts::"throws when Secret key missing"`
|
||||
|
||||
---
|
||||
|
||||
### 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
|
||||
|
||||
#### Scenario: stop calls container.stop() once after container start
|
||||
|
||||
- **WHEN** `stop()` follows container `start()`
|
||||
- **THEN** the container's `stop()` is invoked exactly once
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"stop when no container is a no-op"` AND `src/fixture/garage-s3-fixture.test.ts::"stop calls container.stop()"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: testing-integration is a peer dependency
|
||||
|
||||
The package's `package.json` MUST list `@suaveplan/testing-integration` and `testcontainers` in `peerDependencies`, NOT in `dependencies`.
|
||||
|
||||
#### Scenario: variant does not bundle the core or testcontainers
|
||||
|
||||
- **WHEN** the published `package.json` is inspected
|
||||
- **THEN** `dependencies` does not contain either symbol
|
||||
|
||||
**Verified by:** `src/fixture/garage-s3-fixture.test.ts::"name property returns garage-s3"`
|
||||
@@ -0,0 +1,173 @@
|
||||
# Capability Spec Delta: testing-integration-mailpit (testing-integration-spec-and-generics-hardening)
|
||||
|
||||
**Package**: `@suaveplan/testing-integration-mailpit`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: MailpitFixture implements ServiceFixture<MailpitConfig>
|
||||
|
||||
The package SHALL export `MailpitFixture` with `name === "mailpit"`. `MailpitConfig` SHALL be `{ smtpUrl: string; apiUrl: string }`.
|
||||
|
||||
#### Scenario: name property is "mailpit"
|
||||
|
||||
- **WHEN** an instance's `.name` is read
|
||||
- **THEN** the value is the literal `"mailpit"`
|
||||
|
||||
**Verified by:** `src/fixture/mailpit-fixture.test.ts::"name property returns mailpit"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Env-bridge passthrough mode (2 vars, both required)
|
||||
|
||||
When BOTH `MAILPIT_SMTP_URL` AND `MAILPIT_API_URL` are set and non-empty, `start()` MUST return them directly. A partial set falls through to container mode. In passthrough mode `stop()` is a no-op.
|
||||
|
||||
#### Scenario: returns config from both env vars
|
||||
|
||||
- **WHEN** both env vars are set
|
||||
- **THEN** `start()` returns `{ smtpUrl, apiUrl }` without invoking testcontainers
|
||||
|
||||
#### Scenario: stop is a no-op in passthrough mode
|
||||
|
||||
- **WHEN** `stop()` follows passthrough start
|
||||
- **THEN** no container is touched
|
||||
|
||||
#### Scenario: partial passthrough falls through to container mode
|
||||
|
||||
- **WHEN** only `MAILPIT_SMTP_URL` is set
|
||||
- **THEN** `start()` boots a container
|
||||
|
||||
**Verified by:** `src/fixture/mailpit-fixture.test.ts::"passthrough mode: returns config from both env vars"` AND `src/fixture/mailpit-fixture.test.ts::"passthrough mode: stop is a no-op"` AND `src/fixture/mailpit-fixture.test.ts::"partial passthrough (only SMTP set) falls through to container mode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Container mode
|
||||
|
||||
When env vars are partial or unset, `start()` MUST start a Mailpit container exposing `MAILPIT_SMTP_PORT` and `MAILPIT_API_PORT`, return `{ smtpUrl: "smtp://localhost:<smtpPort>", apiUrl: "http://localhost:<apiPort>" }`.
|
||||
|
||||
#### Scenario: container mode happy path returns correct URLs
|
||||
|
||||
- **WHEN** `start()` completes container boot
|
||||
- **THEN** `result.smtpUrl` and `result.apiUrl` reflect mapped ports
|
||||
|
||||
#### Scenario: stop calls container.stop()
|
||||
|
||||
- **WHEN** `stop()` follows `start()` in container mode
|
||||
- **THEN** the container's `stop()` is invoked exactly once
|
||||
|
||||
#### 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/mailpit-fixture.test.ts::"container mode happy path: returns correct URLs"` AND `src/fixture/mailpit-fixture.test.ts::"container mode: stop calls container.stop()"` AND `src/fixture/mailpit-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: API health probe
|
||||
|
||||
In container mode, `healthCheck()` MUST `GET <apiUrl>/api/v1/messages` via the injected `fetchFn` (default `globalThis.fetch`) and return `res.ok`. Network errors MUST return `false`. Without a started container, `healthCheck()` MUST return `false`.
|
||||
|
||||
#### Scenario: returns true via injected fetchFn
|
||||
|
||||
- **WHEN** the injected fetch returns `{ ok: true }`
|
||||
- **THEN** `healthCheck()` resolves to `true`
|
||||
|
||||
#### Scenario: returns false on `ok: false`
|
||||
|
||||
- **WHEN** the injected fetch returns `{ ok: false }`
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false when fetch throws
|
||||
|
||||
- **WHEN** the injected fetch throws
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false when no container
|
||||
|
||||
- **WHEN** `healthCheck()` is called before `start()`
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `src/fixture/mailpit-fixture.test.ts::"healthCheck returns true via injected fetchFn"` AND `src/fixture/mailpit-fixture.test.ts::"healthCheck returns false via injected fetchFn (ok: false)"` AND `src/fixture/mailpit-fixture.test.ts::"healthCheck returns false when fetch throws"` AND `src/fixture/mailpit-fixture.test.ts::"healthCheck returns false when no container"`
|
||||
|
||||
---
|
||||
|
||||
### 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/mailpit-fixture.test.ts::"stop when no container is a no-op"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: assertMail polls Mailpit for a matching message
|
||||
|
||||
The package SHALL export `assertMail(apiUrl, criteria, options?)`. The function MUST poll `GET <apiUrl>/api/v1/messages` at `pollIntervalMs` (default `ASSERT_MAIL_POLL_INTERVAL_MS`) until `Date.now() >= deadline = Date.now() + timeoutMs` (default `ASSERT_MAIL_TIMEOUT_MS`). A message matches when ANY entry in `m.To` has `Address === criteria.to` (exact) AND `m.Subject` contains `criteria.subject` (substring). Resolution returns `void`. The `fetchFn` MUST be injectable.
|
||||
|
||||
#### Scenario: resolves on first poll when message present
|
||||
|
||||
- **WHEN** the first poll returns a matching message
|
||||
- **THEN** `assertMail` resolves without further polling
|
||||
|
||||
#### Scenario: resolves on subsequent poll
|
||||
|
||||
- **WHEN** the first poll returns no match but the second does
|
||||
- **THEN** `assertMail` resolves
|
||||
|
||||
#### Scenario: subject matches as substring
|
||||
|
||||
- **WHEN** the criteria subject is a substring of the actual subject
|
||||
- **THEN** the message matches
|
||||
|
||||
#### Scenario: address must match exactly
|
||||
|
||||
- **WHEN** the criteria address differs from any actual address
|
||||
- **THEN** the message does NOT match
|
||||
|
||||
#### Scenario: defaults are used when options arg omitted
|
||||
|
||||
- **WHEN** `assertMail(url, criteria)` is called without `options`
|
||||
- **THEN** the call uses `globalThis.fetch` and the default timeout/poll interval
|
||||
|
||||
**Verified by:** `src/helpers/assert-mail.test.ts::"resolves when message found on first poll"` AND `src/helpers/assert-mail.test.ts::"resolves when message found on second poll"` AND `src/helpers/assert-mail.test.ts::"matches subject as substring"` AND `src/helpers/assert-mail.test.ts::"does not match when address differs"` AND `src/helpers/assert-mail.test.ts::"uses default options when called without options arg"` AND `src/helpers/assert-mail.test.ts::"handles empty messages array without crashing"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: assertMail validates response shape via Zod
|
||||
|
||||
The function MUST validate every Mailpit response with a Zod schema (`{ messages?: Array<{ To: Array<{ Address: string }>, Subject: string }> }`). Schema mismatch, non-2xx HTTP status, and timeout MUST all throw `TestEnvironmentError` (the same error class re-exported from `@suaveplan/testing-integration`).
|
||||
|
||||
#### Scenario: timeout throws TestEnvironmentError
|
||||
|
||||
- **WHEN** no matching message arrives within `timeoutMs`
|
||||
- **THEN** the rejection is an instance of `TestEnvironmentError`
|
||||
|
||||
#### Scenario: non-2xx response throws TestEnvironmentError
|
||||
|
||||
- **WHEN** the API returns `503`
|
||||
- **THEN** the rejection is an instance of `TestEnvironmentError`
|
||||
|
||||
#### Scenario: malformed response throws TestEnvironmentError
|
||||
|
||||
- **WHEN** the response body fails Zod validation
|
||||
- **THEN** the rejection is an instance of `TestEnvironmentError`
|
||||
|
||||
**Verified by:** `src/helpers/assert-mail.test.ts::"throws timeout error when no message found"` AND `src/helpers/assert-mail.test.ts::"throws when API returns non-200 response"` AND `src/helpers/assert-mail.test.ts::"throws TestEnvironmentError when API returns malformed body"` AND `src/helpers/assert-mail.test.ts::"non-2xx response throws TestEnvironmentError (instance)"` AND `src/helpers/assert-mail.test.ts::"timeout throws TestEnvironmentError (instance)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: testing-integration is a peer dependency
|
||||
|
||||
The package's `package.json` MUST list `@suaveplan/testing-integration` and `testcontainers` in `peerDependencies`, NOT in `dependencies`. `zod` MAY be in `dependencies` (used by `assertMail`).
|
||||
|
||||
#### Scenario: variant does not bundle the core or testcontainers
|
||||
|
||||
- **WHEN** the published `package.json` is inspected
|
||||
- **THEN** `dependencies` does not contain either symbol
|
||||
|
||||
**Verified by:** `src/fixture/mailpit-fixture.test.ts::"name property returns mailpit"`
|
||||
@@ -0,0 +1,135 @@
|
||||
# Capability Spec Delta: testing-integration-postgres (testing-integration-spec-and-generics-hardening)
|
||||
|
||||
**Package**: `@suaveplan/testing-integration-postgres`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: PostgresFixture implements ServiceFixture<PostgresConfig>
|
||||
|
||||
The package SHALL export `PostgresFixture` implementing `ServiceFixture<PostgresConfig>` with `name === "postgres"`. `PostgresConfig` SHALL be `{ url: string; host: string; port: number; database: string; username: string; password: string }`.
|
||||
|
||||
#### Scenario: name property is "postgres"
|
||||
|
||||
- **WHEN** an instance's `.name` is read
|
||||
- **THEN** the value is the literal `"postgres"`
|
||||
|
||||
**Verified by:** `src/fixture/postgres-fixture.test.ts::"name property returns postgres"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Env-bridge passthrough mode
|
||||
|
||||
When `POSTGRES_URL` is set and non-empty, `start()` MUST parse it via `new URL(...)` and return the config without starting a container. The fallback port (when omitted) MUST be `POSTGRES_CONTAINER_PORT`. In passthrough mode `stop()` MUST be a no-op.
|
||||
|
||||
#### Scenario: parses all config from POSTGRES_URL
|
||||
|
||||
- **WHEN** `POSTGRES_URL=postgresql://u:p@h:5433/db` is set
|
||||
- **THEN** `start()` returns `{ url, host, port: 5433, database: "db", username: "u", password: "p" }`
|
||||
|
||||
#### Scenario: stop is a no-op in passthrough mode
|
||||
|
||||
- **WHEN** the fixture started in passthrough mode and `stop()` is called
|
||||
- **THEN** no container is touched
|
||||
|
||||
#### Scenario: uses default port when URL has no port
|
||||
|
||||
- **WHEN** the URL omits the port
|
||||
- **THEN** the fixture falls back to `POSTGRES_CONTAINER_PORT`
|
||||
|
||||
**Verified by:** `src/fixture/postgres-fixture.test.ts::"passthrough mode: parses all config from POSTGRES_URL"` AND `src/fixture/postgres-fixture.test.ts::"passthrough mode: stop is a no-op"` AND `src/fixture/postgres-fixture.test.ts::"passthrough mode: uses default port when URL has no port"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Container mode
|
||||
|
||||
When no `POSTGRES_URL` is set, `start()` MUST start a Postgres container via the injected `containerFactory` (or `GenericContainer`), set `POSTGRES_USER`/`POSTGRES_PASSWORD`/`POSTGRES_DB` env vars, expose `POSTGRES_CONTAINER_PORT`, and return a config with a populated `url`.
|
||||
|
||||
#### Scenario: container mode happy path returns correct URL
|
||||
|
||||
- **WHEN** `start()` completes container boot
|
||||
- **THEN** `result.url` reflects the username, password, mapped port, and database
|
||||
|
||||
#### Scenario: stop calls container.stop()
|
||||
|
||||
- **WHEN** `stop()` follows `start()` in container mode
|
||||
- **THEN** the container's `stop()` is invoked exactly once
|
||||
|
||||
#### Scenario: custom database/username/password reflected in config and URL
|
||||
|
||||
- **WHEN** the fixture is constructed with overrides
|
||||
- **THEN** the URL and config fields use those overrides
|
||||
|
||||
#### 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/postgres-fixture.test.ts::"container mode happy path: returns correct URL"` AND `src/fixture/postgres-fixture.test.ts::"container mode: stop calls container.stop()"` AND `src/fixture/postgres-fixture.test.ts::"custom database/username/password reflected in config and URL"` AND `src/fixture/postgres-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: pg_isready in-container health check
|
||||
|
||||
In container mode, `healthCheck()` MUST execute `pg_isready -U postgres` inside the container and return `true` only when `exitCode === 0`. The exec function MUST be injectable as `options.execFn`. Without injection it MUST default to `container.exec(cmd)`. Without a started container, `healthCheck()` MUST return `false`.
|
||||
|
||||
#### Scenario: returns true when execFn exit code 0
|
||||
|
||||
- **WHEN** the injected `execFn` resolves to `{ exitCode: 0 }`
|
||||
- **THEN** `healthCheck()` resolves to `true`
|
||||
|
||||
#### Scenario: returns false on non-zero exit code
|
||||
|
||||
- **WHEN** the injected `execFn` resolves to `{ exitCode: 1 }`
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: defaults to container.exec when no execFn injected
|
||||
|
||||
- **WHEN** no `execFn` is supplied
|
||||
- **THEN** `healthCheck()` calls into `container.exec(["pg_isready", "-U", "postgres"])`
|
||||
|
||||
#### Scenario: returns false when no container
|
||||
|
||||
- **WHEN** `healthCheck()` is called before `start()`
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `src/fixture/postgres-fixture.test.ts::"healthCheck returns true via injected execFn (exitCode 0)"` AND `src/fixture/postgres-fixture.test.ts::"healthCheck returns false via injected execFn (exitCode 1)"` AND `src/fixture/postgres-fixture.test.ts::"healthCheck uses container.exec when no execFn provided"` AND `src/fixture/postgres-fixture.test.ts::"healthCheck returns false when no container"`
|
||||
|
||||
---
|
||||
|
||||
### 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/postgres-fixture.test.ts::"stop when no container is a no-op"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: passthrough healthCheck semantics — documented
|
||||
|
||||
In passthrough mode the fixture has no container; `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).
|
||||
|
||||
#### 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
|
||||
|
||||
**Verified by:** `src/fixture/postgres-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 either symbol
|
||||
|
||||
**Verified by:** `src/fixture/postgres-fixture.test.ts::"name property returns postgres"`
|
||||
@@ -0,0 +1,145 @@
|
||||
# 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<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).
|
||||
@@ -0,0 +1,140 @@
|
||||
# Capability Spec Delta: testing-integration-surrealdb (testing-integration-spec-and-generics-hardening)
|
||||
|
||||
**Package**: `@suaveplan/testing-integration-surrealdb`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: SurrealDBFixture implements ServiceFixture<SurrealDBConfig>
|
||||
|
||||
The package SHALL export `SurrealDBFixture` with `name === "surrealdb"`. `SurrealDBConfig` SHALL be `{ url: string; namespace: string; database: string; username: string; password: string }`.
|
||||
|
||||
#### Scenario: name property is "surrealdb"
|
||||
|
||||
- **WHEN** an instance's `.name` is read
|
||||
- **THEN** the value is the literal `"surrealdb"`
|
||||
|
||||
**Verified by:** `src/fixture/surrealdb-fixture.test.ts::"name property returns surrealdb"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Env-bridge passthrough mode (3 vars)
|
||||
|
||||
When `SURREALDB_URL`, `SURREALDB_USERNAME`, AND `SURREALDB_PASSWORD` are all set and non-empty, `start()` MUST return the config directly without starting a container. Namespace/database fall back to `SURREALDB_DEFAULT_NAMESPACE`/`SURREALDB_DEFAULT_DATABASE` unless overridden via options. In passthrough mode `stop()` MUST be a no-op and `healthCheck()` MUST return `false` (no container to probe).
|
||||
|
||||
#### Scenario: returns config from env vars
|
||||
|
||||
- **WHEN** all three env vars are set
|
||||
- **THEN** `start()` returns the config without invoking testcontainers
|
||||
|
||||
#### Scenario: stop is a no-op in passthrough mode
|
||||
|
||||
- **WHEN** the fixture started in passthrough mode and `stop()` is called
|
||||
- **THEN** no container is touched
|
||||
|
||||
#### Scenario: healthCheck returns false in passthrough mode
|
||||
|
||||
- **WHEN** `healthCheck()` is called after a passthrough start
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `src/fixture/surrealdb-fixture.test.ts::"passthrough mode: returns config from env vars"` AND `src/fixture/surrealdb-fixture.test.ts::"passthrough mode: stop is a no-op"` AND `src/fixture/surrealdb-fixture.test.ts::"passthrough mode: healthCheck returns false when no container"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Container mode
|
||||
|
||||
When env vars are partially or unset, `start()` MUST start a SurrealDB v3 container via the injected `containerFactory` (or `GenericContainer`), pass `start --auth --user <root> --pass <generated>` as the command, expose `SURREALDB_CONTAINER_PORT`, and return `{ url: "ws://localhost:<port>", namespace, database, username: SURREALDB_DEFAULT_USERNAME, password: <generated> }`. Image MUST be overridable. Namespace/database MUST be overridable via constructor options.
|
||||
|
||||
#### Scenario: container mode happy path returns correct config
|
||||
|
||||
- **WHEN** `start()` completes container boot
|
||||
- **THEN** `result.url`, `namespace`, `database`, `username`, `password` are populated
|
||||
|
||||
#### Scenario: stop calls container.stop()
|
||||
|
||||
- **WHEN** `stop()` follows `start()` in container mode
|
||||
- **THEN** the container's `stop()` is invoked exactly once
|
||||
|
||||
#### Scenario: image override: factory called with custom image
|
||||
|
||||
- **WHEN** the fixture is constructed with `image: "x"`
|
||||
- **THEN** the container factory is invoked with `"x"`
|
||||
|
||||
#### Scenario: namespace and database overrides reflected in config
|
||||
|
||||
- **WHEN** the fixture is constructed with custom namespace/database
|
||||
- **THEN** the returned config uses those overrides
|
||||
|
||||
#### Scenario: factory falls back to dynamic GenericContainer import
|
||||
|
||||
- **WHEN** no `containerFactory` is injected
|
||||
- **THEN** the fixture dynamically imports testcontainers and uses `GenericContainer`
|
||||
|
||||
#### Scenario: start() stops container if getMappedPort throws
|
||||
|
||||
- **WHEN** `getMappedPort()` throws after the container has booted
|
||||
- **THEN** `start()` calls `container.stop()` and clears the reference before re-throwing
|
||||
|
||||
**Verified by:** `src/fixture/surrealdb-fixture.test.ts::"container mode happy path: returns correct config"` AND `src/fixture/surrealdb-fixture.test.ts::"container mode: stop calls container.stop()"` AND `src/fixture/surrealdb-fixture.test.ts::"image override: factory called with custom image"` AND `src/fixture/surrealdb-fixture.test.ts::"namespace and database overrides reflected in config"` AND `src/fixture/surrealdb-fixture.test.ts::"resolveContainerFactory: returns factory from dynamic import when no containerFactory"` AND `src/fixture/surrealdb-fixture.test.ts::"start(): stops container and clears reference when getMappedPort throws"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: HTTP /health probe
|
||||
|
||||
In container mode, `healthCheck()` MUST `GET http://localhost:<mappedPort>/health` via the injected `fetchFn` (default `globalThis.fetch`) and return `res.ok`. Network errors MUST be swallowed and return `false`. Without a started container, `healthCheck()` MUST return `false`.
|
||||
|
||||
#### Scenario: returns true when fetchFn returns ok
|
||||
|
||||
- **WHEN** the injected fetch returns `{ ok: true }`
|
||||
- **THEN** `healthCheck()` resolves to `true`
|
||||
|
||||
#### Scenario: returns false when fetchFn throws
|
||||
|
||||
- **WHEN** the injected fetch throws
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false when no container
|
||||
|
||||
- **WHEN** `healthCheck()` is called before `start()`
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `src/fixture/surrealdb-fixture.test.ts::"container mode: healthCheck returns true when fetchFn returns ok"` AND `src/fixture/surrealdb-fixture.test.ts::"container mode: healthCheck returns false when fetchFn throws"` AND `src/fixture/surrealdb-fixture.test.ts::"healthCheck returns false when no container"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Random per-run password
|
||||
|
||||
In container mode, `start()` MUST generate the SurrealDB root password via `crypto.randomUUID()` and use it as both the container start arg and the returned config field. Password reuse across runs MUST NOT occur.
|
||||
|
||||
#### Scenario: generated password matches UUID format
|
||||
|
||||
- **WHEN** `start()` runs in container mode
|
||||
- **THEN** `result.password` matches the UUID v4 regex
|
||||
|
||||
**Verified by:** `src/fixture/surrealdb-fixture.test.ts::"generated password matches UUID format"`
|
||||
|
||||
---
|
||||
|
||||
### 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/surrealdb-fixture.test.ts::"stop when no container 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`.
|
||||
|
||||
#### Scenario: variant does not bundle the core or testcontainers
|
||||
|
||||
- **WHEN** the published `package.json` is inspected
|
||||
- **THEN** `dependencies` does not contain either symbol
|
||||
|
||||
**Verified by:** `src/fixture/surrealdb-fixture.test.ts::"name property returns surrealdb"`
|
||||
@@ -0,0 +1,135 @@
|
||||
# Capability Spec Delta: testing-integration-typesense (testing-integration-spec-and-generics-hardening)
|
||||
|
||||
**Package**: `@suaveplan/testing-integration-typesense`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: TypesenseFixture implements ServiceFixture<TypesenseConfig>
|
||||
|
||||
The package SHALL export `TypesenseFixture` with `name === "typesense"`. `TypesenseConfig` SHALL be `{ url: string; apiKey: string }`.
|
||||
|
||||
#### Scenario: name property is "typesense"
|
||||
|
||||
- **WHEN** an instance's `.name` is read
|
||||
- **THEN** the value is the literal `"typesense"`
|
||||
|
||||
**Verified by:** `src/fixture/typesense-fixture.test.ts::"name property returns typesense"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Env-bridge passthrough mode (2 vars, both required)
|
||||
|
||||
When BOTH `TYPESENSE_URL` AND `TYPESENSE_API_KEY` are set and non-empty, `start()` MUST return them directly without starting a container. A partial set (only one of the two) MUST fall through to container mode.
|
||||
|
||||
#### Scenario: returns config from both env vars
|
||||
|
||||
- **WHEN** both env vars are set
|
||||
- **THEN** `start()` returns `{ url, apiKey }` without invoking testcontainers
|
||||
|
||||
#### Scenario: stop is a no-op in passthrough mode
|
||||
|
||||
- **WHEN** `stop()` follows passthrough start
|
||||
- **THEN** no container is touched
|
||||
|
||||
#### Scenario: partial passthrough falls through to container mode
|
||||
|
||||
- **WHEN** only `TYPESENSE_URL` is set
|
||||
- **THEN** `start()` boots a container instead of returning passthrough config
|
||||
|
||||
**Verified by:** `src/fixture/typesense-fixture.test.ts::"passthrough mode: returns config from both env vars"` AND `src/fixture/typesense-fixture.test.ts::"passthrough mode: stop is a no-op"` AND `src/fixture/typesense-fixture.test.ts::"partial passthrough (only URL set) falls through to container mode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Container mode
|
||||
|
||||
When env vars are partial or unset, `start()` MUST start a Typesense 27 container via the injected `containerFactory` (or `GenericContainer`), pass `--data-dir=/data --api-key=<generated> --enable-cors`, expose `TYPESENSE_CONTAINER_PORT`, and return `{ url: "http://localhost:<mappedPort>", apiKey: <generated> }`.
|
||||
|
||||
#### Scenario: container mode happy path returns correct config
|
||||
|
||||
- **WHEN** `start()` completes container boot
|
||||
- **THEN** `result.url` and `result.apiKey` are populated
|
||||
|
||||
#### Scenario: stop calls container.stop()
|
||||
|
||||
- **WHEN** `stop()` follows `start()` in container mode
|
||||
- **THEN** the container's `stop()` is invoked exactly once
|
||||
|
||||
#### 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/typesense-fixture.test.ts::"container mode happy path: returns correct config"` AND `src/fixture/typesense-fixture.test.ts::"container mode: stop calls container.stop()"` AND `src/fixture/typesense-fixture.test.ts::"resolveContainerFactory: falls back to GenericContainer import"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: JSON `{ ok: true }` health probe
|
||||
|
||||
In container mode, `healthCheck()` MUST `GET http://localhost:<mappedPort>/health` via the injected `fetchFn` (default `globalThis.fetch`), parse the JSON body, and return `true` only when `data.ok === true`. Non-200 responses, parse failures, and `fetchFn` throws MUST return `false`. Without a started container, `healthCheck()` MUST return `false`.
|
||||
|
||||
#### Scenario: returns true when JSON ok=true
|
||||
|
||||
- **WHEN** the response body is `{ "ok": true }`
|
||||
- **THEN** `healthCheck()` resolves to `true`
|
||||
|
||||
#### Scenario: returns false when JSON ok=false
|
||||
|
||||
- **WHEN** the response body is `{ "ok": false }`
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false on fetch throw
|
||||
|
||||
- **WHEN** the injected fetch throws
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false on non-200 HTTP response
|
||||
|
||||
- **WHEN** the response has `ok: false`
|
||||
- **THEN** `healthCheck()` resolves to `false`
|
||||
|
||||
#### Scenario: returns false when no container
|
||||
|
||||
- **WHEN** `healthCheck()` is called before `start()`
|
||||
- **THEN** the result is `false`
|
||||
|
||||
**Verified by:** `src/fixture/typesense-fixture.test.ts::"healthCheck returns true when JSON { ok: true }"` AND `src/fixture/typesense-fixture.test.ts::"healthCheck returns false when JSON { ok: false }"` AND `src/fixture/typesense-fixture.test.ts::"healthCheck returns false on fetch throw"` AND `src/fixture/typesense-fixture.test.ts::"healthCheck returns false on non-200 HTTP response"` AND `src/fixture/typesense-fixture.test.ts::"healthCheck returns false when no container"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Random per-run API key
|
||||
|
||||
In container mode, `start()` MUST generate the API key via `crypto.randomUUID()` and use it as both the container start arg and the returned config field.
|
||||
|
||||
#### Scenario: generated apiKey matches UUID format
|
||||
|
||||
- **WHEN** `start()` runs in container mode
|
||||
- **THEN** `result.apiKey` matches the UUID v4 regex
|
||||
|
||||
**Verified by:** `src/fixture/typesense-fixture.test.ts::"generated apiKey matches UUID format"`
|
||||
|
||||
---
|
||||
|
||||
### 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/typesense-fixture.test.ts::"stop when no container 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`.
|
||||
|
||||
#### Scenario: variant does not bundle the core or testcontainers
|
||||
|
||||
- **WHEN** the published `package.json` is inspected
|
||||
- **THEN** `dependencies` does not contain either symbol
|
||||
|
||||
**Verified by:** `src/fixture/typesense-fixture.test.ts::"name property returns typesense"`
|
||||
@@ -0,0 +1,185 @@
|
||||
# Capability Spec Delta: testing-integration (testing-integration-spec-and-generics-hardening)
|
||||
|
||||
**Package**: `@suaveplan/testing-integration`
|
||||
**Status**: PROPOSED
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: ServiceFixture contract
|
||||
|
||||
The package SHALL export a `ServiceFixture<TConfig extends Record<string, unknown>>` interface with `name: string`, `start(): Promise<TConfig>`, `stop(): Promise<void>`, and `healthCheck(): Promise<boolean>`. `stop()` MUST be idempotent. `healthCheck()` MUST NOT throw — it returns `false` to indicate "not yet ready" or "no container to probe".
|
||||
|
||||
#### Scenario: Implementations satisfy the four-method shape
|
||||
|
||||
- **WHEN** a value is typed as `ServiceFixture<{ host: string; port: number }>`
|
||||
- **THEN** TypeScript enforces `name: string`, `start: () => Promise<{ host: string; port: number }>`, `stop: () => Promise<void>`, `healthCheck: () => Promise<boolean>`
|
||||
|
||||
#### Scenario: InferConfig extracts the TConfig type
|
||||
|
||||
- **WHEN** a consumer writes `type C = InferConfig<MyFixture>` for `MyFixture extends ServiceFixture<X>`
|
||||
- **THEN** `C` resolves to `X`
|
||||
|
||||
#### Scenario: InferConfig returns never for non-fixture types
|
||||
|
||||
- **WHEN** `InferConfig<string>` is evaluated
|
||||
- **THEN** the result is `never`
|
||||
|
||||
**Verified by:** `src/fixture/service-fixture.test.ts::"enforces the ServiceFixture<TConfig> shape"` AND `src/fixture/service-fixture.test.ts::"InferConfig extracts TConfig from a ServiceFixture implementation"` AND `src/fixture/service-fixture.test.ts::"InferConfig returns never for non-ServiceFixture types"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TestEnvironment generic over fixture map
|
||||
|
||||
The package SHALL export `TestEnvironment<TFixtures extends DefaultFixtureMap = DefaultFixtureMap>`. `setup()` MUST start every fixture in `TFixtures` in parallel via `Promise.allSettled`, return `{ configs: { [K in keyof TFixtures]?: InferConfig<TFixtures[K]> }; failed: string[] }`, and stop fixtures in reverse start order during `teardown()`. `teardown()` MUST swallow per-fixture `stop()` errors so partial failures do not abort the rest of the teardown. The default generic parameter MUST keep pre-existing erased call sites compiling.
|
||||
|
||||
#### Scenario: setup() returns configs keyed by fixture name with precise per-fixture types
|
||||
|
||||
- **WHEN** `new TestEnvironment({ redis, postgres }).setup()` is called
|
||||
- **THEN** the result `configs.redis` is typed as `RedisConfig | undefined` and `configs.postgres` is typed as `PostgresConfig | undefined`
|
||||
|
||||
#### Scenario: setup() records names of fixtures whose start() threw
|
||||
|
||||
- **WHEN** one fixture's `start()` rejects
|
||||
- **THEN** `failed` contains its `name` and `configs` does not contain its key
|
||||
|
||||
#### Scenario: teardown() stops fixtures in reverse start order
|
||||
|
||||
- **WHEN** three fixtures `a`, `b`, `c` start in that order, then `teardown()` runs
|
||||
- **THEN** their `stop()` methods are called in order `c`, `b`, `a`
|
||||
|
||||
#### Scenario: teardown() before setup() is a no-op
|
||||
|
||||
- **WHEN** `teardown()` is called on a fresh `TestEnvironment` without prior `setup()`
|
||||
- **THEN** it resolves without throwing
|
||||
|
||||
#### Scenario: teardown() swallows errors from stop()
|
||||
|
||||
- **WHEN** a fixture's `stop()` throws
|
||||
- **THEN** `teardown()` still resolves and the error is not propagated
|
||||
|
||||
#### Scenario: setup() runs fixtures in parallel
|
||||
|
||||
- **WHEN** two fixtures both block on a promise during `start()`
|
||||
- **THEN** both `start()` invocations begin before either resolves
|
||||
|
||||
#### Scenario: setup() re-runs clear started list to avoid double-stop
|
||||
|
||||
- **WHEN** `setup()` is invoked twice in a row, then `teardown()` runs
|
||||
- **THEN** each fixture's `stop()` is called exactly once
|
||||
|
||||
**Verified by:** `src/environment/test-environment.test.ts::"setup() starts all fixtures and returns configs + empty failed list"` AND `src/environment/test-environment.test.ts::"setup() records failed fixture names"` AND `src/environment/test-environment.test.ts::"teardown() stops fixtures in reverse start order"` AND `src/environment/test-environment.test.ts::"teardown() is safe when setup() was never called"` AND `src/environment/test-environment.test.ts::"teardown() swallows errors from stop()"` AND `src/environment/test-environment.test.ts::"setup() runs fixtures in parallel"` AND `src/environment/test-environment.test.ts::"setup() clears started list before re-running so teardown does not double-stop"` AND `src/environment/test-environment.spec-conformance.test.ts::"setup() configs[K] is the precise fixture TConfig"` AND `src/environment/test-environment.spec-conformance.test.ts::"default generic parameter keeps existing call sites compiling"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: HealthPoller polls fixtures with shared timeout semantics
|
||||
|
||||
The package SHALL export `HealthPoller` with `poll<TConfig extends Record<string, unknown>>(fixture: Pick<ServiceFixture<TConfig>, "name" | "healthCheck">): Promise<void>`. Polling MUST sleep `intervalMs` between probes, MUST throw a `TestEnvironmentError` (whose `name === "TestEnvironmentError"` and which extends `Error`) when wall-clock exceeds `timeoutMs`, and MUST NOT count the initial probe against the timeout (so `timeoutMs === intervalMs` permits at least one retry after the first unhealthy response). Default `timeoutMs` is `120_000`; default `intervalMs` is `1_000`. The `sleep` callback MUST be injectable so unit tests do not wait wall-clock seconds.
|
||||
|
||||
#### Scenario: Resolves immediately when first probe is healthy
|
||||
|
||||
- **WHEN** `healthCheck()` returns `true` on the first call
|
||||
- **THEN** `poll()` resolves without sleeping
|
||||
|
||||
#### Scenario: Retries on false until healthy
|
||||
|
||||
- **WHEN** `healthCheck()` returns `false, false, true`
|
||||
- **THEN** `poll()` resolves after exactly two `sleep(intervalMs)` calls
|
||||
|
||||
#### Scenario: Throws TestEnvironmentError on timeout
|
||||
|
||||
- **WHEN** `healthCheck()` keeps returning `false` past `timeoutMs`
|
||||
- **THEN** the rejection is a `TestEnvironmentError` whose message includes `"Health check timed out"` and the fixture name
|
||||
|
||||
#### Scenario: timeoutMs === intervalMs permits one retry
|
||||
|
||||
- **WHEN** `timeoutMs === intervalMs` and `healthCheck()` returns `false` then `true`
|
||||
- **THEN** `poll()` resolves rather than firing the timeout after the first unhealthy response
|
||||
|
||||
#### Scenario: poll<TConfig>() preserves precise fixture type
|
||||
|
||||
- **WHEN** `HealthPoller.poll<RedisConfig>(redisFixture)` is called
|
||||
- **THEN** TypeScript accepts the call without widening the fixture to `ServiceFixture<Record<string, unknown>>`
|
||||
|
||||
**Verified by:** `src/helpers/health-poller.test.ts::"resolves immediately when first healthCheck returns true"` AND `src/helpers/health-poller.test.ts::"retries on false and resolves when healthCheck eventually returns true"` AND `src/helpers/health-poller.test.ts::"throws TestEnvironmentError when timeout elapses"` AND `src/helpers/health-poller.test.ts::"succeeds when fixture is unhealthy on call 1 but healthy on call 2, with timeoutMs === intervalMs"` AND `src/helpers/health-poller.test.ts::"uses default intervalMs of 1000"` AND `src/helpers/health-poller.test.ts::"uses default timeoutMs of 120000"` AND `src/helpers/health-poller.test.ts::"TestEnvironmentError has name TestEnvironmentError"` AND `src/environment/test-environment.spec-conformance.test.ts::"accepts any ServiceFixture<TConfig> without widening cast"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: skipIfUnavailable beforeAll helper
|
||||
|
||||
The package SHALL export `skipIfUnavailable<TName extends string = string>(fixtureName: TName): (ctx: { skip: () => void }) => void`. The returned callback MUST read `__integration_failed__` via Vitest's `inject` and MUST call `ctx.skip()` if `fixtureName` is in the list. A `null`/`undefined` return from `inject` MUST be treated as the empty list (no skip). The generic parameter MUST allow consumers paired with a typed `createIntegrationSetup` registry to constrain `fixtureName` to known keys.
|
||||
|
||||
#### Scenario: Skips when fixture is in failed list
|
||||
|
||||
- **WHEN** the failed list contains `"redis"` and `skipIfUnavailable("redis")()` runs
|
||||
- **THEN** `ctx.skip()` is invoked exactly once
|
||||
|
||||
#### Scenario: Does not skip when fixture absent
|
||||
|
||||
- **WHEN** the failed list does not contain the fixture name
|
||||
- **THEN** `ctx.skip()` is never called
|
||||
|
||||
#### Scenario: Treats null inject result as empty list
|
||||
|
||||
- **WHEN** the injected list is `null`
|
||||
- **THEN** `ctx.skip()` is never called
|
||||
|
||||
#### Scenario: Generic narrows fixtureName
|
||||
|
||||
- **WHEN** `skipIfUnavailable<"redis" | "postgres">("redis")` compiles
|
||||
- **THEN** TypeScript would reject `skipIfUnavailable<"redis" | "postgres">("typeo")`
|
||||
|
||||
**Verified by:** `src/helpers/skip-if-unavailable.test.ts::"calls skip() when fixture name is in failed list"` AND `src/helpers/skip-if-unavailable.test.ts::"does not call skip() when fixture name absent from failed list"` AND `src/helpers/skip-if-unavailable.test.ts::"does not call skip() when inject returns null"` AND `src/helpers/skip-if-unavailable.test.ts::"public skipIfUnavailable() returns a function"` AND `src/helpers/skip-if-unavailable.test.ts::"typed generic narrows fixtureName to known keys"` AND `src/environment/test-environment.spec-conformance.test.ts::"constrains fixtureName via TName generic"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: createIntegrationSetup wires fixtures + typed inject()
|
||||
|
||||
The package SHALL export `createIntegrationSetup<TFixtures>(fixtures): IntegrationSetupResult<TFixtures>` returning `{ globalSetup: string; inject: <K extends keyof TFixtures>(key: K) => InferConfig<TFixtures[K]> }`. Calling MUST register every entry in the package-private `fixtureRegistry`. `globalSetup` MUST be an absolute path ending in `global-setup.js`. Multiple calls MUST accumulate into the shared registry. The implementation MAY share the underlying logic via `internal/createIntegrationSetupWithInject` for unit testing, but the `internal/` subpath MUST NOT be listed in `package.json` `exports`.
|
||||
|
||||
#### Scenario: Populates registry with provided fixtures
|
||||
|
||||
- **WHEN** `createIntegrationSetup({ redis, postgres })` is called
|
||||
- **THEN** the package-private `fixtureRegistry` contains both entries
|
||||
|
||||
#### Scenario: globalSetup path ends with global-setup.js
|
||||
|
||||
- **WHEN** `createIntegrationSetup({})` returns
|
||||
- **THEN** `result.globalSetup` is an absolute path ending with `global-setup.js`
|
||||
|
||||
#### Scenario: inject(key) returns the precise per-fixture config type
|
||||
|
||||
- **WHEN** `createIntegrationSetup({ redis, postgres }).inject("redis")` is called
|
||||
- **THEN** the return type is `RedisConfig` (not `unknown` or `Record<string, unknown>`)
|
||||
|
||||
#### Scenario: Multiple calls accumulate into registry
|
||||
|
||||
- **WHEN** `createIntegrationSetup({ redis })` then `createIntegrationSetup({ postgres })` are called
|
||||
- **THEN** the registry has both entries
|
||||
|
||||
**Verified by:** `src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures"` AND `src/setup/create-integration-setup.test.ts::"returns globalSetup as absolute path ending with global-setup.js"` AND `src/setup/create-integration-setup.test.ts::"inject() reads from __integration_configs__ via injectable injectFn"` AND `src/setup/create-integration-setup.test.ts::"multiple calls accumulate into registry"` AND `src/environment/test-environment.spec-conformance.test.ts::"inject(key) returns the precise per-fixture config type"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Internal subpath is not part of the public surface
|
||||
|
||||
The package's `package.json` `exports` map MUST NOT contain `"./internal"` or `"./internal/*"`. The `src/internal/*` modules (`fixtureRegistry`, `createIntegrationSetupWithInject`, `createSkipIfUnavailableWithInject`) are reachable only from within the package's own source tree.
|
||||
|
||||
#### Scenario: Public barrel only re-exports the supported surface
|
||||
|
||||
- **WHEN** the package barrel `src/index.ts` is read
|
||||
- **THEN** it exports `TestEnvironment`, `TestEnvironmentError`, `HealthPoller`, `skipIfUnavailable`, `createIntegrationSetup`, types, AND nothing whose name starts with `_` or lives under `internal/`
|
||||
|
||||
**Verified by:** `src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures"` (the test imports `fixtureRegistry` directly from `../internal/fixture-registry.js` — proving consumers MUST do likewise to reach it, which they cannot since `internal/` is not exported).
|
||||
|
||||
---
|
||||
|
||||
### Requirement: vitest is a peer dependency
|
||||
|
||||
The core `package.json` MUST list `vitest` in `peerDependencies`, NOT only in `devDependencies`. The runtime import in `src/setup/create-integration-setup.ts` (`import { inject } from "vitest"`) requires the consumer to have a satisfying `vitest` install at runtime.
|
||||
|
||||
#### Scenario: peer declaration exists
|
||||
|
||||
- **WHEN** the published `package.json` is inspected
|
||||
- **THEN** `peerDependencies.vitest` is set to `>=3.0.0`
|
||||
|
||||
**Verified by:** `src/setup/create-integration-setup.test.ts::"populates fixtureRegistry with provided fixtures"` (this test imports `createIntegrationSetup`, which transitively imports `vitest`; the test's success in CI proves the peer is resolvable).
|
||||
Reference in New Issue
Block a user