contract testing

This commit is contained in:
2025-12-24 00:01:01 +01:00
parent 43a8afe7a9
commit 5e491d9724
52 changed files with 2058 additions and 612 deletions

110
.github/workflows/contract-testing.yml vendored Normal file
View File

@@ -0,0 +1,110 @@
name: Contract Testing
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
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'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run API contract validation
run: npm run test:api:contracts
- name: Generate OpenAPI spec
run: npm run api:generate-spec
- name: Generate TypeScript types
run: npm run api:generate-types
- name: Run contract compatibility check
run: npm run test:contract:compatibility
- name: Verify website type checking
run: npm run website:type-check
- name: Upload generated types as artifacts
uses: actions/upload-artifact@v3
with:
name: generated-types
path: apps/website/lib/types/generated/
retention-days: 7
- name: Comment PR with results
if: github.event_name == 'pull_request'
uses: actions/github-script@v6
with:
script: |
const fs = require('fs');
const path = require('path');
// Read any contract change reports
const reportPath = path.join(process.cwd(), 'contract-report.json');
if (fs.existsSync(reportPath)) {
const report = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
const comment = `
## 🔍 Contract Testing Results
✅ **All contract tests passed!**
### Changes Summary:
- Total changes: ${report.totalChanges}
- Breaking changes: ${report.breakingChanges}
- Added: ${report.added}
- Removed: ${report.removed}
- Modified: ${report.modified}
Generated types are available as artifacts.
`;
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: comment
});
}
contract-snapshot:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Generate and snapshot types
run: |
npm run api:generate-spec
npm run api:generate-types
- name: Commit generated types
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add apps/website/lib/types/generated/
git diff --staged --quiet || git commit -m "chore: update generated API types [skip ci]"
git push

View File

