Files
bi-agents/_bmad/tea/testarch/knowledge/pact-consumer-framework-setup.md
Cassel 647cbec54f docs: update all documentation and add AI tooling configs
- Rewrite README.md with current architecture, features and stack
- Update docs/API.md with all current endpoints (corporate, BI, client 360)
- Update docs/ARCHITECTURE.md with cache, modular queries, services, ETL
- Update docs/GUIA-USUARIO.md for all roles (admin, corporate, agente)
- Add docs/INDEX.md documentation index
- Add PROJETO.md comprehensive project reference
- Add BI-CCC-Implementation-Guide.md
- Include AI agent configs (.claude, .agents, .gemini, _bmad)
- Add netbird VPN configuration
- Add status report

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-19 13:29:03 -04:00

24 KiB

Pact Consumer CDC — Framework Setup

Principle

When scaffolding a Pact.js consumer contract testing framework, align every artifact — directory layout, vitest config, package.json scripts, shell scripts, CI workflow, and test files — with the canonical @seontechnologies/pactjs-utils conventions. Consistency across repositories eliminates onboarding friction and ensures CI pipelines are copy-paste portable.

Rationale

The TEA framework workflow generates scaffolding for consumer-driven contract (CDC) testing. Without opinionated, battle-tested conventions, each project invents its own structure — different script names, different env var patterns, different CI step ordering — making cross-repo maintenance expensive. This fragment codifies the production-proven patterns from the pactjs-utils reference implementation so that every new project starts correctly.

Pattern Examples

Example 1: Directory Structure & File Naming

Context: Consumer contract test project layout using pactjs-utils conventions.

Implementation:

tests/contract/
├── consumer/
│   ├── get-filter-fields.pacttest.ts    # Consumer test (one per endpoint group)
│   ├── filter-transactions.pacttest.ts
│   └── get-transaction-stats.pacttest.ts
└── support/
    ├── pact-config.ts                   # PactV4 factory (consumer/provider names, output dir)
    ├── provider-states.ts               # Provider state factory functions
    └── consumer-helpers.ts              # Local shim (until pactjs-utils is published)

scripts/
├── env-setup.sh                         # Shared env loader (sourced by all broker scripts)
├── publish-pact.sh                      # Publish pact files to broker
├── can-i-deploy.sh                      # Deployment safety check
└── record-deployment.sh                 # Record deployment after merge

.github/
├── actions/
│   └── detect-breaking-change/
│       └── action.yml                   # PR checkbox-driven breaking change detection
└── workflows/
    └── contract-test-consumer.yml       # Consumer CDC CI workflow

Key Points:

  • Consumer tests use .pacttest.ts extension (not .pact.spec.ts or .contract.ts)
  • Support files live in tests/contract/support/, not mixed with consumer tests
  • Shell scripts live in scripts/ at project root, not nested inside test directories
  • CI workflow named contract-test-consumer.yml (not pact-consumer.yml or other variants)

Example 2: Vitest Configuration for Pact

Context: Minimal vitest config dedicated to contract tests — do NOT copy settings from the project's main vitest.config.ts.

Implementation:

// vitest.config.pact.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    environment: 'node',
    include: ['tests/contract/**/*.pacttest.ts'],
    testTimeout: 30000,
  },
});

Key Points:

  • Do NOT add pool, poolOptions, setupFiles, coverage, or other settings from the unit test config
  • Keep it minimal — Pact tests run in Node environment with extended timeout
  • 30 second timeout accommodates Pact mock server startup and interaction verification
  • Use a dedicated config file (vitest.config.pact.ts), not the main vitest config

Example 3: Package.json Script Naming

Context: Colon-separated naming matching pactjs-utils exactly. Scripts source env-setup.sh inline.

Implementation:

{
  "scripts": {
    "test:pact:consumer": "vitest run --config vitest.config.pact.ts",
    "publish:pact": ". ./scripts/env-setup.sh && ./scripts/publish-pact.sh",
    "can:i:deploy:consumer": ". ./scripts/env-setup.sh && PACTICIPANT=<service-name> ./scripts/can-i-deploy.sh",
    "record:consumer:deployment": ". ./scripts/env-setup.sh && PACTICIPANT=<service-name> ./scripts/record-deployment.sh"
  }
}

