Skip to main content

TVL Platform - API Contract Testing

Summary

This document defines the contract testing strategy for the TVL Platform API, ensuring API stability and preventing breaking changes.


Contract Testing Strategy

What is Contract Testing?

Contract testing validates that an API provider (TVL backend) adheres to a contract (OpenAPI spec) that API consumers (web UI, mobile apps, integrations) depend on.

Goals

  1. Prevent Breaking Changes: Catch breaking changes before deployment
  2. Validate OpenAPI Spec: Ensure OpenAPI spec matches implementation
  3. Consumer-Driven: Test scenarios that real consumers care about
  4. Fast Feedback: Run in CI/CD pipeline (< 30 seconds)

Testing Tools

Primary Tool: Dredd

Dredd validates HTTP API against OpenAPI specification.

npm install --save-dev dredd

Configuration (dredd.yml):

language: nodejs
server: npm start
server-wait: 3
init: npm run seed:test
dry-run: false
hookfiles: ./test/hooks/*.js
only:
- "Properties > Create Property"
- "Bookings > List Bookings"
reporter:
- markdown
- html
output:
- ./test-results/dredd.md
- ./test-results/dredd.html

Secondary Tool: Pact

Pact for consumer-driven contract testing (future).


Test Structure

1. Schema Validation Tests

Validate request/response schemas match OpenAPI spec.

Example: Create Property

// test/contracts/properties.spec.ts
import { expect } from 'chai';
import { validateSchema } from '../utils/validator';
import openApiSpec from '../../apis/openapi.yaml';

describe('POST /properties', () => {
it('should match OpenAPI schema for success response', async () => {
const response = await apiClient.post('/properties', {
name: 'Beach House',
address: {
street: '123 Ocean Dr',
city: 'Miami',
state: 'FL',
postal_code: '33139',
country: 'US'
},
property_type: 'house'
});

expect(response.status).to.equal(201);

const isValid = validateSchema(
response.data,
openApiSpec.paths['/properties'].post.responses['201'].content['application/json'].schema
);

expect(isValid).to.be.true;
});

it('should return 400 for missing required fields', async () => {
const response = await apiClient.post('/properties', {
name: 'Beach House'
// missing address
});

expect(response.status).to.equal(400);
expect(response.data.error.code).to.equal('VALIDATION_ERROR');
});
});

2. Example Validation Tests

Validate that examples in OpenAPI spec are valid.

// test/contracts/validate-examples.spec.ts
import { validateExamples } from '../utils/validator';
import openApiSpec from '../../apis/openapi.yaml';

describe('OpenAPI Examples', () => {
it('should validate all request examples', () => {
const results = validateExamples(openApiSpec, 'request');
expect(results.invalid).to.have.length(0);
});

it('should validate all response examples', () => {
const results = validateExamples(openApiSpec, 'response');
expect(results.invalid).to.have.length(0);
});
});

3. Integration Tests

End-to-end workflow tests.

// test/integration/booking-flow.spec.ts
describe('Booking Flow', () => {
let propertyId: string;
let unitId: string;
let quoteId: string;
let holdId: string;
let bookingId: string;

before(async () => {
// Setup: create property and unit
const property = await apiClient.post('/properties', {...});
propertyId = property.data.id;

const unit = await apiClient.post(`/properties/${propertyId}/units`, {...});
unitId = unit.data.id;
});

it('should complete full booking workflow', async () => {
// 1. Check availability
const availability = await apiClient.get(
`/units/${unitId}/availability?start=2025-12-01&end=2025-12-07`
);
expect(availability.data.available).to.be.true;

// 2. Generate quote
const quote = await apiClient.post('/quotes', {
unit_id: unitId,
check_in: '2025-12-01',
check_out: '2025-12-07',
guests: 2
});
expect(quote.status).to.equal(201);
quoteId = quote.data.id;

// 3. Create hold
const hold = await apiClient.post(`/quotes/${quoteId}/hold`);
expect(hold.status).to.equal(201);
holdId = hold.data.id;

// 4. Confirm booking
const booking = await apiClient.post(`/holds/${holdId}/confirm`, {
payment_method: 'pm_test_card'
});
expect(booking.status).to.equal(200);
bookingId = booking.data.id;

// 5. Verify booking created
const bookingDetails = await apiClient.get(`/bookings/${bookingId}`);
expect(bookingDetails.data.status).to.equal('confirmed');
});
});

4. Consumer Contract Tests (Pact)

Define expectations from consumer perspective (future implementation).

// web-ui/test/pacts/properties.pact.ts
import { Pact } from '@pact-foundation/pact';

const provider = new Pact({
consumer: 'WebUI',
provider: 'TVL-API'
});

describe('Properties API Pact', () => {
before(() => provider.setup());
after(() => provider.finalize());

it('should return properties list', async () => {
await provider.addInteraction({
state: 'user has properties',
uponReceiving: 'a request for properties',
withRequest: {
method: 'GET',
path: '/properties',
headers: {
Authorization: 'Bearer token'
}
},
willRespondWith: {
status: 200,
body: {
data: Matchers.eachLike({
id: Matchers.uuid(),
name: Matchers.string(),
property_type: Matchers.string()
}),
meta: {
has_more: Matchers.boolean()
}
}
}
});

// Execute test
const response = await webUIClient.getProperties();
expect(response.data).to.be.an('array');
});
});

CI/CD Integration

GitHub Actions Workflow

# .github/workflows/api-contract-tests.yml
name: API Contract Tests

on:
pull_request:
paths:
- 'apis/**'
- 'src/api/**'

jobs:
contract-tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'

- name: Install dependencies
run: npm ci

- name: Start test database
run: docker-compose up -d postgres redis

- name: Run migrations
run: npm run migrate:test

- name: Seed test data
run: npm run seed:test

- name: Start API server
run: npm run start:test &
env:
NODE_ENV: test
DATABASE_URL: postgres://test:test@localhost:5432/tvl_test

- name: Wait for API
run: npx wait-on http://localhost:4000/health -t 30000

- name: Run Dredd contract tests
run: npm run test:contracts

- name: Upload test results
if: always()
uses: actions/upload-artifact@v3
with:
name: contract-test-results
path: test-results/

Pre-commit Hook (Optional)

# .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"

npm run validate:openapi

Breaking Change Detection

Automated Breaking Change Detection

Use oasdiff to detect breaking changes between OpenAPI versions.

npm install -g oasdiff

# Compare current spec with previous version
oasdiff breaking \
apis/openapi.v1.0.0.yaml \
apis/openapi.yaml \
--format json \
--fail-on-diff

CI Integration:

- name: Check for breaking changes
run: |
npm run openapi:diff
continue-on-error: false

What Constitutes a Breaking Change?

  • ✅ Detected by oasdiff:

    • Removed endpoints
    • Removed required response fields
    • Changed field types
    • Added required request parameters
    • Removed enum values
  • ⚠️ Requires manual review:

    • Changed error codes
    • Changed rate limits
    • Changed authentication requirements

Test Data Management

Seed Data for Contract Tests

// test/seeds/contract-test-data.ts
export const seedContractTestData = async (db: Database) => {
// Create test organization
const org = await db.organizations.create({
id: 'org-test-123',
name: 'Test Org',
slug: 'test-org'
});

// Create test user
const user = await db.users.create({
id: 'user-test-123',
email: 'test@example.com',
organization_id: org.id
});

// Create test property
const property = await db.properties.create({
id: 'prop-test-123',
organization_id: org.id,
name: 'Test Beach House',
property_type: 'house'
});

// Create test unit
const unit = await db.units.create({
id: 'unit-test-123',
property_id: property.id,
name: 'Entire House',
bedrooms: 3,
bathrooms: 2
});

return { org, user, property, unit };
};

Test Data Cleanup

// test/hooks/cleanup.ts
afterEach(async () => {
// Cleanup test data to ensure test isolation
await db.bookings.deleteMany({ id: { startsWith: 'test-' } });
await db.quotes.deleteMany({ id: { startsWith: 'test-' } });
await db.blocks.deleteMany({ id: { startsWith: 'test-' } });
});

Monitoring Contract Compliance in Production

Runtime Schema Validation

Validate responses in production (with sampling to avoid performance impact).

// src/middleware/schema-validator.ts
import { validateResponse } from './utils/validator';
import openApiSpec from '../apis/openapi.yaml';

export const schemaValidatorMiddleware = (req, res, next) => {
const originalJson = res.json.bind(res);

res.json = (data) => {
// Sample 1% of responses for validation
if (Math.random() < 0.01) {
const path = req.route.path;
const method = req.method.toLowerCase();
const statusCode = res.statusCode.toString();

const schema = openApiSpec.paths[path]?.[method]?.responses[statusCode]?.content['application/json']?.schema;

if (schema) {
const isValid = validateResponse(data, schema);

if (!isValid) {
logger.error('Schema validation failed', {
path,
method,
statusCode,
errors: validator.errors
});

// Report to monitoring
Sentry.captureMessage('API schema mismatch', {
level: 'warning',
extra: { path, method, statusCode, errors: validator.errors }
});
}
}
}

return originalJson(data);
};

next();
};

Validation & Alternatives

Testing Strategy Decisions

Agree: Dredd for OpenAPI validation

  • Alternative: Postman, Swagger Inspector
  • Trade-off: Dredd is CLI-friendly for CI/CD; Postman better for manual testing

Agree: Consumer-driven contracts with Pact (future)

  • Alternative: Provider-driven only
  • Trade-off: Consumer-driven catches more real-world issues but requires coordination

⚠️ Consider: Runtime schema validation sampling rate

  • Current: 1% sampling
  • Alternative: 100% validation or 0% (disabled)
  • Recommendation: Start with 1%, increase to 10% if performance allows

Known Gaps & Assumptions

Assumptions

  1. OpenAPI spec is the source of truth
  2. Test data IDs prefixed with test- for easy cleanup
  3. Contract tests run on every PR
  4. Breaking changes block deployment

Gaps

  1. No visual regression testing for error responses
  2. No performance contract testing (SLO validation)
  3. No contract tests for websocket endpoints (not in MVP)
  4. Manual review still required for non-breaking changes

Sources

  • apis/openapi.yaml (to be created)
  • docs/03-apis/api-changelog.md
  • docs/01-architecture/logical-architecture.md