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>
This commit is contained in:
310
_bmad/tea/testarch/knowledge/pact-consumer-di.md
Normal file
310
_bmad/tea/testarch/knowledge/pact-consumer-di.md
Normal file
@@ -0,0 +1,310 @@
|
||||
# Pact Consumer DI Pattern
|
||||
|
||||
## Principle
|
||||
|
||||
Inject the Pact mock server URL into consumer code via an optional `baseUrl` field on the API context type instead of using raw `fetch()` inside `executeTest()`. This ensures contract tests exercise the real consumer HTTP client — including retry logic, header assembly, timeout configuration, error handling, and metrics — rather than testing Pact itself.
|
||||
|
||||
The base URL is typically a module-level constant evaluated at import time (`export const API_BASE_URL = env.API_BASE_URL`), but `mockServer.url` is only available at runtime inside `executeTest()`. Dependency injection solves this timing mismatch cleanly: add one optional field to the context type, use nullish coalescing in the HTTP client factory, and inject the mock server URL in tests.
|
||||
|
||||
## Rationale
|
||||
|
||||
### The Problem
|
||||
|
||||
Raw `fetch()` in `executeTest()` only proves that Pact returns what you told it to return. The real consumer HTTP client has retry logic, header assembly, timeout configuration, error handling, and metrics collection — none of which are exercised when you hand-craft fetch calls. Contracts written with raw fetch are hand-maintained guesses about what the consumer actually sends.
|
||||
|
||||
### Why NOT vi.mock
|
||||
|
||||
`vi.mock` with ESM (`module: Node16`) has hoisting quirks that make it unreliable for overriding module-level constants. A getter-based mock is non-obvious and fragile — it works until the next bundler or TypeScript config change breaks it. DI is a standard pattern that requires zero mock magic and works across all module systems.
|
||||
|
||||
### Comparison
|
||||
|
||||
| Approach | Production code change | Mock complexity | Exercises real client | Contract accuracy |
|
||||
| ------------ | ---------------------- | -------------------------- | --------------------- | --------------------------- |
|
||||
| Raw fetch | None | None | No | Low — hand-crafted requests |
|
||||
| vi.mock | None | High — ESM hoisting issues | Yes | Medium — fragile setup |
|
||||
| DI (baseUrl) | 2 lines | None | Yes | High — real requests |
|
||||
|
||||
## Pattern Examples
|
||||
|
||||
### Example 1: Production Code Change (2 Lines Total)
|
||||
|
||||
**Context**: Add an optional `baseUrl` field to the API context type and use nullish coalescing in the HTTP client factory. This is the entire production code change required.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
export type ApiContext = {
|
||||
jwtToken: string;
|
||||
customerId: number;
|
||||
adminUserId?: number;
|
||||
correlationId?: string;
|
||||
baseUrl?: string; // Override for testing (Pact mock server)
|
||||
};
|
||||
```
|
||||
|
||||
```typescript
|
||||
// src/http-client.ts
|
||||
import axios from 'axios';
|
||||
import type { AxiosInstance } from 'axios';
|
||||
import type { ApiContext } from './types.js';
|
||||
import { API_BASE_URL, REQUEST_TIMEOUT } from './constants.js';
|
||||
|
||||
function createAxiosInstanceWithContext(context: ApiContext): AxiosInstance {
|
||||
return axios.create({
|
||||
baseURL: context.baseUrl ?? API_BASE_URL,
|
||||
timeout: REQUEST_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${context.jwtToken}`,
|
||||
...(context.correlationId && { 'X-Request-Id': context.correlationId }),
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- `baseUrl` is optional — existing production code never sets it
|
||||
- `??` (nullish coalescing) falls back to `API_BASE_URL` when `baseUrl` is undefined
|
||||
- Zero production behavior change — only test code provides the override
|
||||
- Two lines added total: one type field, one `??` fallback
|
||||
|
||||
### Example 2: Shared Test Context Helper
|
||||
|
||||
**Context**: Create a reusable helper that builds an `ApiContext` with the mock server URL injected. One helper shared across all consumer test files.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
// pact/support/test-context.ts
|
||||
import type { ApiContext } from '../../src/types.js';
|
||||
|
||||
export function createTestContext(mockServerUrl: string): ApiContext {
|
||||
return {
|
||||
jwtToken: 'test-jwt-token',
|
||||
customerId: 1,
|
||||
baseUrl: `${mockServerUrl}/api/v2`,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- `baseUrl` should include the API version prefix when consumer methods use versionless relative paths (e.g., `/transactions`) or endpoint paths are defined without the version segment
|
||||
- Single helper shared across all consumer test files — no repetition
|
||||
- Returns a plain object — follows pure-function-first pattern from `fixture-architecture.md`
|
||||
- Add fields as needed (e.g., `adminUserId`, `correlationId`) for specific test scenarios
|
||||
|
||||
### Example 3: Before/After for a Simple Test
|
||||
|
||||
**Context**: Migrating an existing raw-fetch test to call real consumer code.
|
||||
|
||||
**Before** (raw fetch — tests Pact mock, not consumer code):
|
||||
|
||||
```typescript
|
||||
.executeTest(async (mockServer: V3MockServer) => {
|
||||
const response = await fetch(
|
||||
`${mockServer.url}/api/v2/common/fields?ruleType=!&ignoreFeatureFlags=true`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: "Bearer test-jwt-token",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as Record<string, unknown>[];
|
||||
expect(body).toEqual(expect.arrayContaining([...]));
|
||||
});
|
||||
```
|
||||
|
||||
**After** (real consumer code):
|
||||
|
||||
```typescript
|
||||
.executeTest(async (mockServer: V3MockServer) => {
|
||||
const api = createApiClient(createTestContext(mockServer.url));
|
||||
const result = await api.getFilterFields();
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
readable: expect.any(String),
|
||||
filterType: expect.any(String),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- No HTTP status assertion — the consumer method throws on non-2xx, so reaching the expect proves success
|
||||
- Assertions validate the return value shape, not transport details
|
||||
- The real client's headers, timeout, and retry logic are exercised transparently
|
||||
- Less code, more coverage — the test is shorter and tests more
|
||||
|
||||
### Example 4: Contract Accuracy Fix
|
||||
|
||||
**Context**: Using real consumer code revealed a contract mismatch that raw fetch silently hid. This is the strongest argument for the pattern.
|
||||
|
||||
The real `getCustomerActivityCount(transactionId, dateRange)` sends:
|
||||
|
||||
```json
|
||||
{ "transactionId": "txn-123", "filters": { "dateRange": "last_30_days" } }
|
||||
```
|
||||
|
||||
The old test with raw fetch sent:
|
||||
|
||||
```json
|
||||
{ "transactionId": "txn-123", "filters": {} }
|
||||
```
|
||||
|
||||
This was wrong but passed because raw fetch let you hand-craft any body. When switched to real code, Pact immediately returned a 500 Request-Mismatch because the body shape did not match the interaction.
|
||||
|
||||
**Implementation** — fix the contract to match reality:
|
||||
|
||||
```typescript
|
||||
// WRONG — old contract with empty filters
|
||||
.withRequest({
|
||||
method: "POST",
|
||||
path: "/api/v2/customers/activity/count",
|
||||
body: { transactionId: "txn-123", filters: {} },
|
||||
})
|
||||
|
||||
// CORRECT — matches what real code actually sends
|
||||
.withRequest({
|
||||
method: "POST",
|
||||
path: "/api/v2/customers/activity/count",
|
||||
body: {
|
||||
transactionId: "txn-123",
|
||||
filters: { dateRange: "last_30_days" },
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Contracts become discoverable truth, not hand-maintained guesses
|
||||
- Raw fetch silently hid the mismatch — the mock accepted whatever you sent
|
||||
- The 500 Request-Mismatch from Pact was immediate and clear
|
||||
- Fix the contract when real code reveals a mismatch — that mismatch is a bug the old tests were hiding
|
||||
|
||||
### Example 5: Parallel-Endpoint Methods
|
||||
|
||||
**Context**: Facade methods that call multiple endpoints via `Promise.all` (e.g., `getTransactionStats` calls count + score + amount in parallel). Keep separate `it` blocks per endpoint and use the lower-level request function directly.
|
||||
|
||||
**Implementation**:
|
||||
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { V3MockServer } from '@pact-foundation/pact';
|
||||
import { makeApiRequestWithContext } from '../../src/http-client.js';
|
||||
import type { CountStatistics } from '../../src/types.js';
|
||||
import { createTestContext } from '../support/test-context.js';
|
||||
|
||||
describe('Transaction Statistics - Count Endpoint', () => {
|
||||
// ... provider setup ...
|
||||
|
||||
it('should return count statistics', async () => {
|
||||
const statsRequest = { transactionId: 'txn-123', period: 'daily' };
|
||||
|
||||
await provider
|
||||
.given('transaction statistics exist')
|
||||
.uponReceiving('a request for transaction count statistics')
|
||||
.withRequest({
|
||||
method: 'POST',
|
||||
path: '/api/v2/transactions/statistics/count',
|
||||
body: statsRequest,
|
||||
})
|
||||
.willRespondWith({
|
||||
status: 200,
|
||||
body: { count: 42, period: 'daily' },
|
||||
})
|
||||
.executeTest(async (mockServer: V3MockServer) => {
|
||||
const context = createTestContext(mockServer.url);
|
||||
const result = await makeApiRequestWithContext<CountStatistics>(context, '/transactions/statistics/count', 'POST', statsRequest);
|
||||
expect(result.count).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Key Points**:
|
||||
|
||||
- Each Pact interaction verifies one endpoint contract
|
||||
- The `Promise.all` orchestration is internal logic, not a contract concern
|
||||
- Use `makeApiRequestWithContext` (lower-level) when the facade method bundles multiple calls
|
||||
- Separate `it` blocks keep contracts independent and debuggable
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Wrong: Raw fetch — tests Pact mock, not consumer code
|
||||
|
||||
```typescript
|
||||
// BAD: Raw fetch duplicates headers and URL assembly
|
||||
const response = await fetch(`${mockServer.url}/api/v2/transactions`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-jwt-token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
```
|
||||
|
||||
### Wrong: vi.mock with getter — fragile ESM hoisting
|
||||
|
||||
```typescript
|
||||
// BAD: ESM hoisting makes this non-obvious and brittle
|
||||
vi.mock('../../src/constants.js', async (importOriginal) => ({
|
||||
...(await importOriginal()),
|
||||
get API_BASE_URL() {
|
||||
return mockBaseUrl;
|
||||
},
|
||||
}));
|
||||
```
|
||||
|
||||
### Wrong: Asserting HTTP status instead of return value
|
||||
|
||||
```typescript
|
||||
// BAD: Status 200 tells you nothing about the consumer's parsing logic
|
||||
expect(response.status).toBe(200);
|
||||
```
|
||||
|
||||
### Right: Call real consumer code, assert return values
|
||||
|
||||
```typescript
|
||||
// GOOD: Exercises real client, validates parsed return value
|
||||
const api = createApiClient(createTestContext(mockServer.url));
|
||||
const result = await api.searchTransactions(request);
|
||||
expect(result.transactions).toBeDefined();
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
1. `baseUrl` field MUST be optional with fallback via `??` (nullish coalescing)
|
||||
2. Zero production behavior change — existing code never sets `baseUrl`
|
||||
3. Assertions validate return values from consumer methods, not HTTP status codes
|
||||
4. For parallel-endpoint facade methods, keep separate `it` blocks per endpoint
|
||||
5. Include the API version prefix in `baseUrl` when endpoint paths/consumer methods are versionless (for example, methods call `/transactions` instead of `/api/v2/transactions`)
|
||||
6. Create a single shared test context helper — no repetition across test files
|
||||
7. If real code reveals a contract mismatch, fix the contract — that mismatch is a bug the old tests were hiding
|
||||
|
||||
## Integration Points
|
||||
|
||||
- `contract-testing.md` — Foundational Pact.js patterns and provider verification
|
||||
- `pactjs-utils-consumer-helpers.md` — `createProviderState()`, `setJsonContent()`, and `setJsonBody()` helpers used alongside this pattern
|
||||
- `pactjs-utils-provider-verifier.md` — Provider-side verification configuration
|
||||
- `fixture-architecture.md` — Composable fixture patterns (`createTestContext` follows pure-function-first)
|
||||
- `api-testing-foundations.md` — API testing best practices
|
||||
|
||||
Used in workflows:
|
||||
|
||||
- `automate` — Consumer contract test generation
|
||||
- `test-review` — Contract test quality checks
|
||||
|
||||
## Source
|
||||
|
||||
Pattern derived from my-consumer-app Pact consumer test refactor (March 2026). Implements dependency injection for testability as described in Pact.js best practices.
|
||||
Reference in New Issue
Block a user