Replace <service-name> with the consumer's pacticipant name (e.g., my-frontend-app).

Key Points:

  • Use colon-separated naming: test:pact:consumer, NOT test:contract or test:contract:consumer
  • Broker scripts source env-setup.sh inline in package.json (. ./scripts/env-setup.sh && ...)
  • PACTICIPANT is set per-script invocation, not globally
  • Do NOT use npx pact-broker — use pact-broker directly (installed as a dependency)

Example 4: Shell Scripts

Context: Reusable bash scripts aligned with pactjs-utils conventions.

scripts/env-setup.sh — Shared Environment Loader

#!/bin/bash
# -e: exit on error  -u: error on undefined vars (catches typos/missing env vars in CI)
set -eu

if [ -f .env ]; then
  set -a
  source .env
  set +a
fi

export GITHUB_SHA="${GITHUB_SHA:-$(git rev-parse --short HEAD)}"
export GITHUB_BRANCH="${GITHUB_BRANCH:-$(git rev-parse --abbrev-ref HEAD)}"

scripts/publish-pact.sh — Publish Pacts to Broker

#!/bin/bash
# Publish generated pact files to PactFlow/Pact Broker
#
# Requires: PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH
# -e: exit on error  -u: error on undefined vars  -o pipefail: fail if any pipe segment fails
set -euo pipefail

. ./scripts/env-setup.sh

PACT_DIR="./pacts"

pact-broker publish "$PACT_DIR" \
    --consumer-app-version="$GITHUB_SHA" \
    --branch="$GITHUB_BRANCH" \
    --broker-base-url="$PACT_BROKER_BASE_URL" \
    --broker-token="$PACT_BROKER_TOKEN"

scripts/can-i-deploy.sh — Deployment Safety Check

#!/bin/bash
# Check if a pacticipant version can be safely deployed
#
# Requires: PACTICIPANT (set by caller), PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA
# -e: exit on error  -u: error on undefined vars  -o pipefail: fail if any pipe segment fails
set -euo pipefail

. ./scripts/env-setup.sh

PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}"
ENVIRONMENT="${ENVIRONMENT:-dev}"

pact-broker can-i-deploy \
    --pacticipant "$PACTICIPANT" \
    --version="$GITHUB_SHA" \
    --to-environment "$ENVIRONMENT" \
    --retry-while-unknown=10 \
    --retry-interval=30

scripts/record-deployment.sh — Record Deployment

#!/bin/bash
# Record a deployment to an environment in Pact Broker
# Only records on main/master branch (skips feature branches)
#
# Requires: PACTICIPANT, PACT_BROKER_BASE_URL, PACT_BROKER_TOKEN, GITHUB_SHA, GITHUB_BRANCH
# -e: exit on error  -u: error on undefined vars  -o pipefail: fail if any pipe segment fails
set -euo pipefail

. ./scripts/env-setup.sh

PACTICIPANT="${PACTICIPANT:?PACTICIPANT env var is required}"

if [ "$GITHUB_BRANCH" = "main" ] || [ "$GITHUB_BRANCH" = "master" ]; then
  pact-broker record-deployment \
      --pacticipant "$PACTICIPANT" \
      --version "$GITHUB_SHA" \
      --environment "${npm_config_env:-dev}"
else
  echo "Skipping record-deployment: not on main branch (current: $GITHUB_BRANCH)"
fi

Key Points:

  • env-setup.sh uses set -eu (no pipefail — it only sources .env, no pipes); broker scripts use set -euo pipefail
  • Use pact-broker directly, NOT npx pact-broker
  • Use PACTICIPANT env var (required via ${PACTICIPANT:?...}), not hardcoded service names
  • can-i-deploy includes --retry-while-unknown=10 --retry-interval=30 (waits for provider verification)
  • record-deployment has branch guard (only records on main/master)
  • Do NOT invent custom env vars like PACT_CONSUMER_VERSION or PACT_BREAKING_CHANGE in scripts — those are handled by env-setup.sh and the CI detect-breaking-change action respectively

