Files
suaveplan/openspec/changes/enterprise-testing-contract/specs/testing-contract/spec.md
2026-05-27 23:29:53 +01:00

6.1 KiB

ADDED Requirements

Requirement: Package isolation

The @suaveplan/testing-contract package dependencies, peerDependencies, and devDependencies MUST NOT list any @suaveplan/* scoped package other than @suaveplan/types. External catalog deps only (zod, build tooling). This mirrors the testing-infrastructure carve-out documented in CLAUDE.md rule 8.

Scenario: Dep-graph guard

  • WHEN bunx turbo ls --filter=@suaveplan/testing-contract... is run
  • THEN no @suaveplan/* package other than @suaveplan/types appears in the forward closure of @suaveplan/testing-contract

Requirement: Pact Specification v3 file shape

Pact files emitted and consumed by the package SHALL conform to Pact Specification v3 — a JSON document with consumer.name, provider.name, interactions[] (each carrying description, optional providerStates[], request, response), and metadata.pactSpecification.version === "3.0.0". Field order in serialised output MUST be deterministic so file diffs are reviewable.

Scenario: Round-trip preserves byte equality

  • WHEN a pact file is read via readPactFile, then written via writePactFile to a new path
  • THEN the new file is byte-identical to the original

Scenario: Spec version asserted on read

  • WHEN readPactFile is given a file whose metadata.pactSpecification.version !== "3.0.0"
  • THEN the function throws TestingContractError with code === "TESTING_CONTRACT.PACT_FILE.UNSUPPORTED_VERSION"

Requirement: Consumer-side recording API

defineConsumerInteraction({ provider, request, response, state? }) SHALL register an expected interaction. recordPactInteraction(httpClient) SHALL wrap a FetchLike such that every request issued through the wrapped client is matched against registered interactions and accumulated into a pact file emitted by flushPact(path). Unmatched interactions and interactions with no observed traffic MUST both be reported, distinctly.

Scenario: Recorded interaction lands in pact file

  • WHEN defineConsumerInteraction({ provider:"P", request:{ method:"GET", path:"/x" }, response:{ status:200, body:{ ok:true } } }) is registered AND a recordPactInteraction-wrapped fetch hits GET /x AND flushPact("p.json") runs
  • THEN p.json contains exactly one interaction matching the registered shape

Scenario: Unmatched traffic surfaces as error

  • WHEN the wrapped fetch issues a request that matches no registered interaction AND flushPact is called
  • THEN flushPact throws TestingContractError with code === "TESTING_CONTRACT.RECORD.UNMATCHED_REQUEST" AND context.method and context.path identify the unmatched request

Scenario: Unused interaction surfaces as error

  • WHEN an interaction is registered AND the wrapped fetch issues no matching request AND flushPact is called
  • THEN flushPact throws TestingContractError with code === "TESTING_CONTRACT.RECORD.UNUSED_INTERACTION" AND context.description identifies the interaction

Requirement: Provider-side verification

verifyProviderAgainstPact(pactFile, providerUrl, options) SHALL replay each interaction in the pact file against the supplied providerUrl, asserting that each response's status, headers (where recorded), and body shape match the recorded shape modulo options.ignoreFields. The function MUST use an injectable fetchFn. State handlers registered via options.stateHandlers MUST run before each interaction whose providerStates[] includes the matching state name.

Scenario: Provider response matches recorded shape

  • WHEN the provider returns { status:200, body:{ ok:true } } for the interaction GET /x AND verifyProviderAgainstPact is called
  • THEN the function resolves with { passed: true, results: [{ description, ok: true }] }

Scenario: Provider response diverges

  • WHEN the provider returns { status:500 } instead of the recorded 200
  • THEN the function throws TestingContractError with code === "TESTING_CONTRACT.VERIFY.MISMATCH" AND context.diff contains the structured path → expected/actual diff

Scenario: State handler runs before matching interaction

  • WHEN an interaction declares providerStates:[{ name:"user-exists" }] AND options.stateHandlers["user-exists"] is registered
  • THEN the registered handler is awaited exactly once before the interaction's request is replayed

Requirement: Pact Broker integration

publishPact(brokerUrl, version) SHALL POST the pact file to the broker's pacts/provider/<P>/consumer/<C>/version/<v> endpoint. pullPactsForVerification(brokerUrl, providerName) SHALL GET the broker's verification endpoint and return PactFile[]. Both functions MUST authenticate via the PACT_BROKER_TOKEN env var (Bearer scheme); when unset, both MUST throw before any HTTP call.

Scenario: Publish includes Bearer token

  • WHEN PACT_BROKER_TOKEN=abc is set AND publishPact("https://broker", "1.2.3") runs
  • THEN the issued POST has header Authorization: Bearer abc

Scenario: Missing token aborts before HTTP

  • WHEN PACT_BROKER_TOKEN is unset AND publishPact(...) is called
  • THEN the function throws TestingContractError with code === "TESTING_CONTRACT.BROKER.MISSING_TOKEN" AND no HTTP request is issued

Requirement: Error code prefix

Every error thrown by the package SHALL be a TestingContractError whose code begins with TESTING_CONTRACT.. Plain Error / TypeError MUST NOT be thrown from any public code path.

Scenario: Code namespace enforced

  • WHEN any public function throws
  • THEN the thrown value instanceof TestingContractError === true AND error.code.startsWith("TESTING_CONTRACT.") === true

Requirement: Coverage gate

Istanbul statement, branch, function, and line coverage across packages/testing/testing-contract/src/ (excluding *.bench.ts and *.schemas.ts) MUST be 100%. The pre-publish gate MUST hard-fail below 100%.

Scenario: Coverage gate trips below 100%

  • WHEN any line, branch, function, or statement is uncovered
  • THEN the pre-publish gate fails AND the package MUST NOT be published