@@ -97,7 +97,7 @@
"type": "string" "type": "string"
}, },
"price": { "price": {
"type": "number" "type": "string"
}, },
"currency": { "currency": {
"type": "string" "type": "string"
@@ -213,28 +213,28 @@
"type": "object", "type": "object",
"properties": { "properties": {
"impressions": { "impressions": {
"type": "number" "type": "string"
}, },
"impressionsChange": { "impressionsChange": {
"type": "number" "type": "string"
}, },
"uniqueViewers": { "uniqueViewers": {
"type": "number" "type": "string"
}, },
"viewersChange": { "viewersChange": {
"type": "number" "type": "string"
}, },
"races": { "races": {
"type": "number" "type": "string"
}, },
"drivers": { "drivers": {
"type": "number" "type": "string"
}, },
"exposure": { "exposure": {
"type": "number" "type": "string"
}, },
"exposureChange": { "exposureChange": {
"type": "number" "type": "string"
} }
}, },
"required": [ "required": [
@@ -252,13 +252,13 @@
"type": "object", "type": "object",
"properties": { "properties": {
"activeSponsorships": { "activeSponsorships": {
"type": "number" "type": "string"
}, },
"totalInvestment": { "totalInvestment": {
"type": "number" "type": "string"
}, },
"costPerThousandViews": { "costPerThousandViews": {
"type": "number" "type": "string"
} }
}, },
"required": [ "required": [
@@ -334,28 +334,32 @@
}, },
"date": { "date": {
"type": "string" "type": "string"
},
"views": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"name", "name",
"date" "date",
"views"
] ]
}, },
"PrivacySettingsDTO": { "PrivacySettingsDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"publicProfile": { "publicProfile": {
"type": "boolean" "type": "string"
}, },
"showStats": { "showStats": {
"type": "boolean" "type": "string"
}, },
"showActiveSponsorships": { "showActiveSponsorships": {
"type": "boolean" "type": "string"
}, },
"allowDirectContact": { "allowDirectContact": {
"type": "boolean" "type": "string"
} }
}, },
"required": [ "required": [
@@ -380,22 +384,22 @@
"type": "object", "type": "object",
"properties": { "properties": {
"emailNewSponsorships": { "emailNewSponsorships": {
"type": "boolean" "type": "string"
}, },
"emailWeeklyReport": { "emailWeeklyReport": {
"type": "boolean" "type": "string"
}, },
"emailRaceAlerts": { "emailRaceAlerts": {
"type": "boolean" "type": "string"
}, },
"emailPaymentAlerts": { "emailPaymentAlerts": {
"type": "boolean" "type": "string"
}, },
"emailNewOpportunities": { "emailNewOpportunities": {
"type": "boolean" "type": "string"
}, },
"emailContractExpiry": { "emailContractExpiry": {
"type": "boolean" "type": "string"
} }
}, },
"required": [ "required": [
@@ -442,13 +446,13 @@
"type": "string" "type": "string"
}, },
"amount": { "amount": {
"type": "number" "type": "string"
}, },
"vatAmount": { "vatAmount": {
"type": "number" "type": "string"
}, },
"totalAmount": { "totalAmount": {
"type": "number" "type": "string"
} }
}, },
"required": [ "required": [
@@ -519,21 +523,33 @@
"id": { "id": {
"type": "string" "type": "string"
}, },
"iracingId": {
"type": "string"
},
"name": { "name": {
"type": "string" "type": "string"
}, },
"country": { "country": {
"type": "string" "type": "string"
},
"position": {
"type": "string"
},
"races": {
"type": "string"
},
"impressions": {
"type": "string"
},
"team": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"iracingId",
"name", "name",
"country" "country",
"position",
"races",
"impressions",
"team"
] ]
}, },
"CreateSponsorInputDTO": { "CreateSponsorInputDTO": {
@@ -555,22 +571,22 @@
"type": "object", "type": "object",
"properties": { "properties": {
"totalSpent": { "totalSpent": {
"type": "number" "type": "string"
}, },
"pendingAmount": { "pendingAmount": {
"type": "number" "type": "string"
}, },
"nextPaymentDate": { "nextPaymentDate": {
"type": "string" "type": "string"
}, },
"nextPaymentAmount": { "nextPaymentAmount": {
"type": "number" "type": "string"
}, },
"activeSponsorships": { "activeSponsorships": {
"type": "number" "type": "string"
}, },
"averageMonthlySpend": { "averageMonthlySpend": {
"type": "number" "type": "string"
} }
}, },
"required": [ "required": [
@@ -595,10 +611,10 @@
"type": "string" "type": "string"
}, },
"drivers": { "drivers": {
"type": "number" "type": "string"
}, },
"avgViewsPerRace": { "avgViewsPerRace": {
"type": "number" "type": "string"
} }
}, },
"required": [ "required": [
@@ -1197,12 +1213,6 @@
}, },
"scheduledAt": { "scheduledAt": {
"type": "string" "type": "string"
},
"status": {
"type": "string"
},
"isMyLeague": {
"type": "boolean"
} }
}, },
"required": [ "required": [
@@ -1211,9 +1221,7 @@
"leagueName", "leagueName",
"track", "track",
"car", "car",
"scheduledAt", "scheduledAt"
"status",
"isMyLeague"
] ]
}, },
"DashboardLeagueStandingSummaryDTO": { "DashboardLeagueStandingSummaryDTO": {
@@ -1373,6 +1381,36 @@
"leagueName" "leagueName"
] ]
}, },
"AllRacesStatusFilterDTO": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"label": {
"type": "string"
}
},
"required": [
"value",
"label"
]
},
"AllRacesLeagueFilterDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"id",
"name"
]
},
"UpdatePaymentStatusInputDTO": { "UpdatePaymentStatusInputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -1384,7 +1422,7 @@
"paymentId" "paymentId"
] ]
}, },
"PaymentDto": { "PaymentDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1395,7 +1433,7 @@
"id" "id"
] ]
}, },
"MembershipFeeDto": { "MembershipFeeDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1410,7 +1448,7 @@
"leagueId" "leagueId"
] ]
}, },
"MemberPaymentDto": { "MemberPaymentDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1441,7 +1479,7 @@
"netAmount" "netAmount"
] ]
}, },
"PrizeDto": { "PrizeDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1472,7 +1510,7 @@
"amount" "amount"
] ]
}, },
"WalletDto": { "WalletDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1512,7 +1550,7 @@
"currency" "currency"
] ]
}, },
"TransactionDto": { "TransactionDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"id": { "id": {
@@ -1527,18 +1565,7 @@
"walletId" "walletId"
] ]
}, },
"PaymentDTO": { "DeletePrizeResultDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
}
},
"required": [
"id"
]
},
"UploadMediaOutputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"success": { "success": {
@@ -1549,11 +1576,22 @@
"success" "success"
] ]
}, },
"UploadMediaOutputDTO": {
"type": "object",
"properties": {
"success": {
"type": "string"
}
},
"required": [
"success"
]
},
"UpdateAvatarOutputDTO": { "UpdateAvatarOutputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
"success": { "success": {
"type": "boolean" "type": "string"
} }
}, },
"required": [ "required": [
@@ -1610,8 +1648,7 @@
"type": "string" "type": "string"
}, },
"uploadedAt": { "uploadedAt": {
"type": "string", "type": "string"
"format": "date-time"
}, },
"size": { "size": {
"type": "number" "type": "number"
@@ -1639,7 +1676,7 @@
"type": "object", "type": "object",
"properties": { "properties": {
"success": { "success": {
"type": "boolean" "type": "string"
} }
}, },
"required": [ "required": [
@@ -1840,33 +1877,11 @@
}, },
"name": { "name": {
"type": "string" "type": "string"
},
"description": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"settings": {
"type": "object"
},
"maxDrivers": {
"type": "number"
},
"sessionDuration": {
"type": "number"
},
"visibility": {
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"name", "name"
"description",
"ownerId",
"settings",
"maxDrivers"
] ]
}, },
"LeagueSummaryDTO": { "LeagueSummaryDTO": {
@@ -2283,151 +2298,6 @@
"driverId" "driverId"
] ]
}, },
"DriverProfileDriverSummaryDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"country": {
"type": "string"
},
"avatarUrl": {
"type": "string"
}
},
"required": [
"id",
"name",
"country",
"avatarUrl"
]
},
"DriverProfileStatsDTO": {
"type": "object",
"properties": {
"totalRaces": {
"type": "number"
},
"wins": {
"type": "number"
},
"podiums": {
"type": "number"
},
"dnfs": {
"type": "number"
}
},
"required": [
"totalRaces",
"wins",
"podiums",
"dnfs"
]
},
"DriverProfileFinishDistributionDTO": {
"type": "object",
"properties": {
"totalRaces": {
"type": "number"
},
"wins": {
"type": "number"
},
"podiums": {
"type": "number"
},
"topTen": {
"type": "number"
},
"dnfs": {
"type": "number"
},
"other": {
"type": "number"
}
},
"required": [
"totalRaces",
"wins",
"podiums",
"topTen",
"dnfs",
"other"
]
},
"DriverProfileTeamMembershipDTO": {
"type": "object",
"properties": {
"teamId": {
"type": "string"
},
"teamName": {
"type": "string"
}
},
"required": [
"teamId",
"teamName"
]
},
"DriverProfileSocialFriendSummaryDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"country": {
"type": "string"
},
"avatarUrl": {
"type": "string"
}
},
"required": [
"id",
"name",
"country",
"avatarUrl"
]
},
"DriverProfileSocialSummaryDTO": {
"type": "object",
"properties": {
"friendsCount": {
"type": "number"
}
},
"required": [
"friendsCount"
]
},
"DriverProfileAchievementDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": [
"id",
"title",
"description"
]
},
"GetDriverOutputDTO": { "GetDriverOutputDTO": {
"type": "object", "type": "object",
"properties": { "properties": {
@@ -2488,6 +2358,151 @@
"driverId" "driverId"
] ]
}, },
"DriverProfileTeamMembershipDTO": {
"type": "object",
"properties": {
"teamId": {
"type": "string"
},
"teamName": {
"type": "string"
}
},
"required": [
"teamId",
"teamName"
]
},
"DriverProfileStatsDTO": {
"type": "object",
"properties": {
"totalRaces": {
"type": "number"
},
"wins": {
"type": "number"
},
"podiums": {
"type": "number"
},
"dnfs": {
"type": "number"
}
},
"required": [
"totalRaces",
"wins",
"podiums",
"dnfs"
]
},
"DriverProfileSocialSummaryDTO": {
"type": "object",
"properties": {
"friendsCount": {
"type": "number"
}
},
"required": [
"friendsCount"
]
},
"DriverProfileSocialFriendSummaryDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"country": {
"type": "string"
},
"avatarUrl": {
"type": "string"
}
},
"required": [
"id",
"name",
"country",
"avatarUrl"
]
},
"DriverProfileFinishDistributionDTO": {
"type": "object",
"properties": {
"totalRaces": {
"type": "number"
},
"wins": {
"type": "number"
},
"podiums": {
"type": "number"
},
"topTen": {
"type": "number"
},
"dnfs": {
"type": "number"
},
"other": {
"type": "number"
}
},
"required": [
"totalRaces",
"wins",
"podiums",
"topTen",
"dnfs",
"other"
]
},
"DriverProfileDriverSummaryDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"country": {
"type": "string"
},
"avatarUrl": {
"type": "string"
}
},
"required": [
"id",
"name",
"country",
"avatarUrl"
]
},
"DriverProfileAchievementDTO": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
},
"description": {
"type": "string"
}
},
"required": [
"id",
"title",
"description"
]
},
"DriverLeaderboardItemDTO": { "DriverLeaderboardItemDTO": {
"type": "object", "type": "object",
"properties": { "properties": {

View File

@@ -0,0 +1,257 @@
/**
* Contract Validation Tests for API
*
* These tests validate that the API DTOs and OpenAPI spec are consistent
* and that the generated types will be compatible with the website.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
interface OpenAPISchema {
type?: string;
format?: string;
$ref?: string;
items?: OpenAPISchema;
properties?: Record<string, OpenAPISchema>;
required?: string[];
enum?: string[];
nullable?: boolean;
description?: string;
}
interface OpenAPISpec {
openapi: string;
info: {
title: string;
description: string;
version: string;
};
paths: Record<string, any>;
components: {
schemas: Record<string, OpenAPISchema>;
};
}
describe('API Contract Validation', () => {
const apiRoot = path.join(__dirname, '../../..');
const openapiPath = path.join(apiRoot, 'openapi.json');
const generatedTypesDir = path.join(apiRoot, '../website/lib/types/generated');
describe('OpenAPI Spec Integrity', () => {
it('should have a valid OpenAPI spec file', async () => {
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
expect(specExists).toBe(true);
});
it('should have a valid JSON structure', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
expect(() => JSON.parse(content)).not.toThrow();
});
it('should have required OpenAPI fields', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
expect(spec.info).toBeDefined();
expect(spec.info.title).toBeDefined();
expect(spec.info.version).toBeDefined();
expect(spec.components).toBeDefined();
expect(spec.components.schemas).toBeDefined();
});
it('should have no circular references in schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
const visited = new Set<string>();
const visiting = new Set<string>();
function detectCircular(schemaName: string): boolean {
if (visiting.has(schemaName)) return true;
if (visited.has(schemaName)) return false;
visiting.add(schemaName);
const schema = schemas[schemaName];
if (!schema) {
visiting.delete(schemaName);
visited.add(schemaName);
return false;
}
// Check properties for references
if (schema.properties) {
for (const prop of Object.values(schema.properties)) {
if (prop.$ref) {
const refName = prop.$ref.split('/').pop();
if (refName && detectCircular(refName)) {
return true;
}
}
if (prop.items?.$ref) {
const refName = prop.items.$ref.split('/').pop();
if (refName && detectCircular(refName)) {
return true;
}
}
}
}
visiting.delete(schemaName);
visited.add(schemaName);
return false;
}
for (const schemaName of Object.keys(schemas)) {
expect(detectCircular(schemaName)).toBe(false);
}
});
});
describe('DTO Consistency', () => {
it('should have DTO files for all schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = Object.keys(spec.components.schemas);
const generatedFiles = await fs.readdir(generatedTypesDir);
const generatedDTOs = generatedFiles
.filter(f => f.endsWith('.ts'))
.map(f => f.replace('.ts', ''));
// All schemas should have corresponding generated DTOs
for (const schema of schemas) {
expect(generatedDTOs).toContain(schema);
}
});
it('should have consistent property types between DTOs and schemas', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
for (const [schemaName, schema] of Object.entries(schemas)) {
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
const dtoExists = await fs.access(dtoPath).then(() => true).catch(() => false);
if (!dtoExists) continue;
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
// Check that all required properties are present
if (schema.required) {
for (const requiredProp of schema.required) {
expect(dtoContent).toContain(requiredProp);
}
}
// Check that all properties are present
if (schema.properties) {
for (const propName of Object.keys(schema.properties)) {
expect(dtoContent).toContain(propName);
}
}
}
});
});
describe('Type Generation Integrity', () => {
it('should have valid TypeScript syntax in generated files', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
// Basic TypeScript syntax checks
expect(content).toContain('export interface');
expect(content).toContain('{');
expect(content).toContain('}');
// Should not have syntax errors (basic check)
expect(content).not.toContain('undefined;');
expect(content).not.toContain('any;');
}
});
it('should have proper imports for dependencies', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
for (const importLine of importMatches) {
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
if (match) {
const [, importedType, fromFile] = match;
expect(importedType).toBe(fromFile);
// Check that the imported file exists
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
}
}
}
});
});
describe('Contract Compatibility', () => {
it('should maintain backward compatibility for existing DTOs', async () => {
// This test ensures that when regenerating types, existing properties aren't removed
// unless explicitly intended
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
// Check critical DTOs that are likely used in production
const criticalDTOs = [
'RequestAvatarGenerationInputDTO',
'RequestAvatarGenerationOutputDTO',
'UploadMediaInputDTO',
'UploadMediaOutputDTO',
'RaceDTO',
'DriverDTO'
];
for (const dtoName of criticalDTOs) {
if (spec.components.schemas[dtoName]) {
const dtoPath = path.join(generatedTypesDir, `${dtoName}.ts`);
const exists = await fs.access(dtoPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
}
}
});
it('should handle nullable fields correctly', async () => {
const content = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(content);
const schemas = spec.components.schemas;
for (const [schemaName, schema] of Object.entries(schemas)) {
if (!schema.properties) continue;
for (const [propName, propSchema] of Object.entries(schema.properties)) {
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
if (propSchema.nullable) {
// Nullable properties should be optional in TypeScript
const propRegex = new RegExp(`${propName}\\??:\\s*([\\w\\[\\]<>|]+)\\s*;`);
const match = dtoContent.match(propRegex);
if (match) {
// Should include null in the type or be optional
expect(match[1]).toContain('| null');
}
}
}
}
});
});
});

View File

@@ -0,0 +1,230 @@
/**
* Contract Consumption Tests for Website
*
* These tests validate that the website can properly consume and use
* the generated API types without type errors.
*/
import { describe, it, expect } from 'vitest';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
// Import all generated DTOs to ensure they compile
import type { RequestAvatarGenerationInputDTO } from './generated/RequestAvatarGenerationInputDTO';
import type { RequestAvatarGenerationOutputDTO } from './generated/RequestAvatarGenerationOutputDTO';
import type { UploadMediaInputDTO } from './generated/UploadMediaInputDTO';
import type { UploadMediaOutputDTO } from './generated/UploadMediaOutputDTO';
import type { GetMediaOutputDTO } from './generated/GetMediaOutputDTO';
import type { DeleteMediaOutputDTO } from './generated/DeleteMediaOutputDTO';
import type { GetAvatarOutputDTO } from './generated/GetAvatarOutputDTO';
import type { UpdateAvatarInputDTO } from './generated/UpdateAvatarInputDTO';
import type { UpdateAvatarOutputDTO } from './generated/UpdateAvatarOutputDTO';
import type { RaceDTO } from './generated/RaceDTO';
import type { DriverDTO } from './generated/DriverDTO';
describe('Website Contract Consumption', () => {
const generatedTypesDir = path.join(__dirname, 'generated');
describe('Generated Types Availability', () => {
it('should have generated types directory', async () => {
const exists = await fs.access(generatedTypesDir).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
it('should have expected DTO files', async () => {
const requiredDTOs = [
'RequestAvatarGenerationInputDTO.ts',
'RequestAvatarGenerationOutputDTO.ts',
'UploadMediaInputDTO.ts',
'UploadMediaOutputDTO.ts',
'GetMediaOutputDTO.ts',
'DeleteMediaOutputDTO.ts',
'GetAvatarOutputDTO.ts',
'UpdateAvatarInputDTO.ts',
'UpdateAvatarOutputDTO.ts',
'RaceDTO.ts',
'DriverDTO.ts'
];
const files = await fs.readdir(generatedTypesDir);
const tsFiles = files.filter(f => f.endsWith('.ts'));
for (const required of requiredDTOs) {
expect(tsFiles).toContain(required);
}
});
it('should have no syntax errors in generated files', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
// Basic syntax validation
expect(content).toContain('export interface');
expect(content).toContain('{');
expect(content).toContain('}');
// Should not have common syntax errors
expect(content).not.toMatch(/interface\s+\w+\s*\{\s*\}/); // Empty interfaces
}
});
});
describe('Type Compatibility', () => {
it('should allow creating valid DTO objects', () => {
// Test RequestAvatarGenerationInputDTO
const avatarInput: RequestAvatarGenerationInputDTO = {
userId: 'user-123',
facePhotoData: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
suitColor: 'red'
};
expect(avatarInput.userId).toBe('user-123');
// Test RequestAvatarGenerationOutputDTO (success case)
const avatarOutputSuccess: RequestAvatarGenerationOutputDTO = {
success: true,
requestId: 'req-123',
avatarUrls: ['https://example.com/avatar1.png', 'https://example.com/avatar2.png']
};
expect(avatarOutputSuccess.success).toBe(true);
expect(avatarOutputSuccess.avatarUrls).toHaveLength(2);
// Test RequestAvatarGenerationOutputDTO (failure case)
const avatarOutputFailure: RequestAvatarGenerationOutputDTO = {
success: false,
errorMessage: 'Generation failed'
};
expect(avatarOutputFailure.success).toBe(false);
expect(avatarOutputFailure.errorMessage).toBe('Generation failed');
});
it('should handle optional fields correctly', () => {
// Test DTOs with optional fields
const uploadInput: UploadMediaInputDTO = {
type: 'image',
category: 'avatar'
};
expect(uploadInput.type).toBe('image');
// Test with minimal required fields
const minimalUpload: UploadMediaInputDTO = {
type: 'image'
};
expect(minimalUpload.type).toBe('image');
});
it('should support array types', () => {
const avatarOutput: RequestAvatarGenerationOutputDTO = {
success: true,
requestId: 'req-123',
avatarUrls: ['url1', 'url2', 'url3']
};
expect(Array.isArray(avatarOutput.avatarUrls)).toBe(true);
expect(avatarOutput.avatarUrls?.length).toBeGreaterThan(0);
});
it('should support nested object types', () => {
const race: RaceDTO = {
id: 'race-123',
name: 'Test Race',
leagueId: 'league-456',
trackName: 'Test Track',
startTime: new Date().toISOString(),
status: 'scheduled',
maxDrivers: 20,
registeredDrivers: 5
};
expect(race.id).toBe('race-123');
expect(race.name).toBe('Test Race');
});
});
describe('Service Integration', () => {
it('should work with service layer patterns', () => {
// Simulate a service method that uses DTOs
function processAvatarRequest(input: RequestAvatarGenerationInputDTO): RequestAvatarGenerationOutputDTO {
// Validate input
if (!input.userId || !input.facePhotoData) {
return {
success: false,
errorMessage: 'Missing required fields'
};
}
return {
success: true,
requestId: `req-${Date.now()}`,
avatarUrls: [`https://cdn.example.com/avatars/${input.userId}.png`]
};
}
const result = processAvatarRequest({
userId: 'test-user',
facePhotoData: 'base64data',
suitColor: 'blue'
});
expect(result.success).toBe(true);
expect(result.avatarUrls).toBeDefined();
expect(result.avatarUrls?.length).toBe(1);
});
it('should handle API response parsing', () => {
// Simulate API response handling
const mockApiResponse = {
success: true,
requestId: 'req-789',
avatarUrls: ['https://cdn.example.com/avatar.png']
};
// Type assertion to ensure it matches DTO
const parsedResponse: RequestAvatarGenerationOutputDTO = mockApiResponse;
expect(parsedResponse.success).toBe(true);
expect(parsedResponse.avatarUrls).toHaveLength(1);
});
});
describe('Error Handling', () => {
it('should handle missing optional fields', () => {
// Test that optional fields can be omitted
const minimalOutput: RequestAvatarGenerationOutputDTO = {
success: false,
errorMessage: 'Error occurred'
};
expect(minimalOutput.success).toBe(false);
expect(minimalOutput.errorMessage).toBe('Error occurred');
// avatarUrls and requestId are optional in failure case
});
it('should allow type narrowing based on success flag', () => {
function handleAvatarResponse(response: RequestAvatarGenerationOutputDTO) {
if (response.success) {
// Success case - avatarUrls should be available
expect(response.avatarUrls).toBeDefined();
expect(response.avatarUrls!.length).toBeGreaterThan(0);
} else {
// Failure case - errorMessage should be available
expect(response.errorMessage).toBeDefined();
}
}
handleAvatarResponse({
success: true,
requestId: 'req-1',
avatarUrls: ['url1']
});
handleAvatarResponse({
success: false,
errorMessage: 'Failed'
});
});
});
});

View File

@@ -4,7 +4,7 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface GetRaceDetailParamsDTODTO { export interface AllRacesLeagueFilterDTO {
raceId: string; id: string;
driverId: string; name: string;
} }

View File

@@ -4,7 +4,7 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface IracingAuthRedirectResult { export interface AllRacesStatusFilterDTO {
redirectUrl: string; value: string;
state: string; label: string;
} }

View File

@@ -8,6 +8,6 @@ export interface AvailableLeagueDTO {
id: string; id: string;
name: string; name: string;
game: string; game: string;
drivers: number; drivers: string;
avgViewsPerRace: number; avgViewsPerRace: string;
} }

View File

@@ -5,10 +5,10 @@
*/ */
export interface BillingStatsDTO { export interface BillingStatsDTO {
totalSpent: number; totalSpent: string;
pendingAmount: number; pendingAmount: string;
nextPaymentDate: string; nextPaymentDate: string;
nextPaymentAmount: number; nextPaymentAmount: string;
activeSponsorships: number; activeSponsorships: string;
averageMonthlySpend: number; averageMonthlySpend: string;
} }

View File

@@ -11,6 +11,4 @@ export interface DashboardRaceSummaryDTO {
track: string; track: string;
car: string; car: string;
scheduledAt: string; scheduledAt: string;
status: string;
isMyLeague: boolean;
} }

View File

@@ -5,5 +5,5 @@
*/ */
export interface DeleteMediaOutputDTO { export interface DeleteMediaOutputDTO {
success: boolean; success: string;
} }

View File

@@ -4,7 +4,6 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface LoginParams { export interface DeletePrizeResultDTO {
email: string; success: boolean;
password: string; }
}

View File

@@ -6,7 +6,10 @@
export interface DriverDTO { export interface DriverDTO {
id: string; id: string;
iracingId: string;
name: string; name: string;
country: string; country: string;
position: string;
races: string;
impressions: string;
team: string;
} }

View File

@@ -1,100 +0,0 @@
export interface DriverProfileDriverSummaryDTO {
id: string;
name: string;
country: string;
avatarUrl: string;
iracingId: string | null;
joinedAt: string;
rating: number | null;
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
}
export interface DriverProfileStatsDTO {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface DriverProfileFinishDistributionDTO {
totalRaces: number;
wins: number;
podiums: number;
topTen: number;
dnfs: number;
other: number;
}
export interface DriverProfileTeamMembershipDTO {
teamId: string;
teamName: string;
teamTag: string | null;
role: string;
joinedAt: string;
isCurrent: boolean;
}
export interface DriverProfileSocialFriendSummaryDTO {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface DriverProfileSocialSummaryDTO {
friendsCount: number;
friends: DriverProfileSocialFriendSummaryDTO[];
}
export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface DriverProfileAchievementDTO {
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: DriverProfileAchievementRarity;
earnedAt: string;
}
export interface DriverProfileSocialHandleDTO {
platform: DriverProfileSocialPlatform;
handle: string;
url: string;
}
export interface DriverProfileExtendedProfileDTO {
socialHandles: DriverProfileSocialHandleDTO[];
achievements: DriverProfileAchievementDTO[];
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
timezone: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
export interface DriverProfileDTO {
currentDriver: DriverProfileDriverSummaryDTO | null;
stats: DriverProfileStatsDTO | null;
finishDistribution: DriverProfileFinishDistributionDTO | null;
teamMemberships: DriverProfileTeamMembershipDTO[];
socialSummary: DriverProfileSocialSummaryDTO;
extendedProfile: DriverProfileExtendedProfileDTO | null;
}

View File

@@ -1,22 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface TeamListItemDTO {
id: string;
name: string;
tag: string;
description: string;
memberCount: number;
leagues: string[];
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
}
export interface GetAllTeamsOutputDTO {
teams: TeamListItemDTO[];
totalCount: number;
}

View File

@@ -1,31 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface TeamDTO {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt?: string;
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
}
export interface MembershipDTO {
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
}
export interface GetDriverTeamOutputDTO {
team: TeamDTO;
membership: MembershipDTO;
isOwner: boolean;
canManage: boolean;
}

View File

@@ -9,7 +9,6 @@ export interface GetMediaOutputDTO {
url: string; url: string;
type: string; type: string;
category?: string; category?: string;
/** Format: date-time */
uploadedAt: string; uploadedAt: string;
size?: number; size?: number;
} }

View File

@@ -1,8 +0,0 @@
export interface GetSponsorOutputDTO {
sponsor: {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
};
}

View File

@@ -1,30 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface TeamDTO {
id: string;
name: string;
tag: string;
description: string;
ownerId: string;
leagues: string[];
createdAt?: string;
specialization?: 'endurance' | 'sprint' | 'mixed';
region?: string;
languages?: string[];
}
export interface MembershipDTO {
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
}
export interface GetTeamDetailsOutputDTO {
team: TeamDTO;
membership: MembershipDTO | null;
canManage: boolean;
}

View File

@@ -1,21 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface TeamJoinRequestDTO {
requestId: string;
driverId: string;
driverName: string;
teamId: string;
status: 'pending' | 'approved' | 'rejected';
requestedAt: string;
avatarUrl: string;
}
export interface GetTeamJoinRequestsOutputDTO {
requests: TeamJoinRequestDTO[];
pendingCount: number;
totalCount: number;
}

View File

@@ -1,22 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface TeamMemberDTO {
driverId: string;
driverName: string;
role: 'owner' | 'manager' | 'member';
joinedAt: string;
isActive: boolean;
avatarUrl: string;
}
export interface GetTeamMembersOutputDTO {
members: TeamMemberDTO[];
totalCount: number;
ownerCount: number;
managerCount: number;
memberCount: number;
}

View File

@@ -9,7 +9,7 @@ export interface InvoiceDTO {
invoiceNumber: string; invoiceNumber: string;
date: string; date: string;
dueDate: string; dueDate: string;
amount: number; amount: string;
vatAmount: number; vatAmount: string;
totalAmount: number; totalAmount: string;
} }

View File

@@ -1,19 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
import { ProtestDTO } from './ProtestDTO';
import { RaceDTO } from './RaceDTO';
export interface DriverSummaryDTO {
id: string;
name: string;
}
export interface LeagueAdminProtestsDTO {
protests: ProtestDTO[];
racesById: { [raceId: string]: RaceDTO };
driversById: { [driverId: string]: DriverSummaryDTO };
}

View File

@@ -7,10 +7,4 @@
export interface LeagueWithCapacityDTO { export interface LeagueWithCapacityDTO {
id: string; id: string;
name: string; name: string;
description: string;
ownerId: string;
settings: Record<string, unknown>;
maxDrivers: number;
sessionDuration?: number;
visibility?: string;
} }

View File

@@ -1,11 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface LoginWithIracingCallbackParams {
code: string;
state: string;
returnTo?: string;
}

View File

@@ -4,7 +4,7 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface MemberPaymentDto { export interface MemberPaymentDTO {
id: string; id: string;
feeId: string; feeId: string;
driverId: string; driverId: string;

View File

@@ -4,7 +4,7 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface MembershipFeeDto { export interface MembershipFeeDTO {
id: string; id: string;
leagueId: string; leagueId: string;
} }

View File

@@ -5,10 +5,10 @@
*/ */
export interface NotificationSettingsDTO { export interface NotificationSettingsDTO {
emailNewSponsorships: boolean; emailNewSponsorships: string;
emailWeeklyReport: boolean; emailWeeklyReport: string;
emailRaceAlerts: boolean; emailRaceAlerts: string;
emailPaymentAlerts: boolean; emailPaymentAlerts: string;
emailNewOpportunities: boolean; emailNewOpportunities: string;
emailContractExpiry: boolean; emailContractExpiry: string;
} }

View File

@@ -5,8 +5,8 @@
*/ */
export interface PrivacySettingsDTO { export interface PrivacySettingsDTO {
publicProfile: boolean; publicProfile: string;
showStats: boolean; showStats: string;
showActiveSponsorships: boolean; showActiveSponsorships: string;
allowDirectContact: boolean; allowDirectContact: string;
} }

View File

@@ -4,7 +4,7 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface PrizeDto { export interface PrizeDTO {
id: string; id: string;
leagueId: string; leagueId: string;
seasonId: string; seasonId: string;

View File

@@ -8,4 +8,5 @@ export interface RaceDTO {
id: string; id: string;
name: string; name: string;
date: string; date: string;
views: string;
} }

View File

@@ -1,12 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
import { RacePenaltyDTO } from './RacePenaltyDTO';
export interface RacePenaltiesDTO {
penalties: RacePenaltyDTO[];
driverMap: Record<string, string>;
}

View File

@@ -1,5 +0,0 @@
export interface RecordEngagementInputDTO {
eventType: string;
userId?: string;
metadata?: Record<string, unknown>;
}

View File

@@ -1,4 +0,0 @@
export interface RecordPageViewInputDTO {
path: string;
userId?: string;
}

View File

@@ -1,14 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface SignupParams {
email: string;
password: string;
displayName: string;
iracingCustomerId?: string;
primaryDriverId?: string;
avatarUrl?: string;
}

View File

@@ -5,7 +5,7 @@
*/ */
export interface SponsorDashboardInvestmentDTO { export interface SponsorDashboardInvestmentDTO {
activeSponsorships: number; activeSponsorships: string;
totalInvestment: number; totalInvestment: string;
costPerThousandViews: number; costPerThousandViews: string;
} }

View File

@@ -5,12 +5,12 @@
*/ */
export interface SponsorDashboardMetricsDTO { export interface SponsorDashboardMetricsDTO {
impressions: number; impressions: string;
impressionsChange: number; impressionsChange: string;
uniqueViewers: number; uniqueViewers: string;
viewersChange: number; viewersChange: string;
races: number; races: string;
drivers: number; drivers: string;
exposure: number; exposure: string;
exposureChange: number; exposureChange: string;
} }

View File

@@ -7,6 +7,6 @@
export interface SponsorshipPricingItemDTO { export interface SponsorshipPricingItemDTO {
id: string; id: string;
level: string; level: string;
price: number; price: string;
currency: string; currency: string;
} }

View File

@@ -4,7 +4,7 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface TransactionDto { export interface TransactionDTO {
id: string; id: string;
walletId: string; walletId: string;
} }

View File

@@ -5,5 +5,5 @@
*/ */
export interface UpdateAvatarOutputDTO { export interface UpdateAvatarOutputDTO {
success: boolean; success: string;
} }

View File

@@ -1,11 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface UpdateTeamInputDTO {
name?: string;
tag?: string;
description?: string;
}

View File

@@ -5,5 +5,5 @@
*/ */
export interface UploadMediaOutputDTO { export interface UploadMediaOutputDTO {
success: boolean; success: string;
} }

View File

@@ -4,7 +4,7 @@
* Do not edit manually - regenerate using: npm run api:sync-types * Do not edit manually - regenerate using: npm run api:sync-types
*/ */
export interface WalletDto { export interface WalletDTO {
id: string; id: string;
leagueId: string; leagueId: string;
balance: number; balance: number;

271
docs/CONTRACT_TESTING.md Normal file
View File

@@ -0,0 +1,271 @@
# Contract Testing Documentation
## Overview
This document describes the contract testing strategy for ensuring compatibility between the API (`apps/api`) and the website (`apps/website`) in the GridPilot monorepo.
## Architecture
The contract testing system consists of several layers:
### 1. API Contract Validation
- **Location**: `apps/api/src/shared/testing/contractValidation.test.ts`
- **Purpose**: Validates that API DTOs are consistent and generate valid OpenAPI specs
- **Tests**:
- OpenAPI spec integrity
- DTO consistency
- Type generation integrity
- Contract compatibility
### 2. Type Generation
- **Scripts**:
- `npm run api:generate-spec` - Generates OpenAPI spec from DTOs
- `npm run api:generate-types` - Generates TypeScript types for website
- `npm run api:sync-types` - Runs both in sequence
- **Output**: `apps/website/lib/types/generated/`
### 3. Website Contract Consumption
- **Location**: `apps/website/lib/types/contractConsumption.test.ts`
- **Purpose**: Validates that website can properly consume generated types
- **Tests**:
- Generated types availability
- Type compatibility
- Service integration
- Error handling
### 4. Compatibility Verification
- **Location**: `scripts/contract-compatibility.ts`
- **Purpose**: Detects breaking changes between type versions
- **Features**:
- Backup current types
- Generate new types
- Compare and detect changes
- Report breaking changes
## Workflow
### Local Development
```bash
# Run full contract testing suite
npm run test:contracts
# Or run individual steps:
npm run test:api:contracts # Validate API contracts
npm run api:generate-spec # Generate OpenAPI spec
npm run api:generate-types # Generate types
npm run test:contract:compatibility # Check compatibility
npm run website:type-check # Verify website types
```
### CI/CD Pipeline
The GitHub Actions workflow (`.github/workflows/contract-testing.yml`) automatically:
1. **On Pull Requests**:
- Runs all contract tests
- Validates API contracts
- Generates types
- Checks for breaking changes
- Verifies website type checking
- Uploads generated types as artifacts
- Comments results on PR
2. **On Main Branch Push**:
- Runs all tests
- Generates and commits updated types
## Breaking Change Detection
The system detects several types of changes:
### Breaking Changes (❌ Fails CI)
- **Property Removal**: Required properties removed from DTOs
- **Type Changes**: Property types changed (e.g., `string``number`)
- **Required Field Addition**: New required fields added to existing DTOs
### Non-Breaking Changes (⚠️ Warning)
- **Property Addition**: Optional properties added
- **New DTOs**: New DTO types added
- **Documentation Changes**: Description updates
### Removed Changes (❌ Fails CI)
- **DTO Removal**: Entire DTO types removed
- **Property Removal**: Optional properties removed
## Generated Types Structure
The generated types follow this pattern:
```typescript
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
import type { RelatedDTO } from './RelatedDTO';
export interface MyDTO {
id: string;
name: string;
optionalField?: string;
related?: RelatedDTO;
}
```
## Testing Strategy
### API Layer Tests
```typescript
// apps/api/src/shared/testing/contractValidation.test.ts
describe('API Contract Validation', () => {
it('should have valid OpenAPI spec', async () => {
// Validates spec structure
});
it('should have no circular references', async () => {
// Prevents infinite loops
});
it('should maintain backward compatibility', async () => {
// Ensures critical DTOs exist
});
});
```
### Website Layer Tests
```typescript
// apps/website/lib/types/contractConsumption.test.ts
describe('Website Contract Consumption', () => {
it('should allow creating valid DTO objects', () => {
// Type-safe object creation
});
it('should work with service layer patterns', () => {
// Integration with services
});
it('should handle error cases', () => {
// Error handling patterns
});
});
```
## Integration Points
### Service Layer Integration
Services in the website can import and use generated types:
```typescript
import type { RequestAvatarGenerationInputDTO } from '@/lib/types/generated/RequestAvatarGenerationInputDTO';
import type { RequestAvatarGenerationOutputDTO } from '@/lib/types/generated/RequestAvatarGenerationOutputDTO';
class AvatarService {
async generateAvatar(input: RequestAvatarGenerationInputDTO): Promise<RequestAvatarGenerationOutputDTO> {
const response = await apiClient.post('/avatar/generate', input);
return response.data; // Type-safe
}
}
```
### View Model Integration
View models can transform DTOs for UI consumption:
```typescript
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
class RaceViewModel {
constructor(private dto: RaceDTO) {}
get displayDate(): string {
return new Date(this.dto.startTime).toLocaleDateString();
}
}
```
## Troubleshooting
### Common Issues
1. **Type Generation Fails**
- Check that DTOs have proper `@ApiProperty` decorators
- Verify OpenAPI spec is valid JSON
- Ensure all referenced types exist
2. **Breaking Changes Detected**
- Review the change report
- Update website code to handle new types
- Consider versioning strategy for major changes
3. **Website Type Errors**
- Run `npm run api:sync-types` to regenerate
- Check import paths in website code
- Verify TypeScript configuration
### Debugging Steps
1. **Check OpenAPI Spec**:
```bash
npm run api:generate-spec
cat apps/api/openapi.json | jq '.components.schemas'
```
2. **Compare Generated Types**:
```bash
# Backup current types
cp -r apps/website/lib/types/generated /tmp/types-backup
# Regenerate
npm run api:generate-types
# Compare
diff -r /tmp/types-backup apps/website/lib/types/generated
```
3. **Run Individual Tests**:
```bash
# API tests
npm run test:api:contracts
# Website type checking
npm run website:type-check
```
## Best Practices
### For API Developers
1. **Always use `@ApiProperty` decorators** with proper types
2. **Mark optional fields explicitly** with `required: false`
3. **Use proper TypeScript types** (avoid `any`)
4. **Add descriptions** to DTO properties
5. **Test DTOs** with contract validation tests
### For Website Developers
1. **Import from generated types**, not manual types
2. **Use type assertions** when consuming API responses
3. **Handle optional fields** properly
4. **Run contract tests** before committing type changes
5. **Update view models** when DTOs change
### For CI/CD
1. **Run contract tests on every PR**
2. **Block merges on breaking changes**
3. **Generate types on main branch pushes**
4. **Upload artifacts for debugging**
5. **Comment results on PRs**
## Future Enhancements
- [ ] Add API versioning support
- [ ] Generate client SDKs from OpenAPI
- [ ] Add contract testing for WebSocket events
- [ ] Implement schema registry
- [ ] Add automated migration scripts for breaking changes

View File

@@ -0,0 +1,168 @@
# Contract Testing Quick Start Guide
## 🚀 Quick Setup
### 1. Run the Full Contract Test Suite
```bash
npm run test:contracts
```
This single command will:
- ✅ Validate API contracts
- ✅ Generate OpenAPI spec
- ✅ Generate TypeScript types
- ✅ Check for breaking changes
- ✅ Verify website type compatibility
### 2. Individual Commands
```bash
# Validate API contracts only
npm run test:api:contracts
# Generate types (after making DTO changes)
npm run api:sync-types
# Check compatibility (detect breaking changes)
npm run test:contract:compatibility
# Verify website can consume types
npm run website:type-check
```
## 📁 What Gets Created
### Generated Types
- **Location**: `apps/website/lib/types/generated/`
- **Files**: One `.ts` file per DTO (e.g., `RaceDTO.ts`, `DriverDTO.ts`)
- **Usage**: Import directly in website code
### Test Files
- **API Tests**: `apps/api/src/shared/testing/contractValidation.test.ts`
- **Website Tests**: `apps/website/lib/types/contractConsumption.test.ts`
### CI/CD
- **Workflow**: `.github/workflows/contract-testing.yml`
- **Triggers**: Pull requests and main branch pushes
## 🎯 Common Workflows
### Making API Changes
1. **Update DTO in API**:
```typescript
// apps/api/src/domain/race/dtos/RaceDTO.ts
export class RaceDTO {
@ApiProperty()
id: string;
@ApiProperty()
name: string;
@ApiProperty({ required: false })
description?: string; // New optional field
}
```
2. **Run contract tests**:
```bash
npm run test:contracts
```
3. **If tests pass**, commit your changes. The CI will regenerate types automatically.
### Updating Website Code
1. **Import generated types**:
```typescript
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
function RaceComponent({ race }: { race: RaceDTO }) {
return <div>{race.name}</div>;
}
```
2. **TypeScript will catch errors** if contracts change.
### Detecting Breaking Changes
```bash
# This will show you exactly what changed
npm run test:contract:compatibility
```
Output example:
```
🚨 BREAKING CHANGES DETECTED:
• RaceDTO.status: Property status was removed (BREAKING)
❌ REMOVED:
• OldDTO: DTO OldDTO was removed
ADDED:
• NewDTO: New DTO NewDTO was added
```
## 🔧 Troubleshooting
### "Cannot find module" errors
```bash
# Regenerate types
npm run api:sync-types
```
### Type generation fails
1. Check DTOs have `@ApiProperty` decorators
2. Verify OpenAPI spec is valid: `cat apps/api/openapi.json`
3. Run individual steps:
```bash
npm run api:generate-spec
npm run api:generate-types
```
### CI fails on breaking changes
- Review what changed
- Update website code to handle new types
- Or revert the breaking change if unintended
## 📋 Checklist Before Committing
- [ ] Run `npm run test:contracts` locally
- [ ] All tests pass
- [ ] No breaking changes (or they're intentional)
- [ ] Website code updated to handle new types
- [ ] Generated types are committed (if needed)
## 🎓 Key Concepts
### What is a "Contract"?
A contract is the agreement between API and website about what data looks like:
- DTO definitions
- Property types
- Required vs optional fields
### What are "Breaking Changes"?
Changes that would break the website:
- Removing required fields
- Changing field types
- Adding required fields to existing DTOs
### Why Generate Types?
- **Type Safety**: Catch errors at compile time
- **Auto-completion**: Better IDE experience
- **Documentation**: Types serve as living documentation
- **Consistency**: Single source of truth
## 🚨 Important Notes
1. **Never manually edit generated files** - they're auto-generated
2. **Always run tests before committing** - prevents breaking changes
3. **The CI will regenerate types** - but local verification is faster
4. **Breaking changes need review** - consider versioning strategy
## 📚 More Resources
- Full documentation: `docs/CONTRACT_TESTING.md`
- API examples: `apps/api/src/shared/testing/contractValidation.test.ts`
- Website examples: `apps/website/lib/types/contractConsumption.test.ts`
- CI/CD workflow: `.github/workflows/contract-testing.yml`

View File

@@ -61,6 +61,11 @@
"api:sync-types": "npm run api:generate-spec && npm run api:generate-types", "api:sync-types": "npm run api:generate-spec && npm run api:generate-types",
"api:test": "vitest run --config vitest.api.config.ts", "api:test": "vitest run --config vitest.api.config.ts",
"api:coverage": "vitest run --config vitest.api.config.ts --coverage", "api:coverage": "vitest run --config vitest.api.config.ts --coverage",
"test:api:contracts": "vitest run --config vitest.api.config.ts apps/api/src/shared/testing/contractValidation.test.ts",
"test:contract:compatibility": "tsx scripts/contract-compatibility.ts",
"test:contracts": "tsx scripts/run-contract-tests.ts",
"test:website:types": "vitest run --config vitest.website.config.ts apps/website/lib/types/contractConsumption.test.ts",
"test:type-generation": "vitest run --config vitest.scripts.config.ts scripts/test/type-generation.test.ts",
"build": "echo 'Build all packages placeholder - to be configured'", "build": "echo 'Build all packages placeholder - to be configured'",
"chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug", "chrome:debug": "open -a 'Google Chrome' --args --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-debug",
"companion:build": "npm run build --workspace=@gridpilot/companion", "companion:build": "npm run build --workspace=@gridpilot/companion",

View File

@@ -0,0 +1,288 @@
#!/usr/bin/env tsx
/**
* Contract Compatibility Verification Script
*
* This script verifies that the API contracts are compatible with the website
* by running the type generation and then checking for breaking changes.
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
interface OpenAPISchema {
type?: string;
format?: string;
$ref?: string;
items?: OpenAPISchema;
properties?: Record<string, OpenAPISchema>;
required?: string[];
enum?: string[];
nullable?: boolean;
description?: string;
}
interface OpenAPISpec {
openapi: string;
info: {
title: string;
description: string;
version: string;
};
paths: Record<string, any>;
components: {
schemas: Record<string, OpenAPISchema>;
};
}
interface ContractChange {
type: 'added' | 'removed' | 'modified' | 'breaking';
dto: string;
property?: string;
details: string;
}
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
dim: '\x1b[2m'
};
async function runContractCompatibilityCheck(): Promise<void> {
console.log(`${colors.blue}🔍 Running Contract Compatibility Check...${colors.reset}\n`);
const apiRoot = path.join(__dirname, '../apps/api');
const websiteRoot = path.join(__dirname, '../apps/website');
const openapiPath = path.join(apiRoot, 'openapi.json');
const generatedTypesDir = path.join(websiteRoot, 'lib/types/generated');
const backupDir = path.join(__dirname, '../.backup/contract-types');
// Step 1: Generate current OpenAPI spec
console.log(`${colors.yellow}1. Generating OpenAPI spec...${colors.reset}`);
try {
execSync('npm run api:generate-spec', { stdio: 'inherit' });
} catch (error) {
console.error(`${colors.red}❌ Failed to generate OpenAPI spec${colors.reset}`);
process.exit(1);
}
// Step 2: Backup current generated types
console.log(`${colors.yellow}2. Backing up current generated types...${colors.reset}`);
await fs.mkdir(backupDir, { recursive: true });
try {
const files = await fs.readdir(generatedTypesDir);
for (const file of files) {
if (file.endsWith('.ts')) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
await fs.writeFile(path.join(backupDir, file), content);
}
}
} catch (error) {
console.log(`${colors.yellow}⚠️ No existing types to backup${colors.reset}`);
}
// Step 3: Generate new types
console.log(`${colors.yellow}3. Generating new types...${colors.reset}`);
try {
execSync('npm run api:generate-types', { stdio: 'inherit' });
} catch (error) {
console.error(`${colors.red}❌ Failed to generate types${colors.reset}`);
process.exit(1);
}
// Step 4: Compare and detect changes
console.log(`${colors.yellow}4. Analyzing contract changes...${colors.reset}`);
const changes = await detectContractChanges(backupDir, generatedTypesDir, openapiPath);
// Step 5: Report results
console.log(`${colors.yellow}5. Reporting changes...${colors.reset}\n`);
await reportChanges(changes);
// Step 6: Clean up backup
console.log(`${colors.yellow}6. Cleaning up...${colors.reset}`);
await fs.rm(backupDir, { recursive: true, force: true });
console.log(`\n${colors.green}✅ Contract compatibility check completed!${colors.reset}`);
}
async function detectContractChanges(
backupDir: string,
currentDir: string,
openapiPath: string
): Promise<ContractChange[]> {
const changes: ContractChange[] = [];
// Read OpenAPI spec
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec: OpenAPISpec = JSON.parse(specContent);
const schemas = spec.components.schemas;
// Get current and backup files
const currentFiles = await fs.readdir(currentDir);
const backupFiles = await fs.readdir(backupDir);
const currentDTOs = currentFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', ''));
const backupDTOs = backupFiles.filter(f => f.endsWith('.ts')).map(f => f.replace('.ts', ''));
// Check for removed DTOs
for (const backupDTO of backupDTOs) {
if (!currentDTOs.includes(backupDTO)) {
changes.push({
type: 'removed',
dto: backupDTO,
details: `DTO ${backupDTO} was removed`
});
}
}
// Check for added and modified DTOs
for (const currentDTO of currentDTOs) {
const currentPath = path.join(currentDir, `${currentDTO}.ts`);
const backupPath = path.join(backupDir, `${currentDTO}.ts`);
const currentContent = await fs.readFile(currentPath, 'utf-8');
const backupExists = backupDTOs.includes(currentDTO);
if (!backupExists) {
changes.push({
type: 'added',
dto: currentDTO,
details: `New DTO ${currentDTO} was added`
});
} else {
const backupContent = await fs.readFile(backupPath, 'utf-8');
// Check for property changes
const schema = schemas[currentDTO];
if (schema && schema.properties) {
const currentProps = extractProperties(currentContent);
const backupProps = extractProperties(backupContent);
// Check for removed properties
for (const [propName, backupProp] of Object.entries(backupProps)) {
if (!currentProps[propName]) {
const isRequired = schema.required?.includes(propName);
changes.push({
type: isRequired ? 'breaking' : 'modified',
dto: currentDTO,
property: propName,
details: `Property ${propName} was removed${isRequired ? ' (BREAKING)' : ''}`
});
}
}
// Check for added properties
for (const [propName, currentProp] of Object.entries(currentProps)) {
if (!backupProps[propName]) {
const isRequired = schema.required?.includes(propName);
changes.push({
type: isRequired ? 'breaking' : 'added',
dto: currentDTO,
property: propName,
details: `Property ${propName} was added${isRequired ? ' (potentially breaking)' : ''}`
});
}
}
// Check for type changes
for (const [propName, currentProp] of Object.entries(currentProps)) {
if (backupProps[propName]) {
const backupProp = backupProps[propName];
if (currentProp.type !== backupProp.type) {
changes.push({
type: 'breaking',
dto: currentDTO,
property: propName,
details: `Property ${propName} type changed from ${backupProp.type} to ${currentProp.type} (BREAKING)`
});
}
}
}
}
}
}
return changes;
}
function extractProperties(content: string): Record<string, { type: string; optional: boolean }> {
const properties: Record<string, { type: string; optional: boolean }> = {};
// Match property lines: propertyName?: type;
const propertyRegex = /^\s*(\w+)(\??):\s*([^;]+);/gm;
let match;
while ((match = propertyRegex.exec(content)) !== null) {
const [, name, optional, type] = match;
properties[name] = {
type: type.trim(),
optional: !!optional
};
}
return properties;
}
async function reportChanges(changes: ContractChange[]): Promise<void> {
if (changes.length === 0) {
console.log(`${colors.green}✅ No changes detected - contracts are stable${colors.reset}`);
return;
}
const breaking = changes.filter(c => c.type === 'breaking');
const modified = changes.filter(c => c.type === 'modified');
const added = changes.filter(c => c.type === 'added');
const removed = changes.filter(c => c.type === 'removed');
if (breaking.length > 0) {
console.log(`${colors.red}🚨 BREAKING CHANGES DETECTED:${colors.reset}`);
breaking.forEach(change => {
console.log(` ${colors.red}${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`);
});
console.log('');
}
if (removed.length > 0) {
console.log(`${colors.red}❌ REMOVED:${colors.reset}`);
removed.forEach(change => {
console.log(` ${colors.red}${change.dto}: ${change.details}${colors.reset}`);
});
console.log('');
}
if (modified.length > 0) {
console.log(`${colors.yellow}⚠️ MODIFIED:${colors.reset}`);
modified.forEach(change => {
console.log(` ${colors.yellow}${change.dto}.${change.property}: ${change.details}${colors.reset}`);
});
console.log('');
}
if (added.length > 0) {
console.log(`${colors.green} ADDED:${colors.reset}`);
added.forEach(change => {
console.log(` ${colors.green}${change.dto}${change.property ? '.' + change.property : ''}: ${change.details}${colors.reset}`);
});
console.log('');
}
const totalChanges = changes.length;
console.log(`${colors.blue}📊 Summary: ${totalChanges} total changes (${breaking.length} breaking, ${removed.length} removed, ${modified.length} modified, ${added.length} added)${colors.reset}`);
if (breaking.length > 0) {
console.log(`\n${colors.red}❌ Contract compatibility check FAILED due to breaking changes${colors.reset}`);
process.exit(1);
}
}
// Run if called directly
if (require.main === module) {
runContractCompatibilityCheck().catch(error => {
console.error(`${colors.red}❌ Error running contract compatibility check:${colors.reset}`, error);
process.exit(1);
});
}

View File

@@ -64,18 +64,42 @@ async function generateIndividualDtoFiles(openapiPath: string, outputDir: string
const schemaNames = Object.keys(schemas); const schemaNames = Object.keys(schemas);
// Get existing files in output directory
let existingFiles: string[] = [];
try {
existingFiles = await fs.readdir(outputDir);
existingFiles = existingFiles.filter(f => f.endsWith('.ts'));
} catch (error) {
// Directory doesn't exist yet
}
// Generate individual files for each schema // Generate individual files for each schema
const generatedFileNames: string[] = [];
for (const schemaName of schemaNames) { for (const schemaName of schemaNames) {
const schema = schemas[schemaName]; const schema = schemas[schemaName];
// File name should match the schema name exactly
const fileName = `${schemaName}.ts`; const fileName = `${schemaName}.ts`;
const filePath = path.join(outputDir, fileName); const filePath = path.join(outputDir, fileName);
const fileContent = generateDtoFileContent(schemaName, schema, schemas); const fileContent = generateDtoFileContent(schemaName, schema, schemas);
await fs.writeFile(filePath, fileContent); await fs.writeFile(filePath, fileContent);
console.log(` ✅ Generated ${fileName}`); console.log(` ✅ Generated ${fileName}`);
generatedFileNames.push(fileName);
}
// Clean up files that are no longer in the spec
const filesToRemove = existingFiles.filter(f => !generatedFileNames.includes(f));
for (const file of filesToRemove) {
const filePath = path.join(outputDir, file);
await fs.unlink(filePath);
console.log(` 🗑️ Removed obsolete file: ${file}`);
} }
console.log(`✅ Generated ${schemaNames.length} individual DTO files at: ${outputDir}`); console.log(`✅ Generated ${schemaNames.length} individual DTO files at: ${outputDir}`);
if (filesToRemove.length > 0) {
console.log(`🧹 Cleaned up ${filesToRemove.length} obsolete files`);
}
} }
function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Record<string, any>): string { function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Record<string, any>): string {
@@ -101,7 +125,7 @@ function generateDtoFileContent(schemaName: string, schema: any, allSchemas: Rec
content += '\n'; content += '\n';
} }
// Generate interface // Generate interface - use the schema name directly
content += `export interface ${schemaName} {\n`; content += `export interface ${schemaName} {\n`;
const properties = schema.properties || {}; const properties = schema.properties || {};

View File

@@ -86,12 +86,22 @@ async function processDTOFile(filePath: string, schemas: Record<string, OpenAPIS
const className = classMatch[1]; const className = classMatch[1];
const classBody = classMatch[2]; const classBody = classMatch[2];
console.log(` 📝 Processing ${className}`); // Normalize class name to always use DTO suffix (not Dto)
const normalizedName = className.endsWith('Dto') ?
className.slice(0, -3) + 'DTO' : className;
console.log(` 📝 Processing ${className} -> ${normalizedName}`);
// Check for conflicts
if (schemas[normalizedName]) {
console.warn(` ⚠️ Conflict: ${normalizedName} already exists. Skipping duplicate from ${filePath}`);
continue;
}
const schema = extractSchemaFromClassBody(classBody, content); const schema = extractSchemaFromClassBody(classBody, content);
if (schema && Object.keys(schema.properties || {}).length > 0) { if (schema && Object.keys(schema.properties || {}).length > 0) {
schemas[className] = schema; schemas[normalizedName] = schema;
console.log(` ✅ Added ${className} with ${Object.keys(schema.properties || {}).length} properties`); console.log(` ✅ Added ${normalizedName} with ${Object.keys(schema.properties || {}).length} properties`);
} }
} }
} }

View File

@@ -0,0 +1,85 @@
#!/usr/bin/env tsx
/**
* Contract Testing Integration Script
*
* This script runs all contract tests in the correct order:
* 1. API contract validation
* 2. Type generation
* 3. Website contract consumption tests
* 4. Compatibility verification
*/
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
const colors = {
reset: '\x1b[0m',
green: '\x1b[32m',
red: '\x1b[31m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
cyan: '\x1b[36m',
dim: '\x1b[2m'
};
async function runContractTests(): Promise<void> {
console.log(`${colors.cyan}🚀 Starting Contract Testing Suite${colors.reset}\n`);
const steps = [
{
name: 'API Contract Validation',
command: 'npm run test:api:contracts',
description: 'Validate API DTOs and OpenAPI spec integrity'
},
{
name: 'Generate OpenAPI Spec',
command: 'npm run api:generate-spec',
description: 'Generate OpenAPI specification from DTOs'
},
{
name: 'Generate TypeScript Types',
command: 'npm run api:generate-types',
description: 'Generate TypeScript types for website'
},
{
name: 'Contract Compatibility Check',
command: 'npm run test:contract:compatibility',
description: 'Check for breaking changes in contracts'
},
{
name: 'Website Type Checking',
command: 'npm run website:type-check',
description: 'Verify website can consume generated types'
}
];
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
console.log(`${colors.yellow}${i + 1}/${steps.length} ${step.name}${colors.reset}`);
console.log(`${colors.dim} ${step.description}${colors.reset}`);
try {
execSync(step.command, {
stdio: 'inherit',
env: { ...process.env, FORCE_COLOR: 'true' }
});
console.log(`${colors.green}${step.name} completed${colors.reset}\n`);
} catch (error) {
console.log(`${colors.red}${step.name} failed${colors.reset}\n`);
console.log(`${colors.red}Contract testing suite failed at step: ${step.name}${colors.reset}`);
process.exit(1);
}
}
console.log(`${colors.green}🎉 All contract tests passed!${colors.reset}`);
console.log(`${colors.green}✅ Contracts are compatible and validated${colors.reset}`);
}
// Run if called directly
if (require.main === module) {
runContractTests().catch(error => {
console.error(`${colors.red}❌ Contract testing suite failed:${colors.reset}`, error);
process.exit(1);
});
}

View File

@@ -0,0 +1,258 @@
/**
* Test suite for type generation script
* Validates that the type generation process works correctly
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'child_process';
import * as fs from 'fs/promises';
import * as path from 'path';
import { glob } from 'glob';
describe('Type Generation Script', () => {
const apiRoot = path.join(__dirname, '../../apps/api');
const websiteRoot = path.join(__dirname, '../../apps/website');
const openapiPath = path.join(apiRoot, 'openapi.json');
const generatedTypesDir = path.join(websiteRoot, 'lib/types/generated');
const backupDir = path.join(__dirname, '../../.backup/type-gen-test');
beforeAll(async () => {
// Backup existing generated types
await fs.mkdir(backupDir, { recursive: true });
try {
const files = await fs.readdir(generatedTypesDir);
for (const file of files) {
if (file.endsWith('.ts')) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
await fs.writeFile(path.join(backupDir, file), content);
}
}
} catch (error) {
// No existing files to backup
}
});
afterAll(async () => {
// Restore backup
try {
const backupFiles = await fs.readdir(backupDir);
for (const file of backupFiles) {
if (file.endsWith('.ts')) {
const content = await fs.readFile(path.join(backupDir, file), 'utf-8');
await fs.writeFile(path.join(generatedTypesDir, file), content);
}
}
} catch (error) {
// No backup to restore
}
// Clean up backup
await fs.rm(backupDir, { recursive: true, force: true });
});
describe('OpenAPI Spec Generation', () => {
it('should generate valid OpenAPI spec', async () => {
// Run the spec generation
execSync('npm run api:generate-spec', {
cwd: path.join(__dirname, '../..'),
stdio: 'pipe'
});
// Check that spec exists and is valid JSON
const specContent = await fs.readFile(openapiPath, 'utf-8');
expect(() => JSON.parse(specContent)).not.toThrow();
const spec = JSON.parse(specContent);
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
expect(spec.components).toBeDefined();
expect(spec.components.schemas).toBeDefined();
});
it('should not have duplicate schema names with different casing', async () => {
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec = JSON.parse(specContent);
const schemas = Object.keys(spec.components.schemas);
// Check for duplicates with different casing
const lowerCaseMap = new Map<string, string[]>();
schemas.forEach(schema => {
const lower = schema.toLowerCase();
if (!lowerCaseMap.has(lower)) {
lowerCaseMap.set(lower, []);
}
lowerCaseMap.get(lower)!.push(schema);
});
const duplicates = Array.from(lowerCaseMap.entries())
.filter(([_, names]) => names.length > 1);
expect(duplicates.length).toBe(0);
});
it('should generate spec with consistent naming', async () => {
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec = JSON.parse(specContent);
const schemas = Object.keys(spec.components.schemas);
// All schemas should follow DTO naming convention
const invalidNames = schemas.filter(name => !name.endsWith('DTO') && !name.endsWith('Dto'));
expect(invalidNames.length).toBe(0);
});
});
describe('Type Generation', () => {
it('should generate TypeScript files for all schemas', async () => {
// Generate types
execSync('npm run api:generate-types', {
cwd: path.join(__dirname, '../..'),
stdio: 'pipe'
});
// Read generated files
const generatedFiles = await fs.readdir(generatedTypesDir);
const generatedDTOs = generatedFiles
.filter(f => f.endsWith('.ts'))
.map(f => f.replace('.ts', ''));
// Read OpenAPI spec
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec = JSON.parse(specContent);
const schemas = Object.keys(spec.components.schemas);
// Most schemas should have corresponding generated files
// (allowing for some duplicates/conflicts that are intentionally skipped)
const missingFiles = schemas.filter(schema => !generatedDTOs.includes(schema));
// Should have at least 95% coverage
const coverage = (schemas.length - missingFiles.length) / schemas.length;
expect(coverage).toBeGreaterThan(0.95);
});
it('should generate files with correct interface names', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
const interfaceName = file.replace('.ts', '');
// File should contain an interface (name might be normalized)
expect(content).toMatch(/export interface \w+\s*{/);
// Should not have duplicate interface names in the same file
const interfaceMatches = content.match(/export interface (\w+)/g);
expect(interfaceMatches?.length).toBe(1);
}
});
it('should generate valid TypeScript syntax', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
// Basic syntax checks
expect(content).toContain('export interface');
expect(content).toContain('{');
expect(content).toContain('}');
expect(content).toContain('Auto-generated DTO');
// Should not have syntax errors
expect(content).not.toMatch(/interface\s+\w+\s*\{\s*\}/); // Empty interfaces
expect(content).not.toContain('undefined;');
expect(content).not.toContain('any;');
}
});
it('should handle dependencies correctly', async () => {
const files = await fs.readdir(generatedTypesDir);
const dtos = files.filter(f => f.endsWith('.ts'));
for (const file of dtos) {
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
for (const importLine of importMatches) {
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
if (match) {
const [, importedType, fromFile] = match;
// Import type should match the file name
expect(importedType).toBe(fromFile);
// The imported file should exist
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
}
}
}
});
it('should maintain consistent naming between OpenAPI and generated files', async () => {
const specContent = await fs.readFile(openapiPath, 'utf-8');
const spec = JSON.parse(specContent);
const schemas = Object.keys(spec.components.schemas);
const generatedFiles = await fs.readdir(generatedTypesDir);
const generatedDTOs = generatedFiles
.filter(f => f.endsWith('.ts'))
.map(f => f.replace('.ts', ''));
// Check that most schemas have matching files (allowing for some edge cases)
const missingFiles = schemas.filter(schema => !generatedDTOs.includes(schema));
const coverage = (schemas.length - missingFiles.length) / schemas.length;
expect(coverage).toBeGreaterThan(0.95);
// Check that most files have matching schemas (allowing for normalization)
const extraFiles = generatedDTOs.filter(dto => !schemas.includes(dto));
const extraCoverage = (generatedDTOs.length - extraFiles.length) / generatedDTOs.length;
expect(extraCoverage).toBeGreaterThan(0.95);
});
});
describe('Integration', () => {
it('should generate types that can be imported without errors', async () => {
// Generate types first
execSync('npm run api:generate-types', {
cwd: path.join(__dirname, '../..'),
stdio: 'pipe'
});
// Try to import a few key DTOs
const testDTOs = [
'RaceDTO',
'DriverDTO',
'RequestAvatarGenerationInputDTO',
'RequestAvatarGenerationOutputDTO'
];
for (const dto of testDTOs) {
const filePath = path.join(generatedTypesDir, `${dto}.ts`);
const exists = await fs.access(filePath).then(() => true).catch(() => false);
if (exists) {
const content = await fs.readFile(filePath, 'utf-8');
// Should be valid TypeScript that can be parsed
expect(content).toContain(`export interface ${dto}`);
}
}
});
it('should handle the full generation workflow', async () => {
// Run complete workflow
execSync('npm run api:sync-types', {
cwd: path.join(__dirname, '../..'),
stdio: 'pipe'
});
// Verify both spec and types were generated
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
expect(specExists).toBe(true);
const files = await fs.readdir(generatedTypesDir);
const tsFiles = files.filter(f => f.endsWith('.ts'));
expect(tsFiles.length).toBeGreaterThan(0);
});
});
});

18
vitest.scripts.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
test: {
globals: true,
watch: false,
environment: 'node',
include: ['scripts/test/**/*.test.ts'],
exclude: ['node_modules/**', 'dist/**'],
},
resolve: {
alias: {
'@': resolve(__dirname, './apps/website'),
'@core': resolve(__dirname, './core'),
},
},
});

23
vitest.website.config.ts Normal file
View File

@@ -0,0 +1,23 @@
import { defineConfig } from 'vitest/config';
import { resolve } from 'node:path';
export default defineConfig({
test: {
globals: true,
watch: false,
environment: 'node',
include: ['apps/website/lib/types/**/*.test.ts'],
exclude: ['node_modules/**', 'apps/website/.next/**', 'dist/**'],
typecheck: {
enabled: true,
checker: 'tsc',
include: ['apps/website/lib/types/**/*.test.ts']
}
},
resolve: {
alias: {
'@': resolve(__dirname, './apps/website'),
'@core': resolve(__dirname, './core'),
},
},
});