# 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 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 -c `, (3) `garage layout apply --version `, (4) `garage bucket create `, (5) `garage key create `, (6) `garage bucket allow --read --write --key `, (7) `garage key info `. 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:/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"`