/** * 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 { createHash } from 'crypto'; import * as fs from 'fs/promises'; import * as path from 'path'; 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'); async function sha256OfFile(filePath: string): Promise { const buffer = await fs.readFile(filePath); return createHash('sha256').update(buffer).digest('hex'); } 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 have a valid committed OpenAPI spec', async () => { 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 include league schedule route and schema', async () => { const specContent = await fs.readFile(openapiPath, 'utf-8'); const spec = JSON.parse(specContent); // Route should exist (controller route extraction) expect(spec.paths?.['/leagues/{leagueId}/schedule']).toBeDefined(); // Schema should exist (DTO scanning) const scheduleSchema = spec.components?.schemas?.['LeagueScheduleDTO']; expect(scheduleSchema).toBeDefined(); // Contract requirements: season-aware schedule DTO expect(scheduleSchema.required ?? []).toContain('seasonId'); expect(scheduleSchema.properties?.seasonId).toEqual({ type: 'string' }); // Races must be typed and use RaceDTO items expect(scheduleSchema.required ?? []).toContain('races'); expect(scheduleSchema.properties?.races?.type).toBe('array'); expect(scheduleSchema.properties?.races?.items).toEqual({ $ref: '#/components/schemas/RaceDTO' }); // RaceDTO.date must be ISO-safe string (OpenAPI generator maps Date->date-time, but DTO uses string) const raceSchema = spec.components?.schemas?.['RaceDTO']; expect(raceSchema).toBeDefined(); expect(raceSchema.properties?.date).toEqual({ type: 'string' }); }); 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(); 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 stamp generated output with the committed OpenAPI SHA256', async () => { execSync('npm run api:generate-types', { cwd: path.join(__dirname, '../..'), stdio: 'pipe', }); const expectedHash = await sha256OfFile(openapiPath); const barrelPath = path.join(generatedTypesDir, 'index.ts'); const barrelContent = await fs.readFile(barrelPath, 'utf-8'); expect(barrelContent).toContain(`Spec SHA256: ${expectedHash}`); expect(barrelContent).toContain(`export type { RaceDTO } from './RaceDTO';`); expect(barrelContent).toContain(`export type { DriverDTO } from './DriverDTO';`); const sampleDtoPath = path.join(generatedTypesDir, 'RaceDTO.ts'); const sampleDtoContent = await fs.readFile(sampleDtoPath, 'utf-8'); expect(sampleDtoContent).toContain(`Spec SHA256: ${expectedHash}`); }); 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') && f !== 'index.ts'); for (const file of dtos) { const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8'); // 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 tsFiles = files.filter(f => f.endsWith('.ts')); for (const file of tsFiles) { const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8'); if (file === 'index.ts') { expect(content).toContain('Auto-generated barrel'); expect(content).toContain('export type { RaceDTO } from'); continue; } // 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); }); }); });