Example 5: CI Workflow (contract-test-consumer.yml)

Context: GitHub Actions workflow for consumer CDC, matching pactjs-utils structure exactly.

Implementation:

name: Contract Test - Consumer
on:
  pull_request:
    types: [opened, synchronize, reopened, edited]
  push:
    branches: [main]

env:
  PACT_BROKER_BASE_URL: ${{ secrets.PACT_BROKER_BASE_URL }}
  PACT_BROKER_TOKEN: ${{ secrets.PACT_BROKER_TOKEN }}
  GITHUB_SHA: ${{ github.sha }}
  GITHUB_BRANCH: ${{ github.head_ref || github.ref_name }}

concurrency:
  group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
  cancel-in-progress: true

jobs:
  consumer-contract-test:
    if: github.actor != 'dependabot[bot]'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: actions/setup-node@v6
        with:
          node-version-file: '.nvmrc'
          cache: 'npm'

      - name: Detect Pact breaking change
        uses: ./.github/actions/detect-breaking-change

      - name: Install dependencies
        run: npm ci

      # (1) Generate pact files
      - name: Run consumer contract tests
        run: npm run test:pact:consumer

      # (2) Publish pacts to broker
      - name: Publish pacts to PactFlow
        run: npm run publish:pact

      # After publish, PactFlow fires a webhook that triggers
      # the provider's contract-test-provider.yml workflow.
      # can-i-deploy retries while waiting for provider verification.

      # (4) Check deployment safety (main only — on PRs, local verification is the gate)
      - name: Can I deploy consumer? (main only)
        if: github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true'
        run: npm run can:i:deploy:consumer

      # (5) Record deployment (main only)
      - name: Record consumer deployment (main only)
        if: github.ref == 'refs/heads/main'
        run: npm run record:consumer:deployment --env=dev

Key Points:

  • Workflow-level env block for broker secrets and git vars — not per-step
  • detect-breaking-change step runs before install to set PACT_BREAKING_CHANGE env var
  • Step numbering skips (3) — step 3 is the webhook-triggered provider verification (happens externally)
  • can-i-deploy condition: github.ref == 'refs/heads/main' && env.PACT_BREAKING_CHANGE != 'true'
  • Comment on (4): "on PRs, local verification is the gate"
  • No upload-artifact step — the broker is the source of truth for pact files
  • dependabot[bot] skip on the job (contract tests don't run for dependency updates)
  • PR types include edited — needed for breaking change checkbox detection in PR body
  • GITHUB_BRANCH uses ${{ github.head_ref || github.ref_name }}head_ref for PRs, ref_name for pushes

Example 6: Detect Breaking Change Composite Action

Context: GitHub composite action that reads a [x] Pact breaking change checkbox from the PR body.

Implementation:

Create .github/actions/detect-breaking-change/action.yml:

name: 'Detect Pact Breaking Change'
description: 'Reads the PR template checkbox to determine if this change is a Pact breaking change. Sets PACT_BREAKING_CHANGE env var.'

outputs:
  is_breaking_change:
    description: 'Whether the change is a breaking change (true/false)'
    value: ${{ steps.result.outputs.is_breaking_change }}

runs:
  using: 'composite'
  steps:
    # PR event path: read checkbox directly from current PR body.
    - name: Set PACT_BREAKING_CHANGE from PR description (PR only)
      if: github.event_name == 'pull_request'
      uses: actions/github-script@v7
      with:
        script: |
          const prBody = context.payload.pull_request.body || '';
          const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i;
          const isBreakingChange = breakingChangePattern.test(prBody);
          core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false');
          console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from PR description checkbox).`);

    # Push-to-main path: resolve the merged PR and read the same checkbox.
    - name: Set PACT_BREAKING_CHANGE from merged PR (push to main)
      if: github.event_name == 'push' && github.ref == 'refs/heads/main'
      uses: actions/github-script@v7
      with:
        script: |
          const { data: prs } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
            owner: context.repo.owner,
            repo: context.repo.repo,
            commit_sha: context.sha,
          });
          const merged = prs.find(pr => pr.merged_at);
          const mergedBody = merged?.body || '';
          const breakingChangePattern = /\[\s*[xX]\s*\]\s*Pact breaking change/i;
          const isBreakingChange = breakingChangePattern.test(mergedBody);
          core.exportVariable('PACT_BREAKING_CHANGE', isBreakingChange ? 'true' : 'false');
          console.log(`PACT_BREAKING_CHANGE=${isBreakingChange ? 'true' : 'false'} (from merged PR lookup).`);

    - name: Export result
      id: result
      shell: bash
      run: echo "is_breaking_change=${PACT_BREAKING_CHANGE:-false}" >> "$GITHUB_OUTPUT"

Key Points:

  • Two separate conditional steps (better CI log readability than single if/else)
  • PR path: reads checkbox directly from PR body
  • Push-to-main path: resolves merged PR via GitHub API, reads same checkbox
  • Exports PACT_BREAKING_CHANGE env var for downstream steps
  • outputs.is_breaking_change available for consuming workflows
  • Uses a case-insensitive checkbox regex (/\[\s*[xX]\s*\]\s*Pact breaking change/i) to detect checked states robustly

Example 7: Consumer Test Using PactV4 Builder

Context: Consumer pact test using PactV4 addInteraction() builder pattern. The test MUST call real consumer code (your actual API client/service functions) against the mock server — not raw fetch(). Using fetch() directly defeats the purpose of CDC testing because it doesn't verify your actual consumer code works with the contract.

Implementation:

The consumer code must expose a way to inject the base URL (e.g., setApiUrl(), constructor parameter, or environment variable). This is a prerequisite for contract testing.

// src/api/movie-client.ts — The REAL consumer code (already exists in your project)
import axios from 'axios';

const axiosInstance = axios.create({
  baseURL: process.env.API_URL || 'http://localhost:3001',
});

// Expose a way to override the base URL for Pact testing
export const setApiUrl = (url: string) => {
  axiosInstance.defaults.baseURL = url;
};

export const getMovies = async () => {
  const res = await axiosInstance.get('/movies');
  return res.data;
};

export const getMovieById = async (id: number) => {
  const res = await axiosInstance.get(`/movies/${id}`);
  return res.data;
};
// tests/contract/consumer/get-movies.pacttest.ts
import { MatchersV3 } from '@pact-foundation/pact';
import type { V3MockServer } from '@pact-foundation/pact';
import { createProviderState, setJsonBody, setJsonContent } from '../support/consumer-helpers';
import { movieExists } from '../support/provider-states';
import { createPact } from '../support/pact-config';
// Import REAL consumer code — this is what we're actually testing
import { getMovies, getMovieById, setApiUrl } from '../../../src/api/movie-client';

const { like, integer, string } = MatchersV3;

const pact = createPact();

describe('Movies API Consumer Contract', () => {
  const movieWithId = { id: 1, name: 'The Matrix', year: 1999, rating: 8.7, director: 'Wachowskis' };

  it('should get a movie by ID', async () => {
    const [stateName, stateParams] = createProviderState(movieExists(movieWithId));

    await pact
      .addInteraction()
      .given(stateName, stateParams)
      .uponReceiving('a request to get movie by ID')
      .withRequest(
        'GET',
        '/movies/1',
        setJsonContent({
          headers: { Accept: 'application/json' },
        }),
      )
      .willRespondWith(
        200,
        setJsonBody(
          like({
            id: integer(1),
            name: string('The Matrix'),
            year: integer(1999),
            rating: like(8.7),
            director: string('Wachowskis'),
          }),
        ),
      )
      .executeTest(async (mockServer: V3MockServer) => {
        // Inject mock server URL into the REAL consumer code
        setApiUrl(mockServer.url);

        // Call the REAL consumer function — this is what CDC testing validates
        const movie = await getMovieById(1);

        expect(movie.id).toBe(1);
        expect(movie.name).toBe('The Matrix');
      });
  });

  it('should handle movie not found', async () => {
    await pact
      .addInteraction()
      .given('No movies exist')
      .uponReceiving('a request for a non-existent movie')
      .withRequest('GET', '/movies/999')
      .willRespondWith(404, setJsonBody({ error: 'Movie not found' }))
      .executeTest(async (mockServer: V3MockServer) => {
        setApiUrl(mockServer.url);

        await expect(getMovieById(999)).rejects.toThrow();
      });
  });
});

Key Points:

  • CRITICAL: Always test your REAL consumer code — import and call actual API client functions, never raw fetch()
  • Using fetch() directly only tests that Pact's mock server works, which is meaningless
  • Consumer code MUST expose a URL injection mechanism: setApiUrl(), env var override, or constructor parameter
  • If the consumer code doesn't support URL injection, add it — this is a design prerequisite for CDC testing
  • Use PactV4 addInteraction() builder (not PactV3 fluent API with withRequest({...}) object)
  • Interaction naming convention: Use the pattern "a request to <action> <resource> [<condition>]" for uponReceiving(). Examples: "a request to get a movie by ID", "a request to delete a non-existing movie", "a request to create a movie that already exists". These names appear in Pact Broker UI and verification logs — keep them descriptive and unique within the consumer-provider pair.
  • Use setJsonContent for request/response builder callbacks with query/header/body concerns; use setJsonBody for body-only response callbacks
  • Provider state factory functions (movieExists) return ProviderStateInput objects
  • createProviderState converts to [stateName, stateParams] tuple for .given()

Common URL injection patterns (pick whichever fits your consumer architecture):

Pattern Example Best For
setApiUrl(url) Mutates axios instance baseURL Singleton HTTP client
Constructor param new ApiClient({ baseUrl: mockServer.url }) Class-based clients
Environment variable process.env.API_URL = mockServer.url Config-driven apps
Factory function createApi({ baseUrl: mockServer.url }) Functional patterns

Example 8: Support Files

Pact Config Factory

// tests/contract/support/pact-config.ts
import path from 'node:path';
import { PactV4 } from '@pact-foundation/pact';

export const createPact = (overrides?: { consumer?: string; provider?: string }) =>
  new PactV4({
    dir: path.resolve(process.cwd(), 'pacts'),
    consumer: overrides?.consumer ?? 'MyConsumerApp',
    provider: overrides?.provider ?? 'MyProviderAPI',
    logLevel: 'warn',
  });

Provider State Factories

// tests/contract/support/provider-states.ts
import type { ProviderStateInput } from './consumer-helpers';

export const movieExists = (movie: { id: number; name: string; year: number; rating: number; director: string }): ProviderStateInput => ({
  name: 'An existing movie exists',
  params: movie,
});

export const hasMovieWithId = (id: number): ProviderStateInput => ({
  name: 'Has a movie with a specific ID',
  params: { id },
});

Local Consumer Helpers Shim

// tests/contract/support/consumer-helpers.ts
// TODO(temporary scaffolding): Replace local TemplateHeaders/TemplateQuery types
// with '@seontechnologies/pactjs-utils' exports when available.

type TemplateHeaders = Record<string, string | number | boolean>;
type TemplateQueryValue = string | number | boolean | Array<string | number | boolean>;
type TemplateQuery = Record<string, TemplateQueryValue>;

export type ProviderStateInput = {
  name: string;
  params: Record<string, unknown>;
};

type JsonMap = { [key: string]: boolean | number | string | null | JsonMap | Array<unknown> };
type JsonContentBuilder = {
  headers: (headers: TemplateHeaders) => unknown;
  jsonBody: (body: unknown) => unknown;
  query?: (query: TemplateQuery) => unknown;
};

export type JsonContentInput = {
  body?: unknown;
  headers?: TemplateHeaders;
  query?: TemplateQuery;
};

export const toJsonMap = (obj: Record<string, unknown>): JsonMap =>
  Object.fromEntries(
    Object.entries(obj).map(([key, value]) => {
      if (value === null || value === undefined) return [key, 'null'];
      if (typeof value === 'object' && !(value instanceof Date) && !Array.isArray(value)) return [key, JSON.stringify(value)];
      if (typeof value === 'number' || typeof value === 'boolean') return [key, value];
      if (value instanceof Date) return [key, value.toISOString()];
      return [key, String(value)];
    }),
  );

export const createProviderState = ({ name, params }: ProviderStateInput): [string, JsonMap] => [name, toJsonMap(params)];

export const setJsonContent =
  ({ body, headers, query }: JsonContentInput) =>
  (builder: JsonContentBuilder): void => {
    if (query && builder.query) {
      builder.query(query);
    }

    if (headers) {
      builder.headers(headers);
    }

    if (body !== undefined) {
      builder.jsonBody(body);
    }
  };

export const setJsonBody = (body: unknown) => setJsonContent({ body });

Key Points:

  • If @seontechnologies/pactjs-utils is not yet installed, create a local shim that mirrors the API
  • Add a TODO comment noting to swap for the published package when available
  • The shim exports createProviderState, toJsonMap, setJsonContent, setJsonBody, and helper input types
  • Keep shim types local (or sourced from public exports only); do not import from internal Pact paths like @pact-foundation/pact/src/*

Example 9: .gitignore Entries

Context: Pact-specific entries to add to .gitignore.

# Pact contract testing artifacts
/pacts/
pact-logs/

Validation Checklist

Before presenting the consumer CDC framework to the user, verify:

  • vitest.config.pact.ts is minimal (no pool/coverage/setup copied from unit config)
  • Script names match pactjs-utils (test:pact:consumer, publish:pact, can:i:deploy:consumer, record:consumer:deployment)
  • Scripts source env-setup.sh inline in package.json
  • Shell scripts use pact-broker not npx pact-broker
  • Shell scripts use PACTICIPANT env var pattern
  • can-i-deploy.sh has --retry-while-unknown=10 --retry-interval=30
  • record-deployment.sh has branch guard
  • env-setup.sh uses set -eu; broker scripts use set -euo pipefail — each with explanatory comment
  • CI workflow named contract-test-consumer.yml
  • CI has workflow-level env block (not per-step)
  • CI has detect-breaking-change step before install
  • CI step numbering skips (3) — webhook-triggered provider verification
  • CI can-i-deploy has PACT_BREAKING_CHANGE != 'true' condition
  • CI has NO upload-artifact step
  • .github/actions/detect-breaking-change/action.yml exists
  • Consumer tests use .pacttest.ts extension
  • Consumer tests use PactV4 addInteraction() builder
  • uponReceiving() names follow "a request to <action> <resource> [<condition>]" pattern and are unique within the consumer-provider pair
  • Interaction callbacks use setJsonContent for query/header/body and setJsonBody for body-only responses
  • Request bodies use exact values (no like() wrapper) — Postel's Law: be strict in what you send
  • like(), eachLike(), string(), integer() matchers are only used in willRespondWith (responses), not in withRequest (requests) — matchers check type/shape, not exact values
  • Consumer tests call REAL consumer code (actual API client functions), NOT raw fetch()
  • Consumer code exposes URL injection mechanism (setApiUrl(), env var, or constructor param)
  • Local consumer-helpers shim present if pactjs-utils not installed
  • .gitignore includes /pacts/ and pact-logs/
  • pactjs-utils-overview.md — Library decision tree and installation
  • pactjs-utils-consumer-helpers.mdcreateProviderState, toJsonMap, setJsonContent, and setJsonBody API details
  • pactjs-utils-provider-verifier.md — Provider-side verification patterns
  • pactjs-utils-request-filter.md — Auth injection for provider verification
  • contract-testing.md — Foundational CDC patterns and resilience coverage