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

@@ -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);
});
}