11 KiB
testing-integration-garage-s3
Purpose
Garage S3 v2.0.0 ServiceFixture for integration tests. Provides env-bridge passthrough mode (4 vars), dual-port container orchestration (3900/3902), and a 7-step initialisation sequence (status, layout assign, layout apply, bucket create, key create, bucket allow, key info). Includes admin-port health checking with injectable execFn and fetchFn plus configurable health timeouts. Depends on @suaveplan/testing-integration and testcontainers.
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
.nameis 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
accessKeyIdandsecretAccessKey
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 statusreturns 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 createandbucket allowcommands 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
containerFactoryis injected - THEN the fixture dynamically imports testcontainers and uses
GenericContainer
Scenario: getExecFn uses injected execFn when provided
- WHEN
options.execFnis provided - THEN the init sequence runs through that function
Scenario: getExecFn falls back to container.exec
- WHEN no
execFnis 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 totrue
Scenario: returns false on ok=false
- WHEN the injected fetch returns
{ ok: false } - THEN
healthCheck()resolves tofalse
Scenario: returns false when fetch throws
- WHEN the injected fetch throws
- THEN
healthCheck()resolves tofalse
Scenario: returns false when no container
- WHEN
healthCheck()is called beforestart() - 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()returnsfalsepast the timeout - THEN
start()rejects with aTestEnvironmentErrorcontainingHealth 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_MSandGARAGE_S3_HEALTH_POLL_INTERVAL_MSare 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
parseNodeIdthrows
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
parseKeyInfothrows
Scenario: throws when Secret key missing
- WHEN the output omits the Secret key line
- THEN
parseKeyInfothrows
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 containerstart() - 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.jsonis inspected - THEN
dependenciesdoes not contain either symbol
Verified by: src/fixture/garage-s3-fixture.test.ts::"name property returns garage-s3"