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

View File

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