/** * 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 * as os from 'os'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; interface OpenAPISchema { type?: string; format?: string; $ref?: string; items?: OpenAPISchema; properties?: Record; required?: string[]; enum?: string[]; nullable?: boolean; description?: string; default?: unknown; } interface OpenAPISpec { openapi: string; info: { title: string; description: string; version: string; }; paths: Record; components: { schemas: Record; }; } describe('API Contract Validation', () => { const apiRoot = path.join(__dirname, '../..'); // /Users/marcmintel/Projects/gridpilot const openapiPath = path.join(apiRoot, 'apps/api/openapi.json'); const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated'); const execFileAsync = promisify(execFile); 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('committed openapi.json should match generator output', async () => { const repoRoot = apiRoot; // Already at the repo root const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gridpilot-openapi-')); const generatedOpenapiPath = path.join(tmpDir, 'openapi.json'); await execFileAsync( 'npx', ['--no-install', 'tsx', 'scripts/generate-openapi-spec.ts', '--output', generatedOpenapiPath], { cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 }, ); const committed: OpenAPISpec = JSON.parse(await fs.readFile(openapiPath, 'utf-8')); const generated: OpenAPISpec = JSON.parse(await fs.readFile(generatedOpenapiPath, 'utf-8')); expect(generated).toEqual(committed); }); it('should include real HTTP paths for known routes', async () => { const content = await fs.readFile(openapiPath, 'utf-8'); const spec: OpenAPISpec = JSON.parse(content); const pathKeys = Object.keys(spec.paths ?? {}); expect(pathKeys.length).toBeGreaterThan(0); // A couple of stable routes to detect "empty/stale" specs. expect(spec.paths['/drivers/leaderboard']).toBeDefined(); expect(spec.paths['/dashboard/overview']).toBeDefined(); // Sanity-check the operation objects exist (method keys are lowercase in OpenAPI). expect(spec.paths['/drivers/leaderboard'].get).toBeDefined(); expect(spec.paths['/dashboard/overview'].get).toBeDefined(); }); it('should include league schedule publish/unpublish endpoints and published state', async () => { const content = await fs.readFile(openapiPath, 'utf-8'); const spec: OpenAPISpec = JSON.parse(content); expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish']).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish'].post).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish']).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish'].post).toBeDefined(); const scheduleSchema = spec.components.schemas['LeagueScheduleDTO']; if (!scheduleSchema) { throw new Error('Expected LeagueScheduleDTO schema to be present in OpenAPI spec'); } expect(scheduleSchema.properties?.published).toBeDefined(); expect(scheduleSchema.properties?.published?.type).toBe('boolean'); expect(scheduleSchema.required ?? []).toContain('published'); }); it('should include league roster admin read endpoints and schemas', async () => { const content = await fs.readFile(openapiPath, 'utf-8'); const spec: OpenAPISpec = JSON.parse(content); expect(spec.paths['/leagues/{leagueId}/admin/roster/members']).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/admin/roster/members'].get).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests']).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests'].get).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve']).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve'].post).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject']).toBeDefined(); expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject'].post).toBeDefined(); const memberSchema = spec.components.schemas['LeagueRosterMemberDTO']; if (!memberSchema) { throw new Error('Expected LeagueRosterMemberDTO schema to be present in OpenAPI spec'); } expect(memberSchema.properties?.driverId).toBeDefined(); expect(memberSchema.properties?.role).toBeDefined(); expect(memberSchema.properties?.joinedAt).toBeDefined(); expect(memberSchema.required ?? []).toContain('driverId'); expect(memberSchema.required ?? []).toContain('role'); expect(memberSchema.required ?? []).toContain('joinedAt'); expect(memberSchema.required ?? []).toContain('driver'); const joinRequestSchema = spec.components.schemas['LeagueRosterJoinRequestDTO']; if (!joinRequestSchema) { throw new Error('Expected LeagueRosterJoinRequestDTO schema to be present in OpenAPI spec'); } expect(joinRequestSchema.properties?.id).toBeDefined(); expect(joinRequestSchema.properties?.leagueId).toBeDefined(); expect(joinRequestSchema.properties?.driverId).toBeDefined(); expect(joinRequestSchema.properties?.requestedAt).toBeDefined(); expect(joinRequestSchema.required ?? []).toContain('id'); expect(joinRequestSchema.required ?? []).toContain('leagueId'); expect(joinRequestSchema.required ?? []).toContain('driverId'); expect(joinRequestSchema.required ?? []).toContain('requestedAt'); expect(joinRequestSchema.required ?? []).toContain('driver'); }); 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(); const visiting = new Set(); 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 generated DTO files for critical schemas', async () => { const content = await fs.readFile(openapiPath, 'utf-8'); const spec: OpenAPISpec = JSON.parse(content); const generatedFiles = await fs.readdir(generatedTypesDir); const generatedDTOs = generatedFiles .filter(f => f.endsWith('.ts')) .map(f => f.replace('.ts', '')); // We intentionally do NOT require a 1:1 mapping for *all* schemas here. // OpenAPI generation and type generation can be run as separate steps, // and new schemas should not break API contract validation by themselves. const criticalDTOs = [ 'RequestAvatarGenerationInputDTO', 'RequestAvatarGenerationOutputDTO', 'UploadMediaInputDTO', 'UploadMediaOutputDTO', 'RaceDTO', 'DriverDTO', ]; for (const dtoName of criticalDTOs) { expect(spec.components.schemas[dtoName]).toBeDefined(); expect(generatedDTOs).toContain(dtoName); } }); 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') && !f.endsWith('.test.ts')); for (const file of dtos) { const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8'); // `index.ts` is a generated barrel file (no interfaces). if (file === 'index.ts') { expect(content).toContain('export type {'); expect(content).toContain("from './"); continue; } // Basic TypeScript syntax checks (DTO interfaces) 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') && !f.endsWith('.test.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 [, schema] of Object.entries(schemas)) { const required = new Set(schema.required ?? []); if (!schema.properties) continue; for (const [propName, propSchema] of Object.entries(schema.properties)) { if (!propSchema.nullable) continue; // In OpenAPI 3.0, a `nullable: true` property should not be listed as required, // otherwise downstream generators can't represent it safely. expect(required.has(propName)).toBe(false); } } }); it('should have no empty string defaults for avatar/logo URLs', async () => { const content = await fs.readFile(openapiPath, 'utf-8'); const spec: OpenAPISpec = JSON.parse(content); const schemas = spec.components.schemas; // Check DTOs that should use URL|null pattern const mediaRelatedDTOs = [ 'GetAvatarOutputDTO', 'UpdateAvatarInputDTO', 'DashboardDriverSummaryDTO', 'DriverProfileDriverSummaryDTO', 'DriverLeaderboardItemDTO', 'TeamListItemDTO', 'LeagueSummaryDTO', 'SponsorDTO', ]; for (const dtoName of mediaRelatedDTOs) { const schema = schemas[dtoName]; if (!schema || !schema.properties) continue; // Check for avatarUrl, logoUrl properties for (const [propName, propSchema] of Object.entries(schema.properties)) { if (propName === 'avatarUrl' || propName === 'logoUrl') { // Should be string type, nullable (no empty string defaults) expect(propSchema.type).toBe('string'); expect(propSchema.nullable).toBe(true); // Should not have default value of empty string if (propSchema.default !== undefined) { expect(propSchema.default).not.toBe(''); } } } } }); }); });