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

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 .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"