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
- Prevent Breaking Changes: Catch breaking changes before deployment
- Validate OpenAPI Spec: Ensure OpenAPI spec matches implementation
- Consumer-Driven: Test scenarios that real consumers care about
- 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
- OpenAPI spec is the source of truth
- Test data IDs prefixed with test-for easy cleanup
- Contract tests run on every PR
- Breaking changes block deployment
Gaps
- No visual regression testing for error responses
- No performance contract testing (SLO validation)
- No contract tests for websocket endpoints (not in MVP)
- 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