Some checks failed
CI / lint-typecheck (pull_request) Failing after 12s
CI / tests (pull_request) Has been skipped
CI / contract-tests (pull_request) Has been skipped
CI / e2e-tests (pull_request) Has been skipped
CI / comment-pr (pull_request) Has been skipped
CI / commit-types (pull_request) Has been skipped
364 lines
14 KiB
TypeScript
364 lines
14 KiB
TypeScript
/**
|
|
* API Contract Validation Tests
|
|
*
|
|
* Validates that API DTOs are consistent and generate valid OpenAPI specs.
|
|
* This test suite ensures contract compatibility between API and website.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll } from 'vitest';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
|
|
// Import DTO classes to validate their structure
|
|
import { GetAnalyticsMetricsOutputDTO } from '../../domain/analytics/dtos/GetAnalyticsMetricsOutputDTO';
|
|
import { GetDashboardDataOutputDTO } from '../../domain/analytics/dtos/GetDashboardDataOutputDTO';
|
|
import { RecordEngagementInputDTO } from '../../domain/analytics/dtos/RecordEngagementInputDTO';
|
|
import { RecordEngagementOutputDTO } from '../../domain/analytics/dtos/RecordEngagementOutputDTO';
|
|
import { RecordPageViewInputDTO } from '../../domain/analytics/dtos/RecordPageViewInputDTO';
|
|
import { RecordPageViewOutputDTO } from '../../domain/analytics/dtos/RecordPageViewOutputDTO';
|
|
import { RequestAvatarGenerationInputDTO } from '../../domain/media/dtos/RequestAvatarGenerationInputDTO';
|
|
import { RequestAvatarGenerationOutputDTO } from '../../domain/media/dtos/RequestAvatarGenerationOutputDTO';
|
|
import { UploadMediaInputDTO } from '../../domain/media/dtos/UploadMediaInputDTO';
|
|
import { UploadMediaOutputDTO } from '../../domain/media/dtos/UploadMediaOutputDTO';
|
|
import { ValidateFaceInputDTO } from '../../domain/media/dtos/ValidateFaceInputDTO';
|
|
import { ValidateFaceOutputDTO } from '../../domain/media/dtos/ValidateFaceOutputDTO';
|
|
import { RaceDTO } from '../../domain/race/dtos/RaceDTO';
|
|
import { RaceDetailDTO } from '../../domain/race/dtos/RaceDetailDTO';
|
|
import { RaceResultDTO } from '../../domain/race/dtos/RaceResultDTO';
|
|
import { SponsorDTO } from '../../domain/sponsor/dtos/SponsorDTO';
|
|
import { SponsorshipDTO } from '../../domain/sponsor/dtos/SponsorshipDTO';
|
|
import { TeamDTO } from '../../domain/team/dtos/TeamDto';
|
|
|
|
const colors = {
|
|
reset: '\x1b[0m',
|
|
green: '\x1b[32m',
|
|
red: '\x1b[31m',
|
|
yellow: '\x1b[33m',
|
|
cyan: '\x1b[36m',
|
|
dim: '\x1b[2m'
|
|
};
|
|
|
|
describe('API Contract Validation', () => {
|
|
let openApiSpec: any;
|
|
let specPath: string;
|
|
|
|
beforeAll(async () => {
|
|
// Load the OpenAPI spec
|
|
specPath = path.join(__dirname, '..', '..', '..', 'openapi.json');
|
|
const specContent = await fs.readFile(specPath, 'utf-8');
|
|
openApiSpec = JSON.parse(specContent);
|
|
});
|
|
|
|
describe('OpenAPI Spec Integrity', () => {
|
|
it('should have valid OpenAPI structure', () => {
|
|
expect(openApiSpec).toBeDefined();
|
|
expect(openApiSpec.openapi).toBeDefined();
|
|
expect(openApiSpec.info).toBeDefined();
|
|
expect(openApiSpec.paths).toBeDefined();
|
|
expect(openApiSpec.components).toBeDefined();
|
|
expect(openApiSpec.components.schemas).toBeDefined();
|
|
});
|
|
|
|
it('should have valid OpenAPI version', () => {
|
|
expect(openApiSpec.openapi).toMatch(/^3\.\d+\.\d+$/);
|
|
});
|
|
|
|
it('should have required API metadata', () => {
|
|
expect(openApiSpec.info.title).toBeDefined();
|
|
expect(openApiSpec.info.version).toBeDefined();
|
|
expect(openApiSpec.info.description).toBeDefined();
|
|
});
|
|
|
|
it('should have no circular references in schemas', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
const visited = new Set<string>();
|
|
const visiting = new Set<string>();
|
|
|
|
const checkCircular = (schemaName: string, schema: any): boolean => {
|
|
if (!schema) return false;
|
|
if (visiting.has(schemaName)) {
|
|
return true; // Circular reference detected
|
|
}
|
|
if (visited.has(schemaName)) {
|
|
return false;
|
|
}
|
|
|
|
visiting.add(schemaName);
|
|
|
|
// Check $ref references
|
|
if (schema.$ref) {
|
|
const refName = schema.$ref.split('/').pop();
|
|
if (schemas[refName] && checkCircular(refName, schemas[refName])) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check properties
|
|
if (schema.properties) {
|
|
for (const prop of Object.values(schema.properties)) {
|
|
if ((prop as any).$ref) {
|
|
const refName = (prop as any).$ref.split('/').pop();
|
|
if (schemas[refName] && checkCircular(refName, schemas[refName])) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check array items
|
|
if (schema.items && schema.items.$ref) {
|
|
const refName = schema.items.$ref.split('/').pop();
|
|
if (schemas[refName] && checkCircular(refName, schemas[refName])) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
visiting.delete(schemaName);
|
|
visited.add(schemaName);
|
|
return false;
|
|
};
|
|
|
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
|
if (checkCircular(schemaName, schema as any)) {
|
|
throw new Error(`Circular reference detected in schema: ${schemaName}`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have all required DTOs in OpenAPI spec', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
// List of critical DTOs that must exist in the spec
|
|
const requiredDTOs = [
|
|
'GetAnalyticsMetricsOutputDTO',
|
|
'GetDashboardDataOutputDTO',
|
|
'RecordEngagementInputDTO',
|
|
'RecordEngagementOutputDTO',
|
|
'RecordPageViewInputDTO',
|
|
'RecordPageViewOutputDTO',
|
|
'RequestAvatarGenerationInputDTO',
|
|
'RequestAvatarGenerationOutputDTO',
|
|
'UploadMediaInputDTO',
|
|
'UploadMediaOutputDTO',
|
|
'ValidateFaceInputDTO',
|
|
'ValidateFaceOutputDTO',
|
|
'RaceDTO',
|
|
'RaceDetailDTO',
|
|
'RaceResultDTO',
|
|
'SponsorDTO',
|
|
'SponsorshipDTO',
|
|
'TeamDTO'
|
|
];
|
|
|
|
for (const dtoName of requiredDTOs) {
|
|
expect(schemas[dtoName], `DTO ${dtoName} should exist in OpenAPI spec`).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('should have valid JSON schema for all DTOs', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
|
expect(schema, `Schema ${schemaName} should be an object`).toBeInstanceOf(Object);
|
|
expect(schema.type, `Schema ${schemaName} should have a type`).toBeDefined();
|
|
|
|
if (schema.type === 'object') {
|
|
expect(schema.properties, `Schema ${schemaName} should have properties`).toBeDefined();
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('DTO Consistency', () => {
|
|
it('should have consistent DTO definitions between code and spec', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
// Test a sample of DTOs to ensure they match the spec
|
|
const testDTOs = [
|
|
{ name: 'GetAnalyticsMetricsOutputDTO', expectedProps: ['pageViews', 'uniqueVisitors', 'averageSessionDuration', 'bounceRate'] },
|
|
{ name: 'RaceDTO', expectedProps: ['id', 'name', 'date'] },
|
|
{ name: 'SponsorDTO', expectedProps: ['id', 'name'] }
|
|
];
|
|
|
|
for (const { name, expectedProps } of testDTOs) {
|
|
const schema = schemas[name];
|
|
expect(schema, `Schema ${name} should exist`).toBeDefined();
|
|
|
|
if (schema.properties) {
|
|
for (const prop of expectedProps) {
|
|
expect(schema.properties[prop], `Property ${prop} should exist in ${name}`).toBeDefined();
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have no duplicate DTO names', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
const schemaNames = Object.keys(schemas);
|
|
const uniqueNames = new Set(schemaNames);
|
|
|
|
expect(schemaNames.length).toBe(uniqueNames.size);
|
|
});
|
|
|
|
it('should have consistent naming conventions', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
for (const schemaName of Object.keys(schemas)) {
|
|
// DTO names should end with DTO
|
|
expect(schemaName).toMatch(/DTO$/);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Type Generation Integrity', () => {
|
|
it('should have all DTOs with proper type definitions', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
|
if (schema.type === 'object') {
|
|
expect(schema.properties, `Schema ${schemaName} should have properties`).toBeDefined();
|
|
|
|
// Check that all properties have types or are references
|
|
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
const prop = propSchema as any;
|
|
// Properties can have a type directly, or be a $ref to another schema
|
|
const hasType = prop.type !== undefined;
|
|
const isRef = prop.$ref !== undefined;
|
|
|
|
expect(hasType || isRef, `Property ${propName} in ${schemaName} should have a type or be a $ref`).toBe(true);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have required fields properly marked', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
// Test a few critical DTOs
|
|
const testDTOs = [
|
|
{ name: 'GetAnalyticsMetricsOutputDTO', required: ['pageViews', 'uniqueVisitors', 'averageSessionDuration', 'bounceRate'] },
|
|
{ name: 'RaceDTO', required: ['id', 'name', 'date'] }
|
|
];
|
|
|
|
for (const { name, required } of testDTOs) {
|
|
const schema = schemas[name];
|
|
expect(schema.required, `Schema ${name} should have required fields`).toBeDefined();
|
|
|
|
for (const field of required) {
|
|
expect(schema.required).toContain(field);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have nullable fields properly marked', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
// Check that nullable fields are properly marked
|
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
|
if (schema.properties) {
|
|
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
|
if ((propSchema as any).nullable === true) {
|
|
// Nullable fields should not be in required array
|
|
if (schema.required) {
|
|
expect(schema.required).not.toContain(propName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Contract Compatibility', () => {
|
|
it('should have backward compatible DTOs', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
// Critical DTOs that must maintain backward compatibility
|
|
const criticalDTOs = [
|
|
'RaceDTO',
|
|
'SponsorDTO',
|
|
'TeamDTO',
|
|
'DriverDTO'
|
|
];
|
|
|
|
for (const dtoName of criticalDTOs) {
|
|
const schema = schemas[dtoName];
|
|
expect(schema, `Critical DTO ${dtoName} should exist`).toBeDefined();
|
|
|
|
// These DTOs should have required fields that cannot be removed
|
|
if (schema.required) {
|
|
expect(schema.required.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have no breaking changes in required fields', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
// Check that required fields are not empty for critical DTOs
|
|
const criticalDTOs = ['RaceDTO', 'SponsorDTO', 'TeamDTO'];
|
|
|
|
for (const dtoName of criticalDTOs) {
|
|
const schema = schemas[dtoName];
|
|
if (schema && schema.required) {
|
|
expect(schema.required.length).toBeGreaterThan(0);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have consistent field types across versions', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
|
|
// Check that common fields have consistent types
|
|
const commonFields = {
|
|
id: 'string',
|
|
name: 'string',
|
|
createdAt: 'string',
|
|
updatedAt: 'string'
|
|
};
|
|
|
|
for (const [fieldName, expectedType] of Object.entries(commonFields)) {
|
|
for (const [schemaName, schema] of Object.entries(schemas)) {
|
|
if (schema.properties && schema.properties[fieldName]) {
|
|
expect(schema.properties[fieldName].type).toBe(expectedType);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Contract Validation Summary', () => {
|
|
it('should pass all contract validation checks', () => {
|
|
const schemas = openApiSpec.components.schemas as Record<string, any>;
|
|
const schemaCount = Object.keys(schemas).length;
|
|
|
|
console.log(`${colors.cyan}📊 Contract Validation Summary${colors.reset}`);
|
|
console.log(`${colors.dim} Total DTOs in OpenAPI spec: ${schemaCount}${colors.reset}`);
|
|
console.log(`${colors.dim} Spec file: ${specPath}${colors.reset}`);
|
|
|
|
// Verify critical metrics
|
|
expect(schemaCount).toBeGreaterThan(0);
|
|
|
|
// Count DTOs by category
|
|
const analyticsDTOs = Object.keys(schemas).filter(name => name.includes('Analytics') || name.includes('Engagement') || name.includes('PageView'));
|
|
const mediaDTOs = Object.keys(schemas).filter(name => name.includes('Media') || name.includes('Avatar'));
|
|
const raceDTOs = Object.keys(schemas).filter(name => name.includes('Race'));
|
|
const sponsorDTOs = Object.keys(schemas).filter(name => name.includes('Sponsor'));
|
|
const teamDTOs = Object.keys(schemas).filter(name => name.includes('Team'));
|
|
|
|
console.log(`${colors.dim} Analytics DTOs: ${analyticsDTOs.length}${colors.reset}`);
|
|
console.log(`${colors.dim} Media DTOs: ${mediaDTOs.length}${colors.reset}`);
|
|
console.log(`${colors.dim} Race DTOs: ${raceDTOs.length}${colors.reset}`);
|
|
console.log(`${colors.dim} Sponsor DTOs: ${sponsorDTOs.length}${colors.reset}`);
|
|
console.log(`${colors.dim} Team DTOs: ${teamDTOs.length}${colors.reset}`);
|
|
|
|
// Verify that we have DTOs in each category
|
|
expect(analyticsDTOs.length).toBeGreaterThan(0);
|
|
expect(mediaDTOs.length).toBeGreaterThan(0);
|
|
expect(raceDTOs.length).toBeGreaterThan(0);
|
|
expect(sponsorDTOs.length).toBeGreaterThan(0);
|
|
expect(teamDTOs.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
});
|