From 12027793b138252d8694e3fb2068c926003d5142 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 22 Jan 2026 17:31:54 +0100 Subject: [PATCH] contract tests --- package-lock.json | 25 + tests/contracts/admin-contract.test.ts | 923 ++++++++++++++ tests/contracts/analytics-contract.test.ts | 897 +++++++++++++ tests/contracts/auth-contract.test.ts | 1112 ++++++++++++++++ tests/contracts/bootstrap-contract.test.ts | 313 +++++ tests/contracts/dashboard-contract.test.ts | 1073 ++++++++++++++++ tests/contracts/driver-contract.test.ts | 1328 ++++++++++++++++++++ 7 files changed, 5671 insertions(+) create mode 100644 tests/contracts/admin-contract.test.ts create mode 100644 tests/contracts/analytics-contract.test.ts create mode 100644 tests/contracts/auth-contract.test.ts create mode 100644 tests/contracts/bootstrap-contract.test.ts create mode 100644 tests/contracts/dashboard-contract.test.ts create mode 100644 tests/contracts/driver-contract.test.ts diff --git a/package-lock.json b/package-lock.json index 51978ed9b..f5086ed8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -251,6 +251,25 @@ "undici-types": "~6.21.0" } }, + "apps/companion/node_modules/@types/react": { + "version": "18.3.27", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.27.tgz", + "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", + "dev": true, + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "apps/companion/node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, "apps/companion/node_modules/path-to-regexp": { "version": "8.3.0", "license": "MIT", @@ -4717,6 +4736,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true + }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", diff --git a/tests/contracts/admin-contract.test.ts b/tests/contracts/admin-contract.test.ts new file mode 100644 index 000000000..ef2124b7b --- /dev/null +++ b/tests/contracts/admin-contract.test.ts @@ -0,0 +1,923 @@ +/** + * Contract Validation Tests for Admin Module + * + * These tests validate that the admin API DTOs and OpenAPI spec are consistent + * and that the generated types will be compatible with the website admin client. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +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('Admin Module Contract Validation', () => { + const apiRoot = path.join(__dirname, '../..'); + const openapiPath = path.join(apiRoot, 'apps/api/openapi.json'); + const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated'); + const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types'); + + describe('OpenAPI Spec Integrity for Admin Endpoints', () => { + it('should have admin endpoints defined in OpenAPI spec', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for admin endpoints + expect(spec.paths['/admin/users']).toBeDefined(); + expect(spec.paths['/admin/dashboard/stats']).toBeDefined(); + + // Verify GET methods exist + expect(spec.paths['/admin/users'].get).toBeDefined(); + expect(spec.paths['/admin/dashboard/stats'].get).toBeDefined(); + }); + + it('should have ListUsersRequestDto schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['ListUsersRequestDto']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify optional query parameters + expect(schema.properties?.role).toBeDefined(); + expect(schema.properties?.status).toBeDefined(); + expect(schema.properties?.email).toBeDefined(); + expect(schema.properties?.search).toBeDefined(); + expect(schema.properties?.page).toBeDefined(); + expect(schema.properties?.limit).toBeDefined(); + expect(schema.properties?.sortBy).toBeDefined(); + expect(schema.properties?.sortDirection).toBeDefined(); + }); + + it('should have UserResponseDto schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['UserResponseDto']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('email'); + expect(schema.required).toContain('displayName'); + expect(schema.required).toContain('roles'); + expect(schema.required).toContain('status'); + expect(schema.required).toContain('isSystemAdmin'); + expect(schema.required).toContain('createdAt'); + expect(schema.required).toContain('updatedAt'); + + // Verify field types + expect(schema.properties?.id?.type).toBe('string'); + expect(schema.properties?.email?.type).toBe('string'); + expect(schema.properties?.displayName?.type).toBe('string'); + expect(schema.properties?.roles?.type).toBe('array'); + expect(schema.properties?.status?.type).toBe('string'); + expect(schema.properties?.isSystemAdmin?.type).toBe('boolean'); + expect(schema.properties?.createdAt?.type).toBe('string'); + expect(schema.properties?.updatedAt?.type).toBe('string'); + + // Verify optional fields + expect(schema.properties?.lastLoginAt).toBeDefined(); + expect(schema.properties?.lastLoginAt?.nullable).toBe(true); + expect(schema.properties?.primaryDriverId).toBeDefined(); + expect(schema.properties?.primaryDriverId?.nullable).toBe(true); + }); + + it('should have UserListResponseDto schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['UserListResponseDto']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('users'); + expect(schema.required).toContain('total'); + expect(schema.required).toContain('page'); + expect(schema.required).toContain('limit'); + expect(schema.required).toContain('totalPages'); + + // Verify field types + expect(schema.properties?.users?.type).toBe('array'); + expect(schema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto'); + expect(schema.properties?.total?.type).toBe('number'); + expect(schema.properties?.page?.type).toBe('number'); + expect(schema.properties?.limit?.type).toBe('number'); + expect(schema.properties?.totalPages?.type).toBe('number'); + }); + + it('should have DashboardStatsResponseDto schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardStatsResponseDto']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('totalUsers'); + expect(schema.required).toContain('activeUsers'); + expect(schema.required).toContain('suspendedUsers'); + expect(schema.required).toContain('deletedUsers'); + expect(schema.required).toContain('systemAdmins'); + expect(schema.required).toContain('recentLogins'); + expect(schema.required).toContain('newUsersToday'); + expect(schema.required).toContain('userGrowth'); + expect(schema.required).toContain('roleDistribution'); + expect(schema.required).toContain('statusDistribution'); + expect(schema.required).toContain('activityTimeline'); + + // Verify field types + expect(schema.properties?.totalUsers?.type).toBe('number'); + expect(schema.properties?.activeUsers?.type).toBe('number'); + expect(schema.properties?.suspendedUsers?.type).toBe('number'); + expect(schema.properties?.deletedUsers?.type).toBe('number'); + expect(schema.properties?.systemAdmins?.type).toBe('number'); + expect(schema.properties?.recentLogins?.type).toBe('number'); + expect(schema.properties?.newUsersToday?.type).toBe('number'); + + // Verify nested objects + expect(schema.properties?.userGrowth?.type).toBe('array'); + expect(schema.properties?.roleDistribution?.type).toBe('array'); + expect(schema.properties?.statusDistribution?.type).toBe('object'); + expect(schema.properties?.activityTimeline?.type).toBe('array'); + }); + + it('should have proper query parameter validation in OpenAPI', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const listUsersPath = spec.paths['/admin/users']?.get; + expect(listUsersPath).toBeDefined(); + + // Verify query parameters are documented + const params = listUsersPath.parameters || []; + const paramNames = params.map((p: any) => p.name); + + // These should be query parameters based on the DTO + expect(paramNames).toContain('role'); + expect(paramNames).toContain('status'); + expect(paramNames).toContain('email'); + expect(paramNames).toContain('search'); + expect(paramNames).toContain('page'); + expect(paramNames).toContain('limit'); + expect(paramNames).toContain('sortBy'); + expect(paramNames).toContain('sortDirection'); + }); + }); + + describe('DTO Consistency', () => { + it('should have generated DTO files for admin 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', '')); + + // Check for admin-related DTOs + const adminDTOs = [ + 'ListUsersRequestDto', + 'UserResponseDto', + 'UserListResponseDto', + 'DashboardStatsResponseDto', + ]; + + for (const dtoName of adminDTOs) { + 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; + + // Test ListUsersRequestDto + const listUsersSchema = schemas['ListUsersRequestDto']; + const listUsersDtoPath = path.join(generatedTypesDir, 'ListUsersRequestDto.ts'); + const listUsersDtoExists = await fs.access(listUsersDtoPath).then(() => true).catch(() => false); + + if (listUsersDtoExists) { + const listUsersDtoContent = await fs.readFile(listUsersDtoPath, 'utf-8'); + + // Check that all properties are present + if (listUsersSchema.properties) { + for (const propName of Object.keys(listUsersSchema.properties)) { + expect(listUsersDtoContent).toContain(propName); + } + } + } + + // Test UserResponseDto + const userSchema = schemas['UserResponseDto']; + const userDtoPath = path.join(generatedTypesDir, 'UserResponseDto.ts'); + const userDtoExists = await fs.access(userDtoPath).then(() => true).catch(() => false); + + if (userDtoExists) { + const userDtoContent = await fs.readFile(userDtoPath, 'utf-8'); + + // Check that all required properties are present + if (userSchema.required) { + for (const requiredProp of userSchema.required) { + expect(userDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (userSchema.properties) { + for (const propName of Object.keys(userSchema.properties)) { + expect(userDtoContent).toContain(propName); + } + } + } + + // Test UserListResponseDto + const userListSchema = schemas['UserListResponseDto']; + const userListDtoPath = path.join(generatedTypesDir, 'UserListResponseDto.ts'); + const userListDtoExists = await fs.access(userListDtoPath).then(() => true).catch(() => false); + + if (userListDtoExists) { + const userListDtoContent = await fs.readFile(userListDtoPath, 'utf-8'); + + // Check that all required properties are present + if (userListSchema.required) { + for (const requiredProp of userListSchema.required) { + expect(userListDtoContent).toContain(requiredProp); + } + } + } + + // Test DashboardStatsResponseDto + const dashboardSchema = schemas['DashboardStatsResponseDto']; + const dashboardDtoPath = path.join(generatedTypesDir, 'DashboardStatsResponseDto.ts'); + const dashboardDtoExists = await fs.access(dashboardDtoPath).then(() => true).catch(() => false); + + if (dashboardDtoExists) { + const dashboardDtoContent = await fs.readFile(dashboardDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardSchema.required) { + for (const requiredProp of dashboardSchema.required) { + expect(dashboardDtoContent).toContain(requiredProp); + } + } + } + }); + + it('should have TBD admin types defined', async () => { + const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts'); + const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false); + + expect(adminTypesExists).toBe(true); + + const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8'); + + // Verify UserDto interface + expect(adminTypesContent).toContain('export interface AdminUserDto'); + expect(adminTypesContent).toContain('id: string'); + expect(adminTypesContent).toContain('email: string'); + expect(adminTypesContent).toContain('displayName: string'); + expect(adminTypesContent).toContain('roles: string[]'); + expect(adminTypesContent).toContain('status: string'); + expect(adminTypesContent).toContain('isSystemAdmin: boolean'); + expect(adminTypesContent).toContain('createdAt: string'); + expect(adminTypesContent).toContain('updatedAt: string'); + expect(adminTypesContent).toContain('lastLoginAt?: string'); + expect(adminTypesContent).toContain('primaryDriverId?: string'); + + // Verify UserListResponse interface + expect(adminTypesContent).toContain('export interface AdminUserListResponseDto'); + expect(adminTypesContent).toContain('users: AdminUserDto[]'); + expect(adminTypesContent).toContain('total: number'); + expect(adminTypesContent).toContain('page: number'); + expect(adminTypesContent).toContain('limit: number'); + expect(adminTypesContent).toContain('totalPages: number'); + + // Verify DashboardStats interface + expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto'); + expect(adminTypesContent).toContain('totalUsers: number'); + expect(adminTypesContent).toContain('activeUsers: number'); + expect(adminTypesContent).toContain('suspendedUsers: number'); + expect(adminTypesContent).toContain('deletedUsers: number'); + expect(adminTypesContent).toContain('systemAdmins: number'); + expect(adminTypesContent).toContain('recentLogins: number'); + expect(adminTypesContent).toContain('newUsersToday: number'); + + // Verify ListUsersQuery interface + expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto'); + expect(adminTypesContent).toContain('role?: string'); + expect(adminTypesContent).toContain('status?: string'); + expect(adminTypesContent).toContain('email?: string'); + expect(adminTypesContent).toContain('search?: string'); + expect(adminTypesContent).toContain('page?: number'); + expect(adminTypesContent).toContain('limit?: number'); + expect(adminTypesContent).toContain("sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status'"); + expect(adminTypesContent).toContain("sortDirection?: 'asc' | 'desc'"); + }); + + it('should have admin types re-exported from main types file', async () => { + const adminTypesPath = path.join(websiteTypesDir, 'admin.ts'); + const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false); + + expect(adminTypesExists).toBe(true); + + const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8'); + + // Verify re-exports + expect(adminTypesContent).toContain('AdminUserDto as UserDto'); + expect(adminTypesContent).toContain('AdminUserListResponseDto as UserListResponse'); + expect(adminTypesContent).toContain('AdminListUsersQueryDto as ListUsersQuery'); + expect(adminTypesContent).toContain('AdminDashboardStatsDto as DashboardStats'); + }); + }); + + describe('Admin API Client Contract', () => { + it('should have AdminApiClient defined', async () => { + const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts'); + const adminApiClientExists = await fs.access(adminApiClientPath).then(() => true).catch(() => false); + + expect(adminApiClientExists).toBe(true); + + const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8'); + + // Verify class definition + expect(adminApiClientContent).toContain('export class AdminApiClient'); + expect(adminApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods exist + expect(adminApiClientContent).toContain('async listUsers'); + expect(adminApiClientContent).toContain('async getUser'); + expect(adminApiClientContent).toContain('async updateUserRoles'); + expect(adminApiClientContent).toContain('async updateUserStatus'); + expect(adminApiClientContent).toContain('async deleteUser'); + expect(adminApiClientContent).toContain('async createUser'); + expect(adminApiClientContent).toContain('async getDashboardStats'); + + // Verify method signatures + expect(adminApiClientContent).toContain('listUsers(query: ListUsersQuery = {})'); + expect(adminApiClientContent).toContain('getUser(userId: string)'); + expect(adminApiClientContent).toContain('updateUserRoles(userId: string, roles: string[])'); + expect(adminApiClientContent).toContain('updateUserStatus(userId: string, status: string)'); + expect(adminApiClientContent).toContain('deleteUser(userId: string)'); + expect(adminApiClientContent).toContain('createUser(userData: {'); + expect(adminApiClientContent).toContain('getDashboardStats()'); + }); + + it('should have proper request construction in listUsers method', async () => { + const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts'); + const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8'); + + // Verify query parameter construction + expect(adminApiClientContent).toContain('const params = new URLSearchParams()'); + expect(adminApiClientContent).toContain("params.append('role', query.role)"); + expect(adminApiClientContent).toContain("params.append('status', query.status)"); + expect(adminApiClientContent).toContain("params.append('email', query.email)"); + expect(adminApiClientContent).toContain("params.append('search', query.search)"); + expect(adminApiClientContent).toContain("params.append('page', query.page.toString())"); + expect(adminApiClientContent).toContain("params.append('limit', query.limit.toString())"); + expect(adminApiClientContent).toContain("params.append('sortBy', query.sortBy)"); + expect(adminApiClientContent).toContain("params.append('sortDirection', query.sortDirection)"); + + // Verify endpoint construction + expect(adminApiClientContent).toContain("return this.get(`/admin/users?${params.toString()}`)"); + }); + + it('should have proper request construction in createUser method', async () => { + const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts'); + const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8'); + + // Verify POST request with userData + expect(adminApiClientContent).toContain("return this.post(`/admin/users`, userData)"); + }); + + it('should have proper request construction in getDashboardStats method', async () => { + const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts'); + const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8'); + + // Verify GET request + expect(adminApiClientContent).toContain("return this.get(`/admin/dashboard/stats`)"); + }); + }); + + describe('Request Correctness Tests', () => { + it('should validate ListUsersRequestDto query parameters', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['ListUsersRequestDto']; + + // Verify all query parameters are optional (no required fields) + expect(schema.required).toBeUndefined(); + + // Verify enum values for role + expect(schema.properties?.role?.enum).toBeUndefined(); // No enum constraint in DTO + + // Verify enum values for status + expect(schema.properties?.status?.enum).toBeUndefined(); // No enum constraint in DTO + + // Verify enum values for sortBy + expect(schema.properties?.sortBy?.enum).toEqual([ + 'email', + 'displayName', + 'createdAt', + 'lastLoginAt', + 'status' + ]); + + // Verify enum values for sortDirection + expect(schema.properties?.sortDirection?.enum).toEqual(['asc', 'desc']); + expect(schema.properties?.sortDirection?.default).toBe('asc'); + + // Verify numeric constraints + expect(schema.properties?.page?.minimum).toBe(1); + expect(schema.properties?.page?.default).toBe(1); + expect(schema.properties?.limit?.minimum).toBe(1); + expect(schema.properties?.limit?.maximum).toBe(100); + expect(schema.properties?.limit?.default).toBe(10); + }); + + it('should validate UserResponseDto field constraints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['UserResponseDto']; + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('email'); + expect(schema.required).toContain('displayName'); + expect(schema.required).toContain('roles'); + expect(schema.required).toContain('status'); + expect(schema.required).toContain('isSystemAdmin'); + expect(schema.required).toContain('createdAt'); + expect(schema.required).toContain('updatedAt'); + + // Verify optional fields are nullable + expect(schema.properties?.lastLoginAt?.nullable).toBe(true); + expect(schema.properties?.primaryDriverId?.nullable).toBe(true); + + // Verify roles is an array + expect(schema.properties?.roles?.type).toBe('array'); + expect(schema.properties?.roles?.items?.type).toBe('string'); + }); + + it('should validate DashboardStatsResponseDto structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardStatsResponseDto']; + + // Verify all required fields + const requiredFields = [ + 'totalUsers', + 'activeUsers', + 'suspendedUsers', + 'deletedUsers', + 'systemAdmins', + 'recentLogins', + 'newUsersToday', + 'userGrowth', + 'roleDistribution', + 'statusDistribution', + 'activityTimeline' + ]; + + for (const field of requiredFields) { + expect(schema.required).toContain(field); + } + + // Verify nested object structures + expect(schema.properties?.userGrowth?.items?.$ref).toBe('#/components/schemas/UserGrowthDto'); + expect(schema.properties?.roleDistribution?.items?.$ref).toBe('#/components/schemas/RoleDistributionDto'); + expect(schema.properties?.statusDistribution?.$ref).toBe('#/components/schemas/StatusDistributionDto'); + expect(schema.properties?.activityTimeline?.items?.$ref).toBe('#/components/schemas/ActivityTimelineDto'); + }); + }); + + describe('Response Handling Tests', () => { + it('should handle successful user list response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userListSchema = spec.components.schemas['UserListResponseDto']; + const userSchema = spec.components.schemas['UserResponseDto']; + + // Verify response structure + expect(userListSchema.properties?.users).toBeDefined(); + expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto'); + + // Verify user object has all required fields + expect(userSchema.required).toContain('id'); + expect(userSchema.required).toContain('email'); + expect(userSchema.required).toContain('displayName'); + expect(userSchema.required).toContain('roles'); + expect(userSchema.required).toContain('status'); + expect(userSchema.required).toContain('isSystemAdmin'); + expect(userSchema.required).toContain('createdAt'); + expect(userSchema.required).toContain('updatedAt'); + + // Verify optional fields + expect(userSchema.properties?.lastLoginAt).toBeDefined(); + expect(userSchema.properties?.primaryDriverId).toBeDefined(); + }); + + it('should handle successful dashboard stats response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto']; + + // Verify all required fields are present + expect(dashboardSchema.required).toContain('totalUsers'); + expect(dashboardSchema.required).toContain('activeUsers'); + expect(dashboardSchema.required).toContain('suspendedUsers'); + expect(dashboardSchema.required).toContain('deletedUsers'); + expect(dashboardSchema.required).toContain('systemAdmins'); + expect(dashboardSchema.required).toContain('recentLogins'); + expect(dashboardSchema.required).toContain('newUsersToday'); + expect(dashboardSchema.required).toContain('userGrowth'); + expect(dashboardSchema.required).toContain('roleDistribution'); + expect(dashboardSchema.required).toContain('statusDistribution'); + expect(dashboardSchema.required).toContain('activityTimeline'); + + // Verify nested objects are properly typed + expect(dashboardSchema.properties?.userGrowth?.type).toBe('array'); + expect(dashboardSchema.properties?.roleDistribution?.type).toBe('array'); + expect(dashboardSchema.properties?.statusDistribution?.type).toBe('object'); + expect(dashboardSchema.properties?.activityTimeline?.type).toBe('array'); + }); + + it('should handle optional fields in user response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userSchema = spec.components.schemas['UserResponseDto']; + + // Verify optional fields are nullable + expect(userSchema.properties?.lastLoginAt?.nullable).toBe(true); + expect(userSchema.properties?.primaryDriverId?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(userSchema.required).not.toContain('lastLoginAt'); + expect(userSchema.required).not.toContain('primaryDriverId'); + }); + + it('should handle pagination fields correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userListSchema = spec.components.schemas['UserListResponseDto']; + + // Verify pagination fields are required + expect(userListSchema.required).toContain('total'); + expect(userListSchema.required).toContain('page'); + expect(userListSchema.required).toContain('limit'); + expect(userListSchema.required).toContain('totalPages'); + + // Verify pagination field types + expect(userListSchema.properties?.total?.type).toBe('number'); + expect(userListSchema.properties?.page?.type).toBe('number'); + expect(userListSchema.properties?.limit?.type).toBe('number'); + expect(userListSchema.properties?.totalPages?.type).toBe('number'); + }); + }); + + describe('Error Handling Tests', () => { + it('should document 403 Forbidden response for admin endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const listUsersPath = spec.paths['/admin/users']?.get; + const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get; + + // Verify 403 response is documented + expect(listUsersPath.responses['403']).toBeDefined(); + expect(dashboardStatsPath.responses['403']).toBeDefined(); + }); + + it('should document 401 Unauthorized response for admin endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const listUsersPath = spec.paths['/admin/users']?.get; + const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get; + + // Verify 401 response is documented + expect(listUsersPath.responses['401']).toBeDefined(); + expect(dashboardStatsPath.responses['401']).toBeDefined(); + }); + + it('should document 400 Bad Request response for invalid query parameters', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const listUsersPath = spec.paths['/admin/users']?.get; + + // Verify 400 response is documented + expect(listUsersPath.responses['400']).toBeDefined(); + }); + + it('should document 500 Internal Server Error response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const listUsersPath = spec.paths['/admin/users']?.get; + const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get; + + // Verify 500 response is documented + expect(listUsersPath.responses['500']).toBeDefined(); + expect(dashboardStatsPath.responses['500']).toBeDefined(); + }); + + it('should document 404 Not Found response for user operations', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for user-specific endpoints (if they exist) + const getUserPath = spec.paths['/admin/users/{userId}']?.get; + if (getUserPath) { + expect(getUserPath.responses['404']).toBeDefined(); + } + }); + + it('should document 409 Conflict response for duplicate operations', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for create user endpoint (if it exists) + const createUserPath = spec.paths['/admin/users']?.post; + if (createUserPath) { + expect(createUserPath.responses['409']).toBeDefined(); + } + }); + }); + + describe('Semantic Guarantee Tests', () => { + it('should maintain ordering guarantees for user list', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const listUsersPath = spec.paths['/admin/users']?.get; + + // Verify sortBy and sortDirection parameters are documented + const params = listUsersPath.parameters || []; + const sortByParam = params.find((p: any) => p.name === 'sortBy'); + const sortDirectionParam = params.find((p: any) => p.name === 'sortDirection'); + + expect(sortByParam).toBeDefined(); + expect(sortDirectionParam).toBeDefined(); + + // Verify sortDirection has default value + expect(sortDirectionParam?.schema?.default).toBe('asc'); + }); + + it('should validate pagination consistency', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userListSchema = spec.components.schemas['UserListResponseDto']; + + // Verify pagination fields are all required + expect(userListSchema.required).toContain('page'); + expect(userListSchema.required).toContain('limit'); + expect(userListSchema.required).toContain('total'); + expect(userListSchema.required).toContain('totalPages'); + + // Verify page and limit have constraints + const listUsersPath = spec.paths['/admin/users']?.get; + const params = listUsersPath.parameters || []; + const pageParam = params.find((p: any) => p.name === 'page'); + const limitParam = params.find((p: any) => p.name === 'limit'); + + expect(pageParam?.schema?.minimum).toBe(1); + expect(pageParam?.schema?.default).toBe(1); + expect(limitParam?.schema?.minimum).toBe(1); + expect(limitParam?.schema?.maximum).toBe(100); + expect(limitParam?.schema?.default).toBe(10); + }); + + it('should validate idempotency for user status updates', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for user status update endpoint (if it exists) + const updateUserStatusPath = spec.paths['/admin/users/{userId}/status']?.patch; + if (updateUserStatusPath) { + // Verify it accepts a status parameter + const params = updateUserStatusPath.parameters || []; + const statusParam = params.find((p: any) => p.name === 'status'); + expect(statusParam).toBeDefined(); + } + }); + + it('should validate uniqueness constraints for user email', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userSchema = spec.components.schemas['UserResponseDto']; + + // Verify email is a required field + expect(userSchema.required).toContain('email'); + expect(userSchema.properties?.email?.type).toBe('string'); + + // Check for create user endpoint (if it exists) + const createUserPath = spec.paths['/admin/users']?.post; + if (createUserPath) { + // Verify email is required in request body + const requestBody = createUserPath.requestBody; + if (requestBody && requestBody.content && requestBody.content['application/json']) { + const schema = requestBody.content['application/json'].schema; + if (schema && schema.$ref) { + // This would reference a CreateUserDto which should have email as required + expect(schema.$ref).toContain('CreateUserDto'); + } + } + } + }); + + it('should validate consistency between request and response schemas', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userSchema = spec.components.schemas['UserResponseDto']; + const userListSchema = spec.components.schemas['UserListResponseDto']; + + // Verify UserListResponse contains array of UserResponse + expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto'); + + // Verify UserResponse has consistent field types + expect(userSchema.properties?.id?.type).toBe('string'); + expect(userSchema.properties?.email?.type).toBe('string'); + expect(userSchema.properties?.displayName?.type).toBe('string'); + expect(userSchema.properties?.roles?.type).toBe('array'); + expect(userSchema.properties?.status?.type).toBe('string'); + expect(userSchema.properties?.isSystemAdmin?.type).toBe('boolean'); + }); + + it('should validate semantic consistency in dashboard stats', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto']; + + // Verify totalUsers >= activeUsers + suspendedUsers + deletedUsers + // (This is a semantic guarantee that should be enforced by the backend) + expect(dashboardSchema.properties?.totalUsers).toBeDefined(); + expect(dashboardSchema.properties?.activeUsers).toBeDefined(); + expect(dashboardSchema.properties?.suspendedUsers).toBeDefined(); + expect(dashboardSchema.properties?.deletedUsers).toBeDefined(); + + // Verify systemAdmins is a subset of totalUsers + expect(dashboardSchema.properties?.systemAdmins).toBeDefined(); + + // Verify recentLogins and newUsersToday are non-negative + expect(dashboardSchema.properties?.recentLogins).toBeDefined(); + expect(dashboardSchema.properties?.newUsersToday).toBeDefined(); + }); + + it('should validate pagination metadata consistency', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userListSchema = spec.components.schemas['UserListResponseDto']; + + // Verify pagination metadata is always present + expect(userListSchema.required).toContain('page'); + expect(userListSchema.required).toContain('limit'); + expect(userListSchema.required).toContain('total'); + expect(userListSchema.required).toContain('totalPages'); + + // Verify totalPages calculation is consistent + // totalPages should be >= 1 and should be calculated as Math.ceil(total / limit) + expect(userListSchema.properties?.totalPages?.type).toBe('number'); + }); + }); + + describe('Admin Module Integration Tests', () => { + it('should have consistent types between API DTOs and website types', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts'); + const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8'); + + // Verify UserDto interface matches UserResponseDto schema + const userSchema = spec.components.schemas['UserResponseDto']; + expect(adminTypesContent).toContain('export interface AdminUserDto'); + + // Check all required fields from schema are in interface + for (const field of userSchema.required || []) { + expect(adminTypesContent).toContain(`${field}:`); + } + + // Check all properties from schema are in interface + if (userSchema.properties) { + for (const propName of Object.keys(userSchema.properties)) { + expect(adminTypesContent).toContain(propName); + } + } + }); + + it('should have consistent query types between API and website', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts'); + const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8'); + + // Verify ListUsersQuery interface matches ListUsersRequestDto schema + const listUsersSchema = spec.components.schemas['ListUsersRequestDto']; + expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto'); + + // Check all properties from schema are in interface + if (listUsersSchema.properties) { + for (const propName of Object.keys(listUsersSchema.properties)) { + expect(adminTypesContent).toContain(propName); + } + } + }); + + it('should have consistent response types between API and website', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts'); + const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8'); + + // Verify UserListResponse interface matches UserListResponseDto schema + const userListSchema = spec.components.schemas['UserListResponseDto']; + expect(adminTypesContent).toContain('export interface AdminUserListResponseDto'); + + // Check all required fields from schema are in interface + for (const field of userListSchema.required || []) { + expect(adminTypesContent).toContain(`${field}:`); + } + + // Verify DashboardStats interface matches DashboardStatsResponseDto schema + const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto']; + expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto'); + + // Check all required fields from schema are in interface + for (const field of dashboardSchema.required || []) { + expect(adminTypesContent).toContain(`${field}:`); + } + }); + + it('should have AdminApiClient methods matching API endpoints', async () => { + const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts'); + const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8'); + + // Verify listUsers method exists and uses correct endpoint + expect(adminApiClientContent).toContain('async listUsers'); + expect(adminApiClientContent).toContain("return this.get(`/admin/users?${params.toString()}`)"); + + // Verify getDashboardStats method exists and uses correct endpoint + expect(adminApiClientContent).toContain('async getDashboardStats'); + expect(adminApiClientContent).toContain("return this.get(`/admin/dashboard/stats`)"); + }); + + it('should have proper error handling in AdminApiClient', async () => { + const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts'); + const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(adminApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(adminApiClientContent).toContain('this.get<'); + expect(adminApiClientContent).toContain('this.post<'); + expect(adminApiClientContent).toContain('this.patch<'); + expect(adminApiClientContent).toContain('this.delete<'); + }); + }); +}); diff --git a/tests/contracts/analytics-contract.test.ts b/tests/contracts/analytics-contract.test.ts new file mode 100644 index 000000000..4d109c743 --- /dev/null +++ b/tests/contracts/analytics-contract.test.ts @@ -0,0 +1,897 @@ +/** + * Contract Validation Tests for Analytics Module + * + * These tests validate that the analytics API DTOs and OpenAPI spec are consistent + * and that the generated types will be compatible with the website analytics client. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +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('Analytics Module Contract Validation', () => { + const apiRoot = path.join(__dirname, '../..'); + const openapiPath = path.join(apiRoot, 'apps/api/openapi.json'); + const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated'); + const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types'); + + describe('OpenAPI Spec Integrity for Analytics Endpoints', () => { + it('should have analytics endpoints defined in OpenAPI spec', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for analytics endpoints + expect(spec.paths['/analytics/page-view']).toBeDefined(); + expect(spec.paths['/analytics/engagement']).toBeDefined(); + expect(spec.paths['/analytics/dashboard']).toBeDefined(); + expect(spec.paths['/analytics/metrics']).toBeDefined(); + + // Verify POST methods exist for recording endpoints + expect(spec.paths['/analytics/page-view'].post).toBeDefined(); + expect(spec.paths['/analytics/engagement'].post).toBeDefined(); + + // Verify GET methods exist for query endpoints + expect(spec.paths['/analytics/dashboard'].get).toBeDefined(); + expect(spec.paths['/analytics/metrics'].get).toBeDefined(); + }); + + it('should have RecordPageViewInputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordPageViewInputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('entityType'); + expect(schema.required).toContain('entityId'); + expect(schema.required).toContain('visitorType'); + expect(schema.required).toContain('sessionId'); + + // Verify field types + expect(schema.properties?.entityType?.type).toBe('string'); + expect(schema.properties?.entityId?.type).toBe('string'); + expect(schema.properties?.visitorType?.type).toBe('string'); + expect(schema.properties?.sessionId?.type).toBe('string'); + + // Verify optional fields + expect(schema.properties?.visitorId).toBeDefined(); + expect(schema.properties?.referrer).toBeDefined(); + expect(schema.properties?.userAgent).toBeDefined(); + expect(schema.properties?.country).toBeDefined(); + }); + + it('should have RecordPageViewOutputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordPageViewOutputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('pageViewId'); + + // Verify field types + expect(schema.properties?.pageViewId?.type).toBe('string'); + }); + + it('should have RecordEngagementInputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordEngagementInputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('action'); + expect(schema.required).toContain('entityType'); + expect(schema.required).toContain('entityId'); + expect(schema.required).toContain('actorType'); + expect(schema.required).toContain('sessionId'); + + // Verify field types + expect(schema.properties?.action?.type).toBe('string'); + expect(schema.properties?.entityType?.type).toBe('string'); + expect(schema.properties?.entityId?.type).toBe('string'); + expect(schema.properties?.actorType?.type).toBe('string'); + expect(schema.properties?.sessionId?.type).toBe('string'); + + // Verify optional fields + expect(schema.properties?.actorId).toBeDefined(); + expect(schema.properties?.metadata).toBeDefined(); + }); + + it('should have RecordEngagementOutputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordEngagementOutputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('eventId'); + expect(schema.required).toContain('engagementWeight'); + + // Verify field types + expect(schema.properties?.eventId?.type).toBe('string'); + expect(schema.properties?.engagementWeight?.type).toBe('number'); + }); + + it('should have GetAnalyticsMetricsOutputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetAnalyticsMetricsOutputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('pageViews'); + expect(schema.required).toContain('uniqueVisitors'); + expect(schema.required).toContain('averageSessionDuration'); + expect(schema.required).toContain('bounceRate'); + + // Verify field types + expect(schema.properties?.pageViews?.type).toBe('number'); + expect(schema.properties?.uniqueVisitors?.type).toBe('number'); + expect(schema.properties?.averageSessionDuration?.type).toBe('number'); + expect(schema.properties?.bounceRate?.type).toBe('number'); + }); + + it('should have GetDashboardDataOutputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDashboardDataOutputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('totalUsers'); + expect(schema.required).toContain('activeUsers'); + expect(schema.required).toContain('totalRaces'); + expect(schema.required).toContain('totalLeagues'); + + // Verify field types + expect(schema.properties?.totalUsers?.type).toBe('number'); + expect(schema.properties?.activeUsers?.type).toBe('number'); + expect(schema.properties?.totalRaces?.type).toBe('number'); + expect(schema.properties?.totalLeagues?.type).toBe('number'); + }); + + it('should have proper request/response structure for page-view endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const pageViewPath = spec.paths['/analytics/page-view']?.post; + expect(pageViewPath).toBeDefined(); + + // Verify request body + const requestBody = pageViewPath.requestBody; + expect(requestBody).toBeDefined(); + expect(requestBody.content['application/json']).toBeDefined(); + expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordPageViewInputDTO'); + + // Verify response + const response201 = pageViewPath.responses['201']; + expect(response201).toBeDefined(); + expect(response201.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordPageViewOutputDTO'); + }); + + it('should have proper request/response structure for engagement endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const engagementPath = spec.paths['/analytics/engagement']?.post; + expect(engagementPath).toBeDefined(); + + // Verify request body + const requestBody = engagementPath.requestBody; + expect(requestBody).toBeDefined(); + expect(requestBody.content['application/json']).toBeDefined(); + expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordEngagementInputDTO'); + + // Verify response + const response201 = engagementPath.responses['201']; + expect(response201).toBeDefined(); + expect(response201.content['application/json'].schema.$ref).toBe('#/components/schemas/RecordEngagementOutputDTO'); + }); + + it('should have proper response structure for metrics endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const metricsPath = spec.paths['/analytics/metrics']?.get; + expect(metricsPath).toBeDefined(); + + // Verify response + const response200 = metricsPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/GetAnalyticsMetricsOutputDTO'); + }); + + it('should have proper response structure for dashboard endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardPath = spec.paths['/analytics/dashboard']?.get; + expect(dashboardPath).toBeDefined(); + + // Verify response + const response200 = dashboardPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/GetDashboardDataOutputDTO'); + }); + }); + + describe('DTO Consistency', () => { + it('should have generated DTO files for analytics 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', '')); + + // Check for analytics-related DTOs + const analyticsDTOs = [ + 'RecordPageViewInputDTO', + 'RecordPageViewOutputDTO', + 'RecordEngagementInputDTO', + 'RecordEngagementOutputDTO', + 'GetAnalyticsMetricsOutputDTO', + 'GetDashboardDataOutputDTO', + ]; + + for (const dtoName of analyticsDTOs) { + 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; + + // Test RecordPageViewInputDTO + const pageViewSchema = schemas['RecordPageViewInputDTO']; + const pageViewDtoPath = path.join(generatedTypesDir, 'RecordPageViewInputDTO.ts'); + const pageViewDtoExists = await fs.access(pageViewDtoPath).then(() => true).catch(() => false); + + if (pageViewDtoExists) { + const pageViewDtoContent = await fs.readFile(pageViewDtoPath, 'utf-8'); + + // Check that all properties are present + if (pageViewSchema.properties) { + for (const propName of Object.keys(pageViewSchema.properties)) { + expect(pageViewDtoContent).toContain(propName); + } + } + } + + // Test RecordEngagementInputDTO + const engagementSchema = schemas['RecordEngagementInputDTO']; + const engagementDtoPath = path.join(generatedTypesDir, 'RecordEngagementInputDTO.ts'); + const engagementDtoExists = await fs.access(engagementDtoPath).then(() => true).catch(() => false); + + if (engagementDtoExists) { + const engagementDtoContent = await fs.readFile(engagementDtoPath, 'utf-8'); + + // Check that all properties are present + if (engagementSchema.properties) { + for (const propName of Object.keys(engagementSchema.properties)) { + expect(engagementDtoContent).toContain(propName); + } + } + } + + // Test GetAnalyticsMetricsOutputDTO + const metricsSchema = schemas['GetAnalyticsMetricsOutputDTO']; + const metricsDtoPath = path.join(generatedTypesDir, 'GetAnalyticsMetricsOutputDTO.ts'); + const metricsDtoExists = await fs.access(metricsDtoPath).then(() => true).catch(() => false); + + if (metricsDtoExists) { + const metricsDtoContent = await fs.readFile(metricsDtoPath, 'utf-8'); + + // Check that all required properties are present + if (metricsSchema.required) { + for (const requiredProp of metricsSchema.required) { + expect(metricsDtoContent).toContain(requiredProp); + } + } + } + + // Test GetDashboardDataOutputDTO + const dashboardSchema = schemas['GetDashboardDataOutputDTO']; + const dashboardDtoPath = path.join(generatedTypesDir, 'GetDashboardDataOutputDTO.ts'); + const dashboardDtoExists = await fs.access(dashboardDtoPath).then(() => true).catch(() => false); + + if (dashboardDtoExists) { + const dashboardDtoContent = await fs.readFile(dashboardDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardSchema.required) { + for (const requiredProp of dashboardSchema.required) { + expect(dashboardDtoContent).toContain(requiredProp); + } + } + } + }); + + it('should have analytics types defined in tbd folder', async () => { + // Check if analytics types exist in tbd folder (similar to admin types) + const tbdDir = path.join(websiteTypesDir, 'tbd'); + const tbdFiles = await fs.readdir(tbdDir).catch(() => []); + + // Analytics types might be in a separate file or combined with existing types + // For now, we'll check if the generated types are properly available + const generatedFiles = await fs.readdir(generatedTypesDir); + const analyticsGenerated = generatedFiles.filter(f => + f.includes('Analytics') || + f.includes('Record') || + f.includes('PageView') || + f.includes('Engagement') + ); + + expect(analyticsGenerated.length).toBeGreaterThanOrEqual(6); + }); + + it('should have analytics types re-exported from main types file', async () => { + // Check if there's an analytics.ts file or if types are exported elsewhere + const analyticsTypesPath = path.join(websiteTypesDir, 'analytics.ts'); + const analyticsTypesExists = await fs.access(analyticsTypesPath).then(() => true).catch(() => false); + + if (analyticsTypesExists) { + const analyticsTypesContent = await fs.readFile(analyticsTypesPath, 'utf-8'); + + // Verify re-exports + expect(analyticsTypesContent).toContain('RecordPageViewInputDTO'); + expect(analyticsTypesContent).toContain('RecordEngagementInputDTO'); + expect(analyticsTypesContent).toContain('GetAnalyticsMetricsOutputDTO'); + expect(analyticsTypesContent).toContain('GetDashboardDataOutputDTO'); + } + }); + }); + + describe('Analytics API Client Contract', () => { + it('should have AnalyticsApiClient defined', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientExists = await fs.access(analyticsApiClientPath).then(() => true).catch(() => false); + + expect(analyticsApiClientExists).toBe(true); + + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify class definition + expect(analyticsApiClientContent).toContain('export class AnalyticsApiClient'); + expect(analyticsApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods exist + expect(analyticsApiClientContent).toContain('recordPageView'); + expect(analyticsApiClientContent).toContain('recordEngagement'); + expect(analyticsApiClientContent).toContain('getDashboardData'); + expect(analyticsApiClientContent).toContain('getAnalyticsMetrics'); + + // Verify method signatures + expect(analyticsApiClientContent).toContain('recordPageView(input: RecordPageViewInputDTO)'); + expect(analyticsApiClientContent).toContain('recordEngagement(input: RecordEngagementInputDTO)'); + expect(analyticsApiClientContent).toContain('getDashboardData()'); + expect(analyticsApiClientContent).toContain('getAnalyticsMetrics()'); + }); + + it('should have proper request construction in recordPageView method', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify POST request with input + expect(analyticsApiClientContent).toContain("return this.post('/analytics/page-view', input)"); + }); + + it('should have proper request construction in recordEngagement method', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify POST request with input + expect(analyticsApiClientContent).toContain("return this.post('/analytics/engagement', input)"); + }); + + it('should have proper request construction in getDashboardData method', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify GET request + expect(analyticsApiClientContent).toContain("return this.get('/analytics/dashboard')"); + }); + + it('should have proper request construction in getAnalyticsMetrics method', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify GET request + expect(analyticsApiClientContent).toContain("return this.get('/analytics/metrics')"); + }); + }); + + describe('Request Correctness Tests', () => { + it('should validate RecordPageViewInputDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordPageViewInputDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('entityType'); + expect(schema.required).toContain('entityId'); + expect(schema.required).toContain('visitorType'); + expect(schema.required).toContain('sessionId'); + + // Verify no extra required fields + expect(schema.required.length).toBe(4); + }); + + it('should validate RecordPageViewInputDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordPageViewInputDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('visitorId'); + expect(schema.required).not.toContain('referrer'); + expect(schema.required).not.toContain('userAgent'); + expect(schema.required).not.toContain('country'); + + // Verify optional fields exist + expect(schema.properties?.visitorId).toBeDefined(); + expect(schema.properties?.referrer).toBeDefined(); + expect(schema.properties?.userAgent).toBeDefined(); + expect(schema.properties?.country).toBeDefined(); + }); + + it('should validate RecordEngagementInputDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordEngagementInputDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('action'); + expect(schema.required).toContain('entityType'); + expect(schema.required).toContain('entityId'); + expect(schema.required).toContain('actorType'); + expect(schema.required).toContain('sessionId'); + + // Verify no extra required fields + expect(schema.required.length).toBe(5); + }); + + it('should validate RecordEngagementInputDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordEngagementInputDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('actorId'); + expect(schema.required).not.toContain('metadata'); + + // Verify optional fields exist + expect(schema.properties?.actorId).toBeDefined(); + expect(schema.properties?.metadata).toBeDefined(); + }); + + it('should validate GetAnalyticsMetricsOutputDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetAnalyticsMetricsOutputDTO']; + + // Verify all required fields + expect(schema.required).toContain('pageViews'); + expect(schema.required).toContain('uniqueVisitors'); + expect(schema.required).toContain('averageSessionDuration'); + expect(schema.required).toContain('bounceRate'); + + // Verify field types + expect(schema.properties?.pageViews?.type).toBe('number'); + expect(schema.properties?.uniqueVisitors?.type).toBe('number'); + expect(schema.properties?.averageSessionDuration?.type).toBe('number'); + expect(schema.properties?.bounceRate?.type).toBe('number'); + }); + + it('should validate GetDashboardDataOutputDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDashboardDataOutputDTO']; + + // Verify all required fields + expect(schema.required).toContain('totalUsers'); + expect(schema.required).toContain('activeUsers'); + expect(schema.required).toContain('totalRaces'); + expect(schema.required).toContain('totalLeagues'); + + // Verify field types + expect(schema.properties?.totalUsers?.type).toBe('number'); + expect(schema.properties?.activeUsers?.type).toBe('number'); + expect(schema.properties?.totalRaces?.type).toBe('number'); + expect(schema.properties?.totalLeagues?.type).toBe('number'); + }); + }); + + describe('Response Handling Tests', () => { + it('should handle successful page view recording response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const pageViewSchema = spec.components.schemas['RecordPageViewOutputDTO']; + + // Verify response structure + expect(pageViewSchema.properties?.pageViewId).toBeDefined(); + expect(pageViewSchema.properties?.pageViewId?.type).toBe('string'); + expect(pageViewSchema.required).toContain('pageViewId'); + }); + + it('should handle successful engagement recording response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const engagementSchema = spec.components.schemas['RecordEngagementOutputDTO']; + + // Verify response structure + expect(engagementSchema.properties?.eventId).toBeDefined(); + expect(engagementSchema.properties?.engagementWeight).toBeDefined(); + expect(engagementSchema.properties?.eventId?.type).toBe('string'); + expect(engagementSchema.properties?.engagementWeight?.type).toBe('number'); + expect(engagementSchema.required).toContain('eventId'); + expect(engagementSchema.required).toContain('engagementWeight'); + }); + + it('should handle metrics response with all required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const metricsSchema = spec.components.schemas['GetAnalyticsMetricsOutputDTO']; + + // Verify all required fields are present + for (const field of ['pageViews', 'uniqueVisitors', 'averageSessionDuration', 'bounceRate']) { + expect(metricsSchema.required).toContain(field); + expect(metricsSchema.properties?.[field]).toBeDefined(); + expect(metricsSchema.properties?.[field]?.type).toBe('number'); + } + }); + + it('should handle dashboard data response with all required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardSchema = spec.components.schemas['GetDashboardDataOutputDTO']; + + // Verify all required fields are present + for (const field of ['totalUsers', 'activeUsers', 'totalRaces', 'totalLeagues']) { + expect(dashboardSchema.required).toContain(field); + expect(dashboardSchema.properties?.[field]).toBeDefined(); + expect(dashboardSchema.properties?.[field]?.type).toBe('number'); + } + }); + + it('should handle optional fields in page view input correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordPageViewInputDTO']; + + // Verify optional fields are nullable or optional + expect(schema.properties?.visitorId?.type).toBe('string'); + expect(schema.properties?.referrer?.type).toBe('string'); + expect(schema.properties?.userAgent?.type).toBe('string'); + expect(schema.properties?.country?.type).toBe('string'); + }); + + it('should handle optional fields in engagement input correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['RecordEngagementInputDTO']; + + // Verify optional fields + expect(schema.properties?.actorId?.type).toBe('string'); + expect(schema.properties?.metadata?.type).toBe('object'); + }); + }); + + describe('Error Handling Tests', () => { + it('should document 400 Bad Request response for invalid page view input', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const pageViewPath = spec.paths['/analytics/page-view']?.post; + + // Check if 400 response is documented + if (pageViewPath.responses['400']) { + expect(pageViewPath.responses['400']).toBeDefined(); + } + }); + + it('should document 400 Bad Request response for invalid engagement input', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const engagementPath = spec.paths['/analytics/engagement']?.post; + + // Check if 400 response is documented + if (engagementPath.responses['400']) { + expect(engagementPath.responses['400']).toBeDefined(); + } + }); + + it('should document 401 Unauthorized response for protected endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Dashboard and metrics endpoints should require authentication + const dashboardPath = spec.paths['/analytics/dashboard']?.get; + const metricsPath = spec.paths['/analytics/metrics']?.get; + + // Check if 401 responses are documented + if (dashboardPath.responses['401']) { + expect(dashboardPath.responses['401']).toBeDefined(); + } + if (metricsPath.responses['401']) { + expect(metricsPath.responses['401']).toBeDefined(); + } + }); + + it('should document 500 Internal Server Error response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const pageViewPath = spec.paths['/analytics/page-view']?.post; + const engagementPath = spec.paths['/analytics/engagement']?.post; + + // Check if 500 response is documented for recording endpoints + if (pageViewPath.responses['500']) { + expect(pageViewPath.responses['500']).toBeDefined(); + } + if (engagementPath.responses['500']) { + expect(engagementPath.responses['500']).toBeDefined(); + } + }); + + it('should have proper error handling in AnalyticsApiClient', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(analyticsApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(analyticsApiClientContent).toContain('this.post<'); + expect(analyticsApiClientContent).toContain('this.get<'); + }); + }); + + describe('Semantic Guarantee Tests', () => { + it('should maintain consistency between request and response schemas', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify page view request/response consistency + const pageViewInputSchema = spec.components.schemas['RecordPageViewInputDTO']; + const pageViewOutputSchema = spec.components.schemas['RecordPageViewOutputDTO']; + + // Output should contain a reference to the input (pageViewId relates to the recorded page view) + expect(pageViewOutputSchema.properties?.pageViewId).toBeDefined(); + expect(pageViewOutputSchema.properties?.pageViewId?.type).toBe('string'); + + // Verify engagement request/response consistency + const engagementInputSchema = spec.components.schemas['RecordEngagementInputDTO']; + const engagementOutputSchema = spec.components.schemas['RecordEngagementOutputDTO']; + + // Output should contain event reference and engagement weight + expect(engagementOutputSchema.properties?.eventId).toBeDefined(); + expect(engagementOutputSchema.properties?.engagementWeight).toBeDefined(); + }); + + it('should validate semantic consistency in analytics metrics', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const metricsSchema = spec.components.schemas['GetAnalyticsMetricsOutputDTO']; + + // Verify metrics are non-negative numbers + expect(metricsSchema.properties?.pageViews?.type).toBe('number'); + expect(metricsSchema.properties?.uniqueVisitors?.type).toBe('number'); + expect(metricsSchema.properties?.averageSessionDuration?.type).toBe('number'); + expect(metricsSchema.properties?.bounceRate?.type).toBe('number'); + + // Verify bounce rate is a percentage (0-1 range or 0-100) + // This is a semantic guarantee that should be documented + expect(metricsSchema.properties?.bounceRate).toBeDefined(); + }); + + it('should validate semantic consistency in dashboard data', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardSchema = spec.components.schemas['GetDashboardDataOutputDTO']; + + // Verify dashboard metrics are non-negative numbers + expect(dashboardSchema.properties?.totalUsers?.type).toBe('number'); + expect(dashboardSchema.properties?.activeUsers?.type).toBe('number'); + expect(dashboardSchema.properties?.totalRaces?.type).toBe('number'); + expect(dashboardSchema.properties?.totalLeagues?.type).toBe('number'); + + // Semantic guarantee: activeUsers <= totalUsers + // This should be enforced by the backend + expect(dashboardSchema.properties?.activeUsers).toBeDefined(); + expect(dashboardSchema.properties?.totalUsers).toBeDefined(); + }); + + it('should validate idempotency for analytics recording', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check if recording endpoints support idempotency + const pageViewPath = spec.paths['/analytics/page-view']?.post; + const engagementPath = spec.paths['/analytics/engagement']?.post; + + // Verify session-based deduplication is possible + const pageViewSchema = spec.components.schemas['RecordPageViewInputDTO']; + const engagementSchema = spec.components.schemas['RecordEngagementInputDTO']; + + // Both should have sessionId for deduplication + expect(pageViewSchema.properties?.sessionId).toBeDefined(); + expect(engagementSchema.properties?.sessionId).toBeDefined(); + }); + + it('should validate uniqueness constraints for analytics entities', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const pageViewSchema = spec.components.schemas['RecordPageViewInputDTO']; + const engagementSchema = spec.components.schemas['RecordEngagementInputDTO']; + + // Verify entity identification fields are required + expect(pageViewSchema.required).toContain('entityType'); + expect(pageViewSchema.required).toContain('entityId'); + expect(pageViewSchema.required).toContain('sessionId'); + + expect(engagementSchema.required).toContain('entityType'); + expect(engagementSchema.required).toContain('entityId'); + expect(engagementSchema.required).toContain('sessionId'); + }); + + it('should validate consistency between request and response types', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify all DTOs have consistent type definitions + const dtos = [ + 'RecordPageViewInputDTO', + 'RecordPageViewOutputDTO', + 'RecordEngagementInputDTO', + 'RecordEngagementOutputDTO', + 'GetAnalyticsMetricsOutputDTO', + 'GetDashboardDataOutputDTO', + ]; + + for (const dtoName of dtos) { + const schema = spec.components.schemas[dtoName]; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // All should have properties defined + expect(schema.properties).toBeDefined(); + + // All should have required fields (even if empty array) + expect(schema.required).toBeDefined(); + } + }); + }); + + describe('Analytics Module Integration Tests', () => { + it('should have consistent types between API DTOs and website types', 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', '')); + + // Check all analytics DTOs exist in generated types + const analyticsDTOs = [ + 'RecordPageViewInputDTO', + 'RecordPageViewOutputDTO', + 'RecordEngagementInputDTO', + 'RecordEngagementOutputDTO', + 'GetAnalyticsMetricsOutputDTO', + 'GetDashboardDataOutputDTO', + ]; + + for (const dtoName of analyticsDTOs) { + expect(spec.components.schemas[dtoName]).toBeDefined(); + expect(generatedDTOs).toContain(dtoName); + } + }); + + it('should have AnalyticsApiClient methods matching API endpoints', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify recordPageView method exists and uses correct endpoint + expect(analyticsApiClientContent).toContain('async recordPageView'); + expect(analyticsApiClientContent).toContain("return this.post('/analytics/page-view', input)"); + + // Verify recordEngagement method exists and uses correct endpoint + expect(analyticsApiClientContent).toContain('async recordEngagement'); + expect(analyticsApiClientContent).toContain("return this.post('/analytics/engagement', input)"); + + // Verify getDashboardData method exists and uses correct endpoint + expect(analyticsApiClientContent).toContain('async getDashboardData'); + expect(analyticsApiClientContent).toContain("return this.get('/analytics/dashboard')"); + + // Verify getAnalyticsMetrics method exists and uses correct endpoint + expect(analyticsApiClientContent).toContain('async getAnalyticsMetrics'); + expect(analyticsApiClientContent).toContain("return this.get('/analytics/metrics')"); + }); + + it('should have proper error handling in AnalyticsApiClient', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(analyticsApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(analyticsApiClientContent).toContain('this.post<'); + expect(analyticsApiClientContent).toContain('this.get<'); + }); + + it('should have consistent type imports in AnalyticsApiClient', async () => { + const analyticsApiClientPath = path.join(apiRoot, 'apps/website/lib/api/analytics/AnalyticsApiClient.ts'); + const analyticsApiClientContent = await fs.readFile(analyticsApiClientPath, 'utf-8'); + + // Verify all required types are imported + expect(analyticsApiClientContent).toContain('RecordPageViewOutputDTO'); + expect(analyticsApiClientContent).toContain('RecordEngagementOutputDTO'); + expect(analyticsApiClientContent).toContain('GetDashboardDataOutputDTO'); + expect(analyticsApiClientContent).toContain('GetAnalyticsMetricsOutputDTO'); + expect(analyticsApiClientContent).toContain('RecordPageViewInputDTO'); + expect(analyticsApiClientContent).toContain('RecordEngagementInputDTO'); + }); + }); +}); \ No newline at end of file diff --git a/tests/contracts/auth-contract.test.ts b/tests/contracts/auth-contract.test.ts new file mode 100644 index 000000000..6745422c9 --- /dev/null +++ b/tests/contracts/auth-contract.test.ts @@ -0,0 +1,1112 @@ +/** + * Contract Validation Tests for Auth Module + * + * These tests validate that the auth API DTOs and OpenAPI spec are consistent + * and that the generated types will be compatible with the website auth client. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +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('Auth Module Contract Validation', () => { + const apiRoot = path.join(__dirname, '../..'); + const openapiPath = path.join(apiRoot, 'apps/api/openapi.json'); + const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated'); + const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types'); + + describe('OpenAPI Spec Integrity for Auth Endpoints', () => { + it('should have auth endpoints defined in OpenAPI spec', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for auth endpoints + expect(spec.paths['/auth/signup']).toBeDefined(); + expect(spec.paths['/auth/login']).toBeDefined(); + expect(spec.paths['/auth/session']).toBeDefined(); + expect(spec.paths['/auth/logout']).toBeDefined(); + expect(spec.paths['/auth/forgot-password']).toBeDefined(); + expect(spec.paths['/auth/reset-password']).toBeDefined(); + + // Verify POST methods exist for signup, login, forgot-password, reset-password + expect(spec.paths['/auth/signup'].post).toBeDefined(); + expect(spec.paths['/auth/login'].post).toBeDefined(); + expect(spec.paths['/auth/forgot-password'].post).toBeDefined(); + expect(spec.paths['/auth/reset-password'].post).toBeDefined(); + + // Verify GET methods exist for session + expect(spec.paths['/auth/session'].get).toBeDefined(); + }); + + it('should have AuthSessionDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['AuthSessionDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('token'); + expect(schema.required).toContain('user'); + + // Verify field types + expect(schema.properties?.token?.type).toBe('string'); + expect(schema.properties?.user?.$ref).toBe('#/components/schemas/AuthenticatedUserDTO'); + }); + + it('should have AuthenticatedUserDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['AuthenticatedUserDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('userId'); + expect(schema.required).toContain('email'); + expect(schema.required).toContain('displayName'); + + // Verify field types + expect(schema.properties?.userId?.type).toBe('string'); + expect(schema.properties?.email?.type).toBe('string'); + expect(schema.properties?.displayName?.type).toBe('string'); + + // Verify optional fields + expect(schema.properties?.primaryDriverId).toBeDefined(); + expect(schema.properties?.avatarUrl).toBeDefined(); + expect(schema.properties?.avatarUrl?.nullable).toBe(true); + expect(schema.properties?.companyId).toBeDefined(); + expect(schema.properties?.role).toBeDefined(); + }); + + it('should have SignupParamsDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['SignupParamsDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('email'); + expect(schema.required).toContain('password'); + expect(schema.required).toContain('displayName'); + + // Verify field types + expect(schema.properties?.email?.type).toBe('string'); + expect(schema.properties?.password?.type).toBe('string'); + expect(schema.properties?.displayName?.type).toBe('string'); + + // Verify optional fields + expect(schema.properties?.username).toBeDefined(); + expect(schema.properties?.iracingCustomerId).toBeDefined(); + expect(schema.properties?.primaryDriverId).toBeDefined(); + expect(schema.properties?.avatarUrl).toBeDefined(); + expect(schema.properties?.avatarUrl?.nullable).toBe(true); + }); + + it('should have LoginParamsDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['LoginParamsDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('email'); + expect(schema.required).toContain('password'); + + // Verify field types + expect(schema.properties?.email?.type).toBe('string'); + expect(schema.properties?.password?.type).toBe('string'); + + // Verify optional fields + expect(schema.properties?.rememberMe).toBeDefined(); + expect(schema.properties?.rememberMe?.type).toBe('boolean'); + expect(schema.properties?.rememberMe?.default).toBe(false); + }); + + it('should have ForgotPasswordDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['ForgotPasswordDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('email'); + + // Verify field types + expect(schema.properties?.email?.type).toBe('string'); + }); + + it('should have ResetPasswordDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['ResetPasswordDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('token'); + expect(schema.required).toContain('newPassword'); + + // Verify field types + expect(schema.properties?.token?.type).toBe('string'); + expect(schema.properties?.newPassword?.type).toBe('string'); + }); + + it('should have proper request/response structure for signup endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const signupPath = spec.paths['/auth/signup']?.post; + expect(signupPath).toBeDefined(); + + // Verify request body + const requestBody = signupPath.requestBody; + expect(requestBody).toBeDefined(); + expect(requestBody.content['application/json']).toBeDefined(); + expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/SignupParamsDTO'); + + // Verify response + const response200 = signupPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/AuthSessionDTO'); + }); + + it('should have proper request/response structure for login endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const loginPath = spec.paths['/auth/login']?.post; + expect(loginPath).toBeDefined(); + + // Verify request body + const requestBody = loginPath.requestBody; + expect(requestBody).toBeDefined(); + expect(requestBody.content['application/json']).toBeDefined(); + expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/LoginParamsDTO'); + + // Verify response + const response200 = loginPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/AuthSessionDTO'); + }); + + it('should have proper response structure for session endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const sessionPath = spec.paths['/auth/session']?.get; + expect(sessionPath).toBeDefined(); + + // Verify response + const response200 = sessionPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/AuthSessionDTO'); + }); + + it('should have proper request/response structure for forgot-password endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const forgotPasswordPath = spec.paths['/auth/forgot-password']?.post; + expect(forgotPasswordPath).toBeDefined(); + + // Verify request body + const requestBody = forgotPasswordPath.requestBody; + expect(requestBody).toBeDefined(); + expect(requestBody.content['application/json']).toBeDefined(); + expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ForgotPasswordDTO'); + + // Verify response + const response200 = forgotPasswordPath.responses['200']; + expect(response200).toBeDefined(); + }); + + it('should have proper request/response structure for reset-password endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const resetPasswordPath = spec.paths['/auth/reset-password']?.post; + expect(resetPasswordPath).toBeDefined(); + + // Verify request body + const requestBody = resetPasswordPath.requestBody; + expect(requestBody).toBeDefined(); + expect(requestBody.content['application/json']).toBeDefined(); + expect(requestBody.content['application/json'].schema.$ref).toBe('#/components/schemas/ResetPasswordDTO'); + + // Verify response + const response200 = resetPasswordPath.responses['200']; + expect(response200).toBeDefined(); + }); + }); + + describe('DTO Consistency', () => { + it('should have generated DTO files for auth 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', '')); + + // Check for auth-related DTOs + const authDTOs = [ + 'AuthSessionDTO', + 'AuthenticatedUserDTO', + 'SignupParamsDTO', + 'LoginParamsDTO', + 'ForgotPasswordDTO', + 'ResetPasswordDTO', + ]; + + for (const dtoName of authDTOs) { + 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; + + // Test AuthSessionDTO + const authSessionSchema = schemas['AuthSessionDTO']; + const authSessionDtoPath = path.join(generatedTypesDir, 'AuthSessionDTO.ts'); + const authSessionDtoExists = await fs.access(authSessionDtoPath).then(() => true).catch(() => false); + + if (authSessionDtoExists) { + const authSessionDtoContent = await fs.readFile(authSessionDtoPath, 'utf-8'); + + // Check that all required properties are present + if (authSessionSchema.required) { + for (const requiredProp of authSessionSchema.required) { + expect(authSessionDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (authSessionSchema.properties) { + for (const propName of Object.keys(authSessionSchema.properties)) { + expect(authSessionDtoContent).toContain(propName); + } + } + } + + // Test AuthenticatedUserDTO + const authenticatedUserSchema = schemas['AuthenticatedUserDTO']; + const authenticatedUserDtoPath = path.join(generatedTypesDir, 'AuthenticatedUserDTO.ts'); + const authenticatedUserDtoExists = await fs.access(authenticatedUserDtoPath).then(() => true).catch(() => false); + + if (authenticatedUserDtoExists) { + const authenticatedUserDtoContent = await fs.readFile(authenticatedUserDtoPath, 'utf-8'); + + // Check that all required properties are present + if (authenticatedUserSchema.required) { + for (const requiredProp of authenticatedUserSchema.required) { + expect(authenticatedUserDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (authenticatedUserSchema.properties) { + for (const propName of Object.keys(authenticatedUserSchema.properties)) { + expect(authenticatedUserDtoContent).toContain(propName); + } + } + } + + // Test SignupParamsDTO + const signupParamsSchema = schemas['SignupParamsDTO']; + const signupParamsDtoPath = path.join(generatedTypesDir, 'SignupParamsDTO.ts'); + const signupParamsDtoExists = await fs.access(signupParamsDtoPath).then(() => true).catch(() => false); + + if (signupParamsDtoExists) { + const signupParamsDtoContent = await fs.readFile(signupParamsDtoPath, 'utf-8'); + + // Check that all required properties are present + if (signupParamsSchema.required) { + for (const requiredProp of signupParamsSchema.required) { + expect(signupParamsDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (signupParamsSchema.properties) { + for (const propName of Object.keys(signupParamsSchema.properties)) { + expect(signupParamsDtoContent).toContain(propName); + } + } + } + + // Test LoginParamsDTO + const loginParamsSchema = schemas['LoginParamsDTO']; + const loginParamsDtoPath = path.join(generatedTypesDir, 'LoginParamsDTO.ts'); + const loginParamsDtoExists = await fs.access(loginParamsDtoPath).then(() => true).catch(() => false); + + if (loginParamsDtoExists) { + const loginParamsDtoContent = await fs.readFile(loginParamsDtoPath, 'utf-8'); + + // Check that all required properties are present + if (loginParamsSchema.required) { + for (const requiredProp of loginParamsSchema.required) { + expect(loginParamsDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (loginParamsSchema.properties) { + for (const propName of Object.keys(loginParamsSchema.properties)) { + expect(loginParamsDtoContent).toContain(propName); + } + } + } + + // Test ForgotPasswordDTO + const forgotPasswordSchema = schemas['ForgotPasswordDTO']; + const forgotPasswordDtoPath = path.join(generatedTypesDir, 'ForgotPasswordDTO.ts'); + const forgotPasswordDtoExists = await fs.access(forgotPasswordDtoPath).then(() => true).catch(() => false); + + if (forgotPasswordDtoExists) { + const forgotPasswordDtoContent = await fs.readFile(forgotPasswordDtoPath, 'utf-8'); + + // Check that all required properties are present + if (forgotPasswordSchema.required) { + for (const requiredProp of forgotPasswordSchema.required) { + expect(forgotPasswordDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (forgotPasswordSchema.properties) { + for (const propName of Object.keys(forgotPasswordSchema.properties)) { + expect(forgotPasswordDtoContent).toContain(propName); + } + } + } + + // Test ResetPasswordDTO + const resetPasswordSchema = schemas['ResetPasswordDTO']; + const resetPasswordDtoPath = path.join(generatedTypesDir, 'ResetPasswordDTO.ts'); + const resetPasswordDtoExists = await fs.access(resetPasswordDtoPath).then(() => true).catch(() => false); + + if (resetPasswordDtoExists) { + const resetPasswordDtoContent = await fs.readFile(resetPasswordDtoPath, 'utf-8'); + + // Check that all required properties are present + if (resetPasswordSchema.required) { + for (const requiredProp of resetPasswordSchema.required) { + expect(resetPasswordDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (resetPasswordSchema.properties) { + for (const propName of Object.keys(resetPasswordSchema.properties)) { + expect(resetPasswordDtoContent).toContain(propName); + } + } + } + }); + + it('should have auth types defined in tbd folder', async () => { + // Check if auth types exist in tbd folder (similar to admin types) + const tbdDir = path.join(websiteTypesDir, 'tbd'); + const tbdFiles = await fs.readdir(tbdDir).catch(() => []); + + // Auth types might be in a separate file or combined with existing types + // For now, we'll check if the generated types are properly available + const generatedFiles = await fs.readdir(generatedTypesDir); + const authGenerated = generatedFiles.filter(f => + f.includes('Auth') || + f.includes('Login') || + f.includes('Signup') || + f.includes('Session') || + f.includes('Forgot') || + f.includes('Reset') + ); + + expect(authGenerated.length).toBeGreaterThanOrEqual(6); + }); + + it('should have auth types re-exported from main types file', async () => { + // Check if there's an auth.ts file or if types are exported elsewhere + const authTypesPath = path.join(websiteTypesDir, 'auth.ts'); + const authTypesExists = await fs.access(authTypesPath).then(() => true).catch(() => false); + + if (authTypesExists) { + const authTypesContent = await fs.readFile(authTypesPath, 'utf-8'); + + // Verify re-exports + expect(authTypesContent).toContain('AuthSessionDTO'); + expect(authTypesContent).toContain('AuthenticatedUserDTO'); + expect(authTypesContent).toContain('SignupParamsDTO'); + expect(authTypesContent).toContain('LoginParamsDTO'); + expect(authTypesContent).toContain('ForgotPasswordDTO'); + expect(authTypesContent).toContain('ResetPasswordDTO'); + } + }); + }); + + describe('Auth API Client Contract', () => { + it('should have AuthApiClient defined', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientExists = await fs.access(authApiClientPath).then(() => true).catch(() => false); + + expect(authApiClientExists).toBe(true); + + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify class definition + expect(authApiClientContent).toContain('export class AuthApiClient'); + expect(authApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods exist + expect(authApiClientContent).toContain('signup'); + expect(authApiClientContent).toContain('login'); + expect(authApiClientContent).toContain('getSession'); + expect(authApiClientContent).toContain('logout'); + expect(authApiClientContent).toContain('forgotPassword'); + expect(authApiClientContent).toContain('resetPassword'); + + // Verify method signatures + expect(authApiClientContent).toContain('signup(params: SignupParamsDTO)'); + expect(authApiClientContent).toContain('login(params: LoginParamsDTO)'); + expect(authApiClientContent).toContain('getSession()'); + expect(authApiClientContent).toContain('logout()'); + expect(authApiClientContent).toContain('forgotPassword(params: ForgotPasswordDTO)'); + expect(authApiClientContent).toContain('resetPassword(params: ResetPasswordDTO)'); + }); + + it('should have proper request construction in signup method', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify POST request with params + expect(authApiClientContent).toContain("return this.post('/auth/signup', params)"); + }); + + it('should have proper request construction in login method', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify POST request with params + expect(authApiClientContent).toContain("return this.post('/auth/login', params)"); + }); + + it('should have proper request construction in getSession method', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify GET request with allowUnauthenticated option + expect(authApiClientContent).toContain("return this.request('GET', '/auth/session', undefined, {"); + expect(authApiClientContent).toContain('allowUnauthenticated: true'); + }); + + it('should have proper request construction in logout method', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify POST request with empty body + expect(authApiClientContent).toContain("return this.post('/auth/logout', {})"); + }); + + it('should have proper request construction in forgotPassword method', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify POST request with params + expect(authApiClientContent).toContain("return this.post<{ message: string; magicLink?: string }>('/auth/forgot-password', params)"); + }); + + it('should have proper request construction in resetPassword method', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify POST request with params + expect(authApiClientContent).toContain("return this.post<{ message: string }>('/auth/reset-password', params)"); + }); + }); + + describe('Request Correctness Tests', () => { + it('should validate SignupParamsDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['SignupParamsDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('email'); + expect(schema.required).toContain('password'); + expect(schema.required).toContain('displayName'); + + // Verify no extra required fields + expect(schema.required.length).toBe(3); + }); + + it('should validate SignupParamsDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['SignupParamsDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('username'); + expect(schema.required).not.toContain('iracingCustomerId'); + expect(schema.required).not.toContain('primaryDriverId'); + expect(schema.required).not.toContain('avatarUrl'); + + // Verify optional fields exist + expect(schema.properties?.username).toBeDefined(); + expect(schema.properties?.iracingCustomerId).toBeDefined(); + expect(schema.properties?.primaryDriverId).toBeDefined(); + expect(schema.properties?.avatarUrl).toBeDefined(); + }); + + it('should validate LoginParamsDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['LoginParamsDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('email'); + expect(schema.required).toContain('password'); + + // Verify no extra required fields + expect(schema.required.length).toBe(2); + }); + + it('should validate LoginParamsDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['LoginParamsDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('rememberMe'); + + // Verify optional fields exist + expect(schema.properties?.rememberMe).toBeDefined(); + expect(schema.properties?.rememberMe?.type).toBe('boolean'); + expect(schema.properties?.rememberMe?.default).toBe(false); + }); + + it('should validate ForgotPasswordDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['ForgotPasswordDTO']; + + // Verify all required fields + expect(schema.required).toContain('email'); + + // Verify field types + expect(schema.properties?.email?.type).toBe('string'); + }); + + it('should validate ResetPasswordDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['ResetPasswordDTO']; + + // Verify all required fields + expect(schema.required).toContain('token'); + expect(schema.required).toContain('newPassword'); + + // Verify field types + expect(schema.properties?.token?.type).toBe('string'); + expect(schema.properties?.newPassword?.type).toBe('string'); + }); + + it('should validate AuthSessionDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['AuthSessionDTO']; + + // Verify all required fields + expect(schema.required).toContain('token'); + expect(schema.required).toContain('user'); + + // Verify field types + expect(schema.properties?.token?.type).toBe('string'); + expect(schema.properties?.user?.$ref).toBe('#/components/schemas/AuthenticatedUserDTO'); + }); + + it('should validate AuthenticatedUserDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['AuthenticatedUserDTO']; + + // Verify all required fields + const requiredFields = ['userId', 'email', 'displayName']; + for (const field of requiredFields) { + expect(schema.required).toContain(field); + } + + // Verify field types + expect(schema.properties?.userId?.type).toBe('string'); + expect(schema.properties?.email?.type).toBe('string'); + expect(schema.properties?.displayName?.type).toBe('string'); + expect(schema.properties?.role?.type).toBe('string'); + + // Verify optional fields are nullable + expect(schema.properties?.avatarUrl?.nullable).toBe(true); + }); + }); + + describe('Response Handling Tests', () => { + it('should handle successful signup response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const authSessionSchema = spec.components.schemas['AuthSessionDTO']; + + // Verify response structure + expect(authSessionSchema.properties?.token).toBeDefined(); + expect(authSessionSchema.properties?.user).toBeDefined(); + expect(authSessionSchema.properties?.token?.type).toBe('string'); + expect(authSessionSchema.properties?.user?.$ref).toBe('#/components/schemas/AuthenticatedUserDTO'); + expect(authSessionSchema.required).toContain('token'); + expect(authSessionSchema.required).toContain('user'); + }); + + it('should handle successful login response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const authSessionSchema = spec.components.schemas['AuthSessionDTO']; + + // Verify response structure + expect(authSessionSchema.properties?.token).toBeDefined(); + expect(authSessionSchema.properties?.user).toBeDefined(); + expect(authSessionSchema.properties?.token?.type).toBe('string'); + expect(authSessionSchema.properties?.user?.$ref).toBe('#/components/schemas/AuthenticatedUserDTO'); + expect(authSessionSchema.required).toContain('token'); + expect(authSessionSchema.required).toContain('user'); + }); + + it('should handle session response with all required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const authSessionSchema = spec.components.schemas['AuthSessionDTO']; + + // Verify all required fields are present + for (const field of ['token', 'user']) { + expect(authSessionSchema.required).toContain(field); + expect(authSessionSchema.properties?.[field]).toBeDefined(); + } + }); + + it('should handle optional fields in user response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userSchema = spec.components.schemas['AuthenticatedUserDTO']; + + // Verify optional fields are nullable + expect(userSchema.properties?.avatarUrl?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(userSchema.required).not.toContain('avatarUrl'); + expect(userSchema.required).not.toContain('primaryDriverId'); + expect(userSchema.required).not.toContain('companyId'); + expect(userSchema.required).not.toContain('role'); + }); + + it('should handle forgot-password response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const forgotPasswordPath = spec.paths['/auth/forgot-password']?.post; + expect(forgotPasswordPath).toBeDefined(); + + // Verify response structure + const response200 = forgotPasswordPath.responses['200']; + expect(response200).toBeDefined(); + }); + + it('should handle reset-password response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const resetPasswordPath = spec.paths['/auth/reset-password']?.post; + expect(resetPasswordPath).toBeDefined(); + + // Verify response structure + const response200 = resetPasswordPath.responses['200']; + expect(response200).toBeDefined(); + }); + }); + + describe('Error Handling Tests', () => { + it('should document 400 Bad Request response for invalid signup input', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const signupPath = spec.paths['/auth/signup']?.post; + + // Check if 400 response is documented + if (signupPath.responses['400']) { + expect(signupPath.responses['400']).toBeDefined(); + } + }); + + it('should document 400 Bad Request response for invalid login input', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const loginPath = spec.paths['/auth/login']?.post; + + // Check if 400 response is documented + if (loginPath.responses['400']) { + expect(loginPath.responses['400']).toBeDefined(); + } + }); + + it('should document 401 Unauthorized response for login endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const loginPath = spec.paths['/auth/login']?.post; + + // Check if 401 response is documented + if (loginPath.responses['401']) { + expect(loginPath.responses['401']).toBeDefined(); + } + }); + + it('should document 400 Bad Request response for invalid forgot-password input', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const forgotPasswordPath = spec.paths['/auth/forgot-password']?.post; + + // Check if 400 response is documented + if (forgotPasswordPath.responses['400']) { + expect(forgotPasswordPath.responses['400']).toBeDefined(); + } + }); + + it('should document 400 Bad Request response for invalid reset-password input', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const resetPasswordPath = spec.paths['/auth/reset-password']?.post; + + // Check if 400 response is documented + if (resetPasswordPath.responses['400']) { + expect(resetPasswordPath.responses['400']).toBeDefined(); + } + }); + + it('should document 401 Unauthorized response for protected endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Session endpoint should require authentication + const sessionPath = spec.paths['/auth/session']?.get; + + // Check if 401 responses are documented + if (sessionPath.responses['401']) { + expect(sessionPath.responses['401']).toBeDefined(); + } + }); + + it('should document 500 Internal Server Error response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const signupPath = spec.paths['/auth/signup']?.post; + const loginPath = spec.paths['/auth/login']?.post; + + // Check if 500 response is documented for auth endpoints + if (signupPath.responses['500']) { + expect(signupPath.responses['500']).toBeDefined(); + } + if (loginPath.responses['500']) { + expect(loginPath.responses['500']).toBeDefined(); + } + }); + + it('should have proper error handling in AuthApiClient', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(authApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(authApiClientContent).toContain('this.post<'); + expect(authApiClientContent).toContain('this.get<'); + expect(authApiClientContent).toContain('this.request<'); + }); + }); + + describe('Semantic Guarantee Tests', () => { + it('should maintain consistency between request and response schemas', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify signup request/response consistency + const signupInputSchema = spec.components.schemas['SignupParamsDTO']; + const signupOutputSchema = spec.components.schemas['AuthSessionDTO']; + + // Output should contain token and user + expect(signupOutputSchema.properties?.token).toBeDefined(); + expect(signupOutputSchema.properties?.user).toBeDefined(); + + // Verify login request/response consistency + const loginInputSchema = spec.components.schemas['LoginParamsDTO']; + const loginOutputSchema = spec.components.schemas['AuthSessionDTO']; + + // Output should contain token and user + expect(loginOutputSchema.properties?.token).toBeDefined(); + expect(loginOutputSchema.properties?.user).toBeDefined(); + }); + + it('should validate semantic consistency in auth session', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const authSessionSchema = spec.components.schemas['AuthSessionDTO']; + const userSchema = spec.components.schemas['AuthenticatedUserDTO']; + + // Verify session has token and user + expect(authSessionSchema.properties?.token?.type).toBe('string'); + expect(authSessionSchema.properties?.user?.$ref).toBe('#/components/schemas/AuthenticatedUserDTO'); + + // Verify user has required fields + expect(userSchema.required).toContain('userId'); + expect(userSchema.required).toContain('email'); + expect(userSchema.required).toContain('displayName'); + }); + + it('should validate idempotency for logout', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check if logout endpoint exists + const logoutPath = spec.paths['/auth/logout']?.post; + if (logoutPath) { + // Verify it accepts an empty body (idempotent operation) + const requestBody = logoutPath.requestBody; + if (requestBody && requestBody.content && requestBody.content['application/json']) { + const schema = requestBody.content['application/json'].schema; + // Empty body or minimal body + expect(schema).toBeDefined(); + } + } + }); + + it('should validate uniqueness constraints for user email', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const userSchema = spec.components.schemas['AuthenticatedUserDTO']; + + // Verify email is a required field + expect(userSchema.required).toContain('email'); + expect(userSchema.properties?.email?.type).toBe('string'); + + // Check for signup endpoint + const signupPath = spec.paths['/auth/signup']?.post; + if (signupPath) { + // Verify email is required in request body + const requestBody = signupPath.requestBody; + if (requestBody && requestBody.content && requestBody.content['application/json']) { + const schema = requestBody.content['application/json'].schema; + if (schema && schema.$ref) { + // This would reference SignupParamsDTO which should have email as required + expect(schema.$ref).toContain('SignupParamsDTO'); + } + } + } + }); + + it('should validate consistency between request and response types', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify all DTOs have consistent type definitions + const dtos = [ + 'AuthSessionDTO', + 'AuthenticatedUserDTO', + 'SignupParamsDTO', + 'LoginParamsDTO', + 'ForgotPasswordDTO', + 'ResetPasswordDTO', + ]; + + for (const dtoName of dtos) { + const schema = spec.components.schemas[dtoName]; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // All should have properties defined + expect(schema.properties).toBeDefined(); + + // All should have required fields (even if empty array) + expect(schema.required).toBeDefined(); + } + }); + + it('should validate semantic consistency in auth session token', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const authSessionSchema = spec.components.schemas['AuthSessionDTO']; + + // Verify token is a string (JWT format) + expect(authSessionSchema.properties?.token?.type).toBe('string'); + + // Verify user is a reference to AuthenticatedUserDTO + expect(authSessionSchema.properties?.user?.$ref).toBe('#/components/schemas/AuthenticatedUserDTO'); + }); + + it('should validate pagination is not applicable for auth endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Auth endpoints should not have pagination + const authEndpoints = [ + '/auth/signup', + '/auth/login', + '/auth/session', + '/auth/logout', + '/auth/forgot-password', + '/auth/reset-password', + ]; + + for (const endpoint of authEndpoints) { + const path = spec.paths[endpoint]; + if (path) { + // Check if there are any query parameters for pagination + const methods = Object.keys(path); + for (const method of methods) { + const operation = path[method]; + if (operation.parameters) { + const paramNames = operation.parameters.map((p: any) => p.name); + // Auth endpoints should not have page/limit parameters + expect(paramNames).not.toContain('page'); + expect(paramNames).not.toContain('limit'); + } + } + } + } + }); + }); + + describe('Auth Module Integration Tests', () => { + it('should have consistent types between API DTOs and website types', 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', '')); + + // Check all auth DTOs exist in generated types + const authDTOs = [ + 'AuthSessionDTO', + 'AuthenticatedUserDTO', + 'SignupParamsDTO', + 'LoginParamsDTO', + 'ForgotPasswordDTO', + 'ResetPasswordDTO', + ]; + + for (const dtoName of authDTOs) { + expect(spec.components.schemas[dtoName]).toBeDefined(); + expect(generatedDTOs).toContain(dtoName); + } + }); + + it('should have AuthApiClient methods matching API endpoints', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify signup method exists and uses correct endpoint + expect(authApiClientContent).toContain('async signup'); + expect(authApiClientContent).toContain("return this.post('/auth/signup', params)"); + + // Verify login method exists and uses correct endpoint + expect(authApiClientContent).toContain('async login'); + expect(authApiClientContent).toContain("return this.post('/auth/login', params)"); + + // Verify getSession method exists and uses correct endpoint + expect(authApiClientContent).toContain('async getSession'); + expect(authApiClientContent).toContain("return this.request('GET', '/auth/session', undefined, {"); + + // Verify logout method exists and uses correct endpoint + expect(authApiClientContent).toContain('async logout'); + expect(authApiClientContent).toContain("return this.post('/auth/logout', {})"); + }); + + it('should have proper error handling in AuthApiClient', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(authApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(authApiClientContent).toContain('this.post<'); + expect(authApiClientContent).toContain('this.get<'); + expect(authApiClientContent).toContain('this.request<'); + }); + + it('should have consistent type imports in AuthApiClient', async () => { + const authApiClientPath = path.join(apiRoot, 'apps/website/lib/api/auth/AuthApiClient.ts'); + const authApiClientContent = await fs.readFile(authApiClientPath, 'utf-8'); + + // Verify all required types are imported + expect(authApiClientContent).toContain('AuthSessionDTO'); + expect(authApiClientContent).toContain('LoginParamsDTO'); + expect(authApiClientContent).toContain('SignupParamsDTO'); + expect(authApiClientContent).toContain('ForgotPasswordDTO'); + expect(authApiClientContent).toContain('ResetPasswordDTO'); + }); + }); +}); diff --git a/tests/contracts/bootstrap-contract.test.ts b/tests/contracts/bootstrap-contract.test.ts new file mode 100644 index 000000000..1a013a917 --- /dev/null +++ b/tests/contracts/bootstrap-contract.test.ts @@ -0,0 +1,313 @@ +/** + * Contract Validation Tests for Bootstrap Module + * + * These tests validate that the bootstrap module is properly configured and that + * the initialization process follows expected patterns. The bootstrap module is + * an internal initialization module that runs during application startup and + * does not expose HTTP endpoints. + * + * Key Findings: + * - Bootstrap module is an internal initialization module (not an API endpoint) + * - It runs during application startup via OnModuleInit lifecycle hook + * - It seeds the database with initial data (admin users, achievements, racing data) + * - It does not expose any HTTP controllers or endpoints + * - No API client exists in the website app for bootstrap operations + * - No bootstrap-related endpoints are defined in the OpenAPI spec + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +interface OpenAPISpec { + openapi: string; + info: { + title: string; + description: string; + version: string; + }; + paths: Record; + components: { + schemas: Record; + }; +} + +describe('Bootstrap Module Contract Validation', () => { + const apiRoot = path.join(__dirname, '../..'); + const openapiPath = path.join(apiRoot, 'apps/api/openapi.json'); + const bootstrapModulePath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.ts'); + const bootstrapAdaptersPath = path.join(apiRoot, 'adapters/bootstrap'); + + describe('OpenAPI Spec Integrity for Bootstrap', () => { + it('should NOT have bootstrap endpoints defined in OpenAPI spec', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Bootstrap is an internal module, not an API endpoint + // Verify no bootstrap-related paths exist + const bootstrapPaths = Object.keys(spec.paths).filter(p => p.includes('bootstrap')); + expect(bootstrapPaths.length).toBe(0); + }); + + it('should NOT have bootstrap-related DTOs in OpenAPI spec', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Bootstrap module doesn't expose DTOs for API consumption + // It uses internal DTOs for seeding data + const bootstrapSchemas = Object.keys(spec.components.schemas).filter(s => + s.toLowerCase().includes('bootstrap') || + s.toLowerCase().includes('seed') + ); + expect(bootstrapSchemas.length).toBe(0); + }); + }); + + describe('Bootstrap Module Structure', () => { + it('should have BootstrapModule defined', async () => { + const bootstrapModuleExists = await fs.access(bootstrapModulePath).then(() => true).catch(() => false); + expect(bootstrapModuleExists).toBe(true); + }); + + it('should have BootstrapModule implement OnModuleInit', async () => { + const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8'); + + // Verify it implements OnModuleInit lifecycle hook + expect(bootstrapModuleContent).toContain('implements OnModuleInit'); + expect(bootstrapModuleContent).toContain('async onModuleInit()'); + }); + + it('should have BootstrapModule with proper dependencies', async () => { + const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8'); + + // Verify required dependencies are injected + expect(bootstrapModuleContent).toContain('@Inject(ENSURE_INITIAL_DATA_TOKEN)'); + expect(bootstrapModuleContent).toContain('@Inject(SEED_DEMO_USERS_TOKEN)'); + expect(bootstrapModuleContent).toContain('@Inject(\'Logger\')'); + expect(bootstrapModuleContent).toContain('@Inject(\'RacingSeedDependencies\')'); + }); + + it('should have BootstrapModule with proper imports', async () => { + const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8'); + + // Verify persistence modules are imported + expect(bootstrapModuleContent).toContain('RacingPersistenceModule'); + expect(bootstrapModuleContent).toContain('SocialPersistenceModule'); + expect(bootstrapModuleContent).toContain('AchievementPersistenceModule'); + expect(bootstrapModuleContent).toContain('IdentityPersistenceModule'); + expect(bootstrapModuleContent).toContain('AdminPersistenceModule'); + }); + }); + + describe('Bootstrap Adapters Structure', () => { + it('should have EnsureInitialData adapter', async () => { + const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts'); + const ensureInitialDataExists = await fs.access(ensureInitialDataPath).then(() => true).catch(() => false); + expect(ensureInitialDataExists).toBe(true); + }); + + it('should have SeedDemoUsers adapter', async () => { + const seedDemoUsersPath = path.join(bootstrapAdaptersPath, 'SeedDemoUsers.ts'); + const seedDemoUsersExists = await fs.access(seedDemoUsersPath).then(() => true).catch(() => false); + expect(seedDemoUsersExists).toBe(true); + }); + + it('should have SeedRacingData adapter', async () => { + const seedRacingDataPath = path.join(bootstrapAdaptersPath, 'SeedRacingData.ts'); + const seedRacingDataExists = await fs.access(seedRacingDataPath).then(() => true).catch(() => false); + expect(seedRacingDataExists).toBe(true); + }); + + it('should have racing seed factories', async () => { + const racingDir = path.join(bootstrapAdaptersPath, 'racing'); + const racingDirExists = await fs.access(racingDir).then(() => true).catch(() => false); + expect(racingDirExists).toBe(true); + + // Verify key factory files exist + const racingFiles = await fs.readdir(racingDir); + expect(racingFiles).toContain('RacingDriverFactory.ts'); + expect(racingFiles).toContain('RacingTeamFactory.ts'); + expect(racingFiles).toContain('RacingLeagueFactory.ts'); + expect(racingFiles).toContain('RacingRaceFactory.ts'); + }); + }); + + describe('Bootstrap Configuration', () => { + it('should have bootstrap configuration in environment', async () => { + const envPath = path.join(apiRoot, 'apps/api/src/env.ts'); + const envContent = await fs.readFile(envPath, 'utf-8'); + + // Verify bootstrap configuration functions exist + expect(envContent).toContain('getEnableBootstrap'); + expect(envContent).toContain('getForceReseed'); + }); + + it('should have bootstrap enabled by default', async () => { + const envPath = path.join(apiRoot, 'apps/api/src/env.ts'); + const envContent = await fs.readFile(envPath, 'utf-8'); + + // Verify bootstrap is enabled by default (for dev/test) + expect(envContent).toContain('GRIDPILOT_API_BOOTSTRAP'); + expect(envContent).toContain('true'); // Default value + }); + }); + + describe('Bootstrap Initialization Logic', () => { + it('should have proper initialization sequence', async () => { + const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8'); + + // Verify initialization sequence + expect(bootstrapModuleContent).toContain('await this.ensureInitialData.execute()'); + expect(bootstrapModuleContent).toContain('await this.shouldSeedRacingData()'); + expect(bootstrapModuleContent).toContain('await this.shouldSeedDemoUsers()'); + }); + + it('should have environment-aware seeding logic', async () => { + const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8'); + + // Verify environment checks + expect(bootstrapModuleContent).toContain('process.env.NODE_ENV'); + expect(bootstrapModuleContent).toContain('production'); + expect(bootstrapModuleContent).toContain('inmemory'); + expect(bootstrapModuleContent).toContain('postgres'); + }); + + it('should have force reseed capability', async () => { + const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8'); + + // Verify force reseed logic + expect(bootstrapModuleContent).toContain('getForceReseed()'); + expect(bootstrapModuleContent).toContain('Force reseed enabled'); + }); + }); + + describe('Bootstrap Data Seeding', () => { + it('should seed initial admin user', async () => { + const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts'); + const ensureInitialDataContent = await fs.readFile(ensureInitialDataPath, 'utf-8'); + + // Verify admin user seeding + expect(ensureInitialDataContent).toContain('admin@gridpilot.local'); + expect(ensureInitialDataContent).toContain('Admin'); + expect(ensureInitialDataContent).toContain('signupUseCase'); + }); + + it('should seed achievements', async () => { + const ensureInitialDataPath = path.join(bootstrapAdaptersPath, 'EnsureInitialData.ts'); + const ensureInitialDataContent = await fs.readFile(ensureInitialDataPath, 'utf-8'); + + // Verify achievement seeding + expect(ensureInitialDataContent).toContain('DRIVER_ACHIEVEMENTS'); + expect(ensureInitialDataContent).toContain('STEWARD_ACHIEVEMENTS'); + expect(ensureInitialDataContent).toContain('ADMIN_ACHIEVEMENTS'); + expect(ensureInitialDataContent).toContain('COMMUNITY_ACHIEVEMENTS'); + expect(ensureInitialDataContent).toContain('createAchievementUseCase'); + }); + + it('should seed demo users', async () => { + const seedDemoUsersPath = path.join(bootstrapAdaptersPath, 'SeedDemoUsers.ts'); + const seedDemoUsersContent = await fs.readFile(seedDemoUsersPath, 'utf-8'); + + // Verify demo user seeding + expect(seedDemoUsersContent).toContain('SeedDemoUsers'); + expect(seedDemoUsersContent).toContain('execute'); + }); + + it('should seed racing data', async () => { + const seedRacingDataPath = path.join(bootstrapAdaptersPath, 'SeedRacingData.ts'); + const seedRacingDataContent = await fs.readFile(seedRacingDataPath, 'utf-8'); + + // Verify racing data seeding + expect(seedRacingDataContent).toContain('SeedRacingData'); + expect(seedRacingDataContent).toContain('execute'); + expect(seedRacingDataContent).toContain('RacingSeedDependencies'); + }); + }); + + describe('Bootstrap Providers', () => { + it('should have BootstrapProviders defined', async () => { + const bootstrapProvidersPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapProviders.ts'); + const bootstrapProvidersExists = await fs.access(bootstrapProvidersPath).then(() => true).catch(() => false); + expect(bootstrapProvidersExists).toBe(true); + }); + + it('should have proper provider tokens', async () => { + const bootstrapProvidersContent = await fs.readFile( + path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapProviders.ts'), + 'utf-8' + ); + + // Verify provider tokens are defined + expect(bootstrapProvidersContent).toContain('ENSURE_INITIAL_DATA_TOKEN'); + expect(bootstrapProvidersContent).toContain('SEED_DEMO_USERS_TOKEN'); + }); + }); + + describe('Bootstrap Module Integration', () => { + it('should be imported in main app module', async () => { + const appModulePath = path.join(apiRoot, 'apps/api/src/app.module.ts'); + const appModuleContent = await fs.readFile(appModulePath, 'utf-8'); + + // Verify BootstrapModule is imported + expect(appModuleContent).toContain('BootstrapModule'); + expect(appModuleContent).toContain('./domain/bootstrap/BootstrapModule'); + }); + + it('should be included in app module imports', async () => { + const appModulePath = path.join(apiRoot, 'apps/api/src/app.module.ts'); + const appModuleContent = await fs.readFile(appModulePath, 'utf-8'); + + // Verify BootstrapModule is in imports array + expect(appModuleContent).toMatch(/imports:\s*\[[^\]]*BootstrapModule[^\]]*\]/s); + }); + }); + + describe('Bootstrap Module Tests', () => { + it('should have unit tests for BootstrapModule', async () => { + const bootstrapModuleTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.test.ts'); + const bootstrapModuleTestExists = await fs.access(bootstrapModuleTestPath).then(() => true).catch(() => false); + expect(bootstrapModuleTestExists).toBe(true); + }); + + it('should have postgres seed tests', async () => { + const postgresSeedTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/BootstrapModule.postgres-seed.test.ts'); + const postgresSeedTestExists = await fs.access(postgresSeedTestPath).then(() => true).catch(() => false); + expect(postgresSeedTestExists).toBe(true); + }); + + it('should have racing seed tests', async () => { + const racingSeedTestPath = path.join(apiRoot, 'apps/api/src/domain/bootstrap/RacingSeed.test.ts'); + const racingSeedTestExists = await fs.access(racingSeedTestPath).then(() => true).catch(() => false); + expect(racingSeedTestExists).toBe(true); + }); + }); + + describe('Bootstrap Module Contract Summary', () => { + it('should document that bootstrap is an internal module', async () => { + const bootstrapModuleContent = await fs.readFile(bootstrapModulePath, 'utf-8'); + + // Verify bootstrap is documented as internal initialization + expect(bootstrapModuleContent).toContain('Initializing application data'); + expect(bootstrapModuleContent).toContain('Bootstrap disabled'); + }); + + it('should have no API client in website app', async () => { + const websiteApiDir = path.join(apiRoot, 'apps/website/lib/api'); + const apiFiles = await fs.readdir(websiteApiDir); + + // Verify no bootstrap API client exists + const bootstrapFiles = apiFiles.filter(f => f.toLowerCase().includes('bootstrap')); + expect(bootstrapFiles.length).toBe(0); + }); + + it('should have no bootstrap endpoints in OpenAPI', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify no bootstrap paths exist + const allPaths = Object.keys(spec.paths); + const bootstrapPaths = allPaths.filter(p => p.toLowerCase().includes('bootstrap')); + expect(bootstrapPaths.length).toBe(0); + }); + }); +}); diff --git a/tests/contracts/dashboard-contract.test.ts b/tests/contracts/dashboard-contract.test.ts new file mode 100644 index 000000000..6cb3199b8 --- /dev/null +++ b/tests/contracts/dashboard-contract.test.ts @@ -0,0 +1,1073 @@ +/** + * Contract Validation Tests for Dashboard Module + * + * These tests validate that the dashboard API DTOs and OpenAPI spec are consistent + * and that the generated types will be compatible with the website dashboard client. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +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('Dashboard Module Contract Validation', () => { + const apiRoot = path.join(__dirname, '../..'); + const openapiPath = path.join(apiRoot, 'apps/api/openapi.json'); + const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated'); + const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types'); + + describe('OpenAPI Spec Integrity for Dashboard Endpoints', () => { + it('should have dashboard endpoints defined in OpenAPI spec', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for dashboard endpoints + expect(spec.paths['/dashboard/overview']).toBeDefined(); + + // Verify GET methods exist + expect(spec.paths['/dashboard/overview'].get).toBeDefined(); + }); + + it('should have DashboardOverviewDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardOverviewDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('myUpcomingRaces'); + expect(schema.required).toContain('otherUpcomingRaces'); + expect(schema.required).toContain('upcomingRaces'); + expect(schema.required).toContain('activeLeaguesCount'); + expect(schema.required).toContain('recentResults'); + expect(schema.required).toContain('leagueStandingsSummaries'); + expect(schema.required).toContain('feedSummary'); + expect(schema.required).toContain('friends'); + + // Verify optional fields + expect(schema.properties?.currentDriver).toBeDefined(); + expect(schema.properties?.nextRace).toBeDefined(); + }); + + it('should have DashboardDriverSummaryDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardDriverSummaryDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('country'); + expect(schema.required).toContain('totalRaces'); + expect(schema.required).toContain('wins'); + expect(schema.required).toContain('podiums'); + + // Verify optional fields + expect(schema.properties?.avatarUrl).toBeDefined(); + expect(schema.properties?.category).toBeDefined(); + expect(schema.properties?.rating).toBeDefined(); + expect(schema.properties?.globalRank).toBeDefined(); + expect(schema.properties?.consistency).toBeDefined(); + }); + + it('should have DashboardRaceSummaryDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardRaceSummaryDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('track'); + expect(schema.required).toContain('car'); + expect(schema.required).toContain('scheduledAt'); + expect(schema.required).toContain('status'); + expect(schema.required).toContain('isMyLeague'); + + // Verify optional fields + expect(schema.properties?.leagueId).toBeDefined(); + expect(schema.properties?.leagueName).toBeDefined(); + }); + + it('should have DashboardRecentResultDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardRecentResultDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('raceId'); + expect(schema.required).toContain('raceName'); + expect(schema.required).toContain('finishedAt'); + expect(schema.required).toContain('position'); + expect(schema.required).toContain('incidents'); + + // Verify optional fields + expect(schema.properties?.leagueId).toBeDefined(); + expect(schema.properties?.leagueName).toBeDefined(); + }); + + it('should have DashboardLeagueStandingSummaryDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardLeagueStandingSummaryDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('leagueId'); + expect(schema.required).toContain('leagueName'); + expect(schema.required).toContain('totalDrivers'); + + // Verify optional fields + expect(schema.properties?.position).toBeDefined(); + expect(schema.properties?.points).toBeDefined(); + }); + + it('should have DashboardFeedItemSummaryDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardFeedItemSummaryDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('type'); + expect(schema.required).toContain('headline'); + expect(schema.required).toContain('timestamp'); + + // Verify optional fields + expect(schema.properties?.body).toBeDefined(); + expect(schema.properties?.ctaLabel).toBeDefined(); + expect(schema.properties?.ctaHref).toBeDefined(); + }); + + it('should have DashboardFeedSummaryDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardFeedSummaryDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('notificationCount'); + expect(schema.required).toContain('items'); + }); + + it('should have DashboardFriendSummaryDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardFriendSummaryDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('country'); + + // Verify optional fields + expect(schema.properties?.avatarUrl).toBeDefined(); + }); + + it('should have proper request/response structure for dashboard overview endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardPath = spec.paths['/dashboard/overview']?.get; + expect(dashboardPath).toBeDefined(); + + // Verify query parameters + expect(dashboardPath.parameters).toBeDefined(); + const driverIdParam = dashboardPath.parameters.find((p: any) => p.name === 'driverId'); + expect(driverIdParam).toBeDefined(); + expect(driverIdParam.in).toBe('query'); + + // Verify response + const response200 = dashboardPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json'].schema.$ref).toBe('#/components/schemas/DashboardOverviewDTO'); + }); + }); + + describe('DTO Consistency', () => { + it('should have generated DTO files for dashboard 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', '')); + + // Check for dashboard-related DTOs + const dashboardDTOs = [ + 'DashboardOverviewDTO', + 'DashboardDriverSummaryDTO', + 'DashboardRaceSummaryDTO', + 'DashboardRecentResultDTO', + 'DashboardLeagueStandingSummaryDTO', + 'DashboardFeedItemSummaryDTO', + 'DashboardFeedSummaryDTO', + 'DashboardFriendSummaryDTO', + ]; + + for (const dtoName of dashboardDTOs) { + 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; + + // Test DashboardOverviewDTO + const dashboardOverviewSchema = schemas['DashboardOverviewDTO']; + const dashboardOverviewDtoPath = path.join(generatedTypesDir, 'DashboardOverviewDTO.ts'); + const dashboardOverviewDtoExists = await fs.access(dashboardOverviewDtoPath).then(() => true).catch(() => false); + + if (dashboardOverviewDtoExists) { + const dashboardOverviewDtoContent = await fs.readFile(dashboardOverviewDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardOverviewSchema.required) { + for (const requiredProp of dashboardOverviewSchema.required) { + expect(dashboardOverviewDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardOverviewSchema.properties) { + for (const propName of Object.keys(dashboardOverviewSchema.properties)) { + expect(dashboardOverviewDtoContent).toContain(propName); + } + } + } + + // Test DashboardDriverSummaryDTO + const dashboardDriverSummarySchema = schemas['DashboardDriverSummaryDTO']; + const dashboardDriverSummaryDtoPath = path.join(generatedTypesDir, 'DashboardDriverSummaryDTO.ts'); + const dashboardDriverSummaryDtoExists = await fs.access(dashboardDriverSummaryDtoPath).then(() => true).catch(() => false); + + if (dashboardDriverSummaryDtoExists) { + const dashboardDriverSummaryDtoContent = await fs.readFile(dashboardDriverSummaryDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardDriverSummarySchema.required) { + for (const requiredProp of dashboardDriverSummarySchema.required) { + expect(dashboardDriverSummaryDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardDriverSummarySchema.properties) { + for (const propName of Object.keys(dashboardDriverSummarySchema.properties)) { + expect(dashboardDriverSummaryDtoContent).toContain(propName); + } + } + } + + // Test DashboardRaceSummaryDTO + const dashboardRaceSummarySchema = schemas['DashboardRaceSummaryDTO']; + const dashboardRaceSummaryDtoPath = path.join(generatedTypesDir, 'DashboardRaceSummaryDTO.ts'); + const dashboardRaceSummaryDtoExists = await fs.access(dashboardRaceSummaryDtoPath).then(() => true).catch(() => false); + + if (dashboardRaceSummaryDtoExists) { + const dashboardRaceSummaryDtoContent = await fs.readFile(dashboardRaceSummaryDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardRaceSummarySchema.required) { + for (const requiredProp of dashboardRaceSummarySchema.required) { + expect(dashboardRaceSummaryDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardRaceSummarySchema.properties) { + for (const propName of Object.keys(dashboardRaceSummarySchema.properties)) { + expect(dashboardRaceSummaryDtoContent).toContain(propName); + } + } + } + + // Test DashboardRecentResultDTO + const dashboardRecentResultSchema = schemas['DashboardRecentResultDTO']; + const dashboardRecentResultDtoPath = path.join(generatedTypesDir, 'DashboardRecentResultDTO.ts'); + const dashboardRecentResultDtoExists = await fs.access(dashboardRecentResultDtoPath).then(() => true).catch(() => false); + + if (dashboardRecentResultDtoExists) { + const dashboardRecentResultDtoContent = await fs.readFile(dashboardRecentResultDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardRecentResultSchema.required) { + for (const requiredProp of dashboardRecentResultSchema.required) { + expect(dashboardRecentResultDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardRecentResultSchema.properties) { + for (const propName of Object.keys(dashboardRecentResultSchema.properties)) { + expect(dashboardRecentResultDtoContent).toContain(propName); + } + } + } + + // Test DashboardLeagueStandingSummaryDTO + const dashboardLeagueStandingSchema = schemas['DashboardLeagueStandingSummaryDTO']; + const dashboardLeagueStandingDtoPath = path.join(generatedTypesDir, 'DashboardLeagueStandingSummaryDTO.ts'); + const dashboardLeagueStandingDtoExists = await fs.access(dashboardLeagueStandingDtoPath).then(() => true).catch(() => false); + + if (dashboardLeagueStandingDtoExists) { + const dashboardLeagueStandingDtoContent = await fs.readFile(dashboardLeagueStandingDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardLeagueStandingSchema.required) { + for (const requiredProp of dashboardLeagueStandingSchema.required) { + expect(dashboardLeagueStandingDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardLeagueStandingSchema.properties) { + for (const propName of Object.keys(dashboardLeagueStandingSchema.properties)) { + expect(dashboardLeagueStandingDtoContent).toContain(propName); + } + } + } + + // Test DashboardFeedItemSummaryDTO + const dashboardFeedItemSchema = schemas['DashboardFeedItemSummaryDTO']; + const dashboardFeedItemDtoPath = path.join(generatedTypesDir, 'DashboardFeedItemSummaryDTO.ts'); + const dashboardFeedItemDtoExists = await fs.access(dashboardFeedItemDtoPath).then(() => true).catch(() => false); + + if (dashboardFeedItemDtoExists) { + const dashboardFeedItemDtoContent = await fs.readFile(dashboardFeedItemDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardFeedItemSchema.required) { + for (const requiredProp of dashboardFeedItemSchema.required) { + expect(dashboardFeedItemDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardFeedItemSchema.properties) { + for (const propName of Object.keys(dashboardFeedItemSchema.properties)) { + expect(dashboardFeedItemDtoContent).toContain(propName); + } + } + } + + // Test DashboardFeedSummaryDTO + const dashboardFeedSummarySchema = schemas['DashboardFeedSummaryDTO']; + const dashboardFeedSummaryDtoPath = path.join(generatedTypesDir, 'DashboardFeedSummaryDTO.ts'); + const dashboardFeedSummaryDtoExists = await fs.access(dashboardFeedSummaryDtoPath).then(() => true).catch(() => false); + + if (dashboardFeedSummaryDtoExists) { + const dashboardFeedSummaryDtoContent = await fs.readFile(dashboardFeedSummaryDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardFeedSummarySchema.required) { + for (const requiredProp of dashboardFeedSummarySchema.required) { + expect(dashboardFeedSummaryDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardFeedSummarySchema.properties) { + for (const propName of Object.keys(dashboardFeedSummarySchema.properties)) { + expect(dashboardFeedSummaryDtoContent).toContain(propName); + } + } + } + + // Test DashboardFriendSummaryDTO + const dashboardFriendSummarySchema = schemas['DashboardFriendSummaryDTO']; + const dashboardFriendSummaryDtoPath = path.join(generatedTypesDir, 'DashboardFriendSummaryDTO.ts'); + const dashboardFriendSummaryDtoExists = await fs.access(dashboardFriendSummaryDtoPath).then(() => true).catch(() => false); + + if (dashboardFriendSummaryDtoExists) { + const dashboardFriendSummaryDtoContent = await fs.readFile(dashboardFriendSummaryDtoPath, 'utf-8'); + + // Check that all required properties are present + if (dashboardFriendSummarySchema.required) { + for (const requiredProp of dashboardFriendSummarySchema.required) { + expect(dashboardFriendSummaryDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (dashboardFriendSummarySchema.properties) { + for (const propName of Object.keys(dashboardFriendSummarySchema.properties)) { + expect(dashboardFriendSummaryDtoContent).toContain(propName); + } + } + } + }); + + it('should have dashboard types defined in tbd folder', async () => { + // Check if dashboard types exist in tbd folder (similar to admin types) + const tbdDir = path.join(websiteTypesDir, 'tbd'); + const tbdFiles = await fs.readdir(tbdDir).catch(() => []); + + // Dashboard types might be in a separate file or combined with existing types + // For now, we'll check if the generated types are properly available + const generatedFiles = await fs.readdir(generatedTypesDir); + const dashboardGenerated = generatedFiles.filter(f => + f.includes('Dashboard') || + f.includes('Driver') || + f.includes('Race') || + f.includes('Feed') || + f.includes('Friend') || + f.includes('League') + ); + + expect(dashboardGenerated.length).toBeGreaterThanOrEqual(8); + }); + + it('should have dashboard types re-exported from main types file', async () => { + // Check if there's a dashboard.ts file or if types are exported elsewhere + const dashboardTypesPath = path.join(websiteTypesDir, 'dashboard.ts'); + const dashboardTypesExists = await fs.access(dashboardTypesPath).then(() => true).catch(() => false); + + if (dashboardTypesExists) { + const dashboardTypesContent = await fs.readFile(dashboardTypesPath, 'utf-8'); + + // Verify re-exports + expect(dashboardTypesContent).toContain('DashboardOverviewDTO'); + expect(dashboardTypesContent).toContain('DashboardDriverSummaryDTO'); + expect(dashboardTypesContent).toContain('DashboardRaceSummaryDTO'); + expect(dashboardTypesContent).toContain('DashboardRecentResultDTO'); + expect(dashboardTypesContent).toContain('DashboardLeagueStandingSummaryDTO'); + expect(dashboardTypesContent).toContain('DashboardFeedItemSummaryDTO'); + expect(dashboardTypesContent).toContain('DashboardFeedSummaryDTO'); + expect(dashboardTypesContent).toContain('DashboardFriendSummaryDTO'); + } + }); + }); + + describe('Dashboard API Client Contract', () => { + it('should have DashboardApiClient defined', async () => { + const dashboardApiClientPath = path.join(apiRoot, 'apps/website/lib/api/dashboard/DashboardApiClient.ts'); + const dashboardApiClientExists = await fs.access(dashboardApiClientPath).then(() => true).catch(() => false); + + expect(dashboardApiClientExists).toBe(true); + + const dashboardApiClientContent = await fs.readFile(dashboardApiClientPath, 'utf-8'); + + // Verify class definition + expect(dashboardApiClientContent).toContain('export class DashboardApiClient'); + expect(dashboardApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods exist + expect(dashboardApiClientContent).toContain('getDashboardOverview'); + + // Verify method signatures + expect(dashboardApiClientContent).toContain('getDashboardOverview()'); + }); + + it('should have proper request construction in getDashboardOverview method', async () => { + const dashboardApiClientPath = path.join(apiRoot, 'apps/website/lib/api/dashboard/DashboardApiClient.ts'); + const dashboardApiClientContent = await fs.readFile(dashboardApiClientPath, 'utf-8'); + + // Verify GET request + expect(dashboardApiClientContent).toContain("return this.get('/dashboard/overview')"); + }); + }); + + describe('Request Correctness Tests', () => { + it('should validate DashboardOverviewDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardOverviewDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('myUpcomingRaces'); + expect(schema.required).toContain('otherUpcomingRaces'); + expect(schema.required).toContain('upcomingRaces'); + expect(schema.required).toContain('activeLeaguesCount'); + expect(schema.required).toContain('recentResults'); + expect(schema.required).toContain('leagueStandingsSummaries'); + expect(schema.required).toContain('feedSummary'); + expect(schema.required).toContain('friends'); + + // Verify no extra required fields + expect(schema.required.length).toBe(8); + }); + + it('should validate DashboardOverviewDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardOverviewDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('currentDriver'); + expect(schema.required).not.toContain('nextRace'); + + // Verify optional fields exist + expect(schema.properties?.currentDriver).toBeDefined(); + expect(schema.properties?.nextRace).toBeDefined(); + }); + + it('should validate DashboardDriverSummaryDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardDriverSummaryDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('id'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('country'); + expect(schema.required).toContain('totalRaces'); + expect(schema.required).toContain('wins'); + expect(schema.required).toContain('podiums'); + + // Verify no extra required fields + expect(schema.required.length).toBe(6); + }); + + it('should validate DashboardDriverSummaryDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardDriverSummaryDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('avatarUrl'); + expect(schema.required).not.toContain('category'); + expect(schema.required).not.toContain('rating'); + expect(schema.required).not.toContain('globalRank'); + expect(schema.required).not.toContain('consistency'); + + // Verify optional fields exist + expect(schema.properties?.avatarUrl).toBeDefined(); + expect(schema.properties?.category).toBeDefined(); + expect(schema.properties?.rating).toBeDefined(); + expect(schema.properties?.globalRank).toBeDefined(); + expect(schema.properties?.consistency).toBeDefined(); + }); + + it('should validate DashboardRaceSummaryDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardRaceSummaryDTO']; + + // Verify all required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('track'); + expect(schema.required).toContain('car'); + expect(schema.required).toContain('scheduledAt'); + expect(schema.required).toContain('status'); + expect(schema.required).toContain('isMyLeague'); + + // Verify field types + expect(schema.properties?.id?.type).toBe('string'); + expect(schema.properties?.track?.type).toBe('string'); + expect(schema.properties?.car?.type).toBe('string'); + expect(schema.properties?.scheduledAt?.type).toBe('string'); + expect(schema.properties?.status?.type).toBe('string'); + expect(schema.properties?.isMyLeague?.type).toBe('boolean'); + }); + + it('should validate DashboardRecentResultDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardRecentResultDTO']; + + // Verify all required fields + expect(schema.required).toContain('raceId'); + expect(schema.required).toContain('raceName'); + expect(schema.required).toContain('finishedAt'); + expect(schema.required).toContain('position'); + expect(schema.required).toContain('incidents'); + + // Verify field types + expect(schema.properties?.raceId?.type).toBe('string'); + expect(schema.properties?.raceName?.type).toBe('string'); + expect(schema.properties?.finishedAt?.type).toBe('string'); + expect(schema.properties?.position?.type).toBe('number'); + expect(schema.properties?.incidents?.type).toBe('number'); + }); + + it('should validate DashboardLeagueStandingSummaryDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardLeagueStandingSummaryDTO']; + + // Verify all required fields + expect(schema.required).toContain('leagueId'); + expect(schema.required).toContain('leagueName'); + expect(schema.required).toContain('totalDrivers'); + + // Verify field types + expect(schema.properties?.leagueId?.type).toBe('string'); + expect(schema.properties?.leagueName?.type).toBe('string'); + expect(schema.properties?.totalDrivers?.type).toBe('number'); + }); + + it('should validate DashboardFeedItemSummaryDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardFeedItemSummaryDTO']; + + // Verify all required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('type'); + expect(schema.required).toContain('headline'); + expect(schema.required).toContain('timestamp'); + + // Verify field types + expect(schema.properties?.id?.type).toBe('string'); + expect(schema.properties?.type?.type).toBe('string'); + expect(schema.properties?.headline?.type).toBe('string'); + expect(schema.properties?.timestamp?.type).toBe('string'); + }); + + it('should validate DashboardFeedSummaryDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardFeedSummaryDTO']; + + // Verify all required fields + expect(schema.required).toContain('notificationCount'); + expect(schema.required).toContain('items'); + + // Verify field types + expect(schema.properties?.notificationCount?.type).toBe('number'); + expect(schema.properties?.items?.type).toBe('array'); + }); + + it('should validate DashboardFriendSummaryDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DashboardFriendSummaryDTO']; + + // Verify all required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('country'); + + // Verify field types + expect(schema.properties?.id?.type).toBe('string'); + expect(schema.properties?.name?.type).toBe('string'); + expect(schema.properties?.country?.type).toBe('string'); + }); + }); + + describe('Response Handling Tests', () => { + it('should handle successful dashboard overview response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardOverviewSchema = spec.components.schemas['DashboardOverviewDTO']; + + // Verify response structure + expect(dashboardOverviewSchema.properties?.currentDriver).toBeDefined(); + expect(dashboardOverviewSchema.properties?.myUpcomingRaces).toBeDefined(); + expect(dashboardOverviewSchema.properties?.otherUpcomingRaces).toBeDefined(); + expect(dashboardOverviewSchema.properties?.upcomingRaces).toBeDefined(); + expect(dashboardOverviewSchema.properties?.activeLeaguesCount).toBeDefined(); + expect(dashboardOverviewSchema.properties?.nextRace).toBeDefined(); + expect(dashboardOverviewSchema.properties?.recentResults).toBeDefined(); + expect(dashboardOverviewSchema.properties?.leagueStandingsSummaries).toBeDefined(); + expect(dashboardOverviewSchema.properties?.feedSummary).toBeDefined(); + expect(dashboardOverviewSchema.properties?.friends).toBeDefined(); + }); + + it('should handle response with all required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardOverviewSchema = spec.components.schemas['DashboardOverviewDTO']; + + // Verify all required fields are present + for (const field of ['myUpcomingRaces', 'otherUpcomingRaces', 'upcomingRaces', 'activeLeaguesCount', 'recentResults', 'leagueStandingsSummaries', 'feedSummary', 'friends']) { + expect(dashboardOverviewSchema.required).toContain(field); + expect(dashboardOverviewSchema.properties?.[field]).toBeDefined(); + } + }); + + it('should handle optional fields in driver response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const driverSchema = spec.components.schemas['DashboardDriverSummaryDTO']; + + // Verify optional fields are nullable + expect(driverSchema.properties?.avatarUrl?.nullable).toBe(true); + expect(driverSchema.properties?.category?.nullable).toBe(true); + expect(driverSchema.properties?.rating?.nullable).toBe(true); + expect(driverSchema.properties?.globalRank?.nullable).toBe(true); + expect(driverSchema.properties?.consistency?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(driverSchema.required).not.toContain('avatarUrl'); + expect(driverSchema.required).not.toContain('category'); + expect(driverSchema.required).not.toContain('rating'); + expect(driverSchema.required).not.toContain('globalRank'); + expect(driverSchema.required).not.toContain('consistency'); + }); + + it('should handle optional fields in race response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const raceSchema = spec.components.schemas['DashboardRaceSummaryDTO']; + + // Verify optional fields are nullable + expect(raceSchema.properties?.leagueId?.nullable).toBe(true); + expect(raceSchema.properties?.leagueName?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(raceSchema.required).not.toContain('leagueId'); + expect(raceSchema.required).not.toContain('leagueName'); + }); + + it('should handle optional fields in recent result response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const recentResultSchema = spec.components.schemas['DashboardRecentResultDTO']; + + // Verify optional fields are nullable + expect(recentResultSchema.properties?.leagueId?.nullable).toBe(true); + expect(recentResultSchema.properties?.leagueName?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(recentResultSchema.required).not.toContain('leagueId'); + expect(recentResultSchema.required).not.toContain('leagueName'); + }); + + it('should handle optional fields in league standing response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const leagueStandingSchema = spec.components.schemas['DashboardLeagueStandingSummaryDTO']; + + // Verify optional fields are nullable + expect(leagueStandingSchema.properties?.position?.nullable).toBe(true); + expect(leagueStandingSchema.properties?.points?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(leagueStandingSchema.required).not.toContain('position'); + expect(leagueStandingSchema.required).not.toContain('points'); + }); + + it('should handle optional fields in feed item response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const feedItemSchema = spec.components.schemas['DashboardFeedItemSummaryDTO']; + + // Verify optional fields are nullable + expect(feedItemSchema.properties?.body?.nullable).toBe(true); + expect(feedItemSchema.properties?.ctaLabel?.nullable).toBe(true); + expect(feedItemSchema.properties?.ctaHref?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(feedItemSchema.required).not.toContain('body'); + expect(feedItemSchema.required).not.toContain('ctaLabel'); + expect(feedItemSchema.required).not.toContain('ctaHref'); + }); + + it('should handle optional fields in friend response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const friendSchema = spec.components.schemas['DashboardFriendSummaryDTO']; + + // Verify optional fields are nullable + expect(friendSchema.properties?.avatarUrl?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(friendSchema.required).not.toContain('avatarUrl'); + }); + }); + + describe('Error Handling Tests', () => { + it('should document 401 Unauthorized response for dashboard overview endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardPath = spec.paths['/dashboard/overview']?.get; + + // Check if 401 response is documented + if (dashboardPath.responses['401']) { + expect(dashboardPath.responses['401']).toBeDefined(); + } + }); + + it('should document 500 Internal Server Error response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardPath = spec.paths['/dashboard/overview']?.get; + + // Check if 500 response is documented for dashboard endpoint + if (dashboardPath.responses['500']) { + expect(dashboardPath.responses['500']).toBeDefined(); + } + }); + + it('should have proper error handling in DashboardApiClient', async () => { + const dashboardApiClientPath = path.join(apiRoot, 'apps/website/lib/api/dashboard/DashboardApiClient.ts'); + const dashboardApiClientContent = await fs.readFile(dashboardApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(dashboardApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(dashboardApiClientContent).toContain('this.get<'); + }); + }); + + describe('Semantic Guarantee Tests', () => { + it('should maintain consistency between request and response schemas', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify dashboard overview request/response consistency + const dashboardOverviewSchema = spec.components.schemas['DashboardOverviewDTO']; + + // Output should contain all required fields + expect(dashboardOverviewSchema.properties?.myUpcomingRaces).toBeDefined(); + expect(dashboardOverviewSchema.properties?.otherUpcomingRaces).toBeDefined(); + expect(dashboardOverviewSchema.properties?.upcomingRaces).toBeDefined(); + expect(dashboardOverviewSchema.properties?.activeLeaguesCount).toBeDefined(); + expect(dashboardOverviewSchema.properties?.recentResults).toBeDefined(); + expect(dashboardOverviewSchema.properties?.leagueStandingsSummaries).toBeDefined(); + expect(dashboardOverviewSchema.properties?.feedSummary).toBeDefined(); + expect(dashboardOverviewSchema.properties?.friends).toBeDefined(); + }); + + it('should validate semantic consistency in dashboard overview', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardOverviewSchema = spec.components.schemas['DashboardOverviewDTO']; + + // Verify overview has all required fields + expect(dashboardOverviewSchema.required).toContain('myUpcomingRaces'); + expect(dashboardOverviewSchema.required).toContain('otherUpcomingRaces'); + expect(dashboardOverviewSchema.required).toContain('upcomingRaces'); + expect(dashboardOverviewSchema.required).toContain('activeLeaguesCount'); + expect(dashboardOverviewSchema.required).toContain('recentResults'); + expect(dashboardOverviewSchema.required).toContain('leagueStandingsSummaries'); + expect(dashboardOverviewSchema.required).toContain('feedSummary'); + expect(dashboardOverviewSchema.required).toContain('friends'); + }); + + it('should validate idempotency for dashboard overview', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check if dashboard overview endpoint exists + const dashboardPath = spec.paths['/dashboard/overview']?.get; + if (dashboardPath) { + // Verify it's a GET request (idempotent) + expect(dashboardPath).toBeDefined(); + + // Verify no request body (GET requests are idempotent) + expect(dashboardPath.requestBody).toBeUndefined(); + } + }); + + it('should validate uniqueness constraints for driver and race IDs', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const driverSchema = spec.components.schemas['DashboardDriverSummaryDTO']; + const raceSchema = spec.components.schemas['DashboardRaceSummaryDTO']; + + // Verify driver ID is a required field + expect(driverSchema.required).toContain('id'); + expect(driverSchema.properties?.id?.type).toBe('string'); + + // Verify race ID is a required field + expect(raceSchema.required).toContain('id'); + expect(raceSchema.properties?.id?.type).toBe('string'); + }); + + it('should validate consistency between request and response types', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify all DTOs have consistent type definitions + const dtos = [ + 'DashboardOverviewDTO', + 'DashboardDriverSummaryDTO', + 'DashboardRaceSummaryDTO', + 'DashboardRecentResultDTO', + 'DashboardLeagueStandingSummaryDTO', + 'DashboardFeedItemSummaryDTO', + 'DashboardFeedSummaryDTO', + 'DashboardFriendSummaryDTO', + ]; + + for (const dtoName of dtos) { + const schema = spec.components.schemas[dtoName]; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // All should have properties defined + expect(schema.properties).toBeDefined(); + + // All should have required fields (even if empty array) + expect(schema.required).toBeDefined(); + } + }); + + it('should validate semantic consistency in dashboard data', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const dashboardOverviewSchema = spec.components.schemas['DashboardOverviewDTO']; + + // Verify upcoming races are arrays + expect(dashboardOverviewSchema.properties?.myUpcomingRaces?.type).toBe('array'); + expect(dashboardOverviewSchema.properties?.otherUpcomingRaces?.type).toBe('array'); + expect(dashboardOverviewSchema.properties?.upcomingRaces?.type).toBe('array'); + + // Verify recent results are arrays + expect(dashboardOverviewSchema.properties?.recentResults?.type).toBe('array'); + + // Verify league standings are arrays + expect(dashboardOverviewSchema.properties?.leagueStandingsSummaries?.type).toBe('array'); + + // Verify friends are arrays + expect(dashboardOverviewSchema.properties?.friends?.type).toBe('array'); + + // Verify activeLeaguesCount is a number + expect(dashboardOverviewSchema.properties?.activeLeaguesCount?.type).toBe('number'); + }); + + it('should validate pagination is not applicable for dashboard overview', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Dashboard overview should not have pagination + const dashboardPath = spec.paths['/dashboard/overview']; + if (dashboardPath) { + // Check if there are any query parameters for pagination + const methods = Object.keys(dashboardPath); + for (const method of methods) { + const operation = dashboardPath[method]; + if (operation.parameters) { + const paramNames = operation.parameters.map((p: any) => p.name); + // Dashboard overview should not have page/limit parameters + expect(paramNames).not.toContain('page'); + expect(paramNames).not.toContain('limit'); + } + } + } + }); + }); + + describe('Dashboard Module Integration Tests', () => { + it('should have consistent types between API DTOs and website types', 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', '')); + + // Check all dashboard DTOs exist in generated types + const dashboardDTOs = [ + 'DashboardOverviewDTO', + 'DashboardDriverSummaryDTO', + 'DashboardRaceSummaryDTO', + 'DashboardRecentResultDTO', + 'DashboardLeagueStandingSummaryDTO', + 'DashboardFeedItemSummaryDTO', + 'DashboardFeedSummaryDTO', + 'DashboardFriendSummaryDTO', + ]; + + for (const dtoName of dashboardDTOs) { + expect(spec.components.schemas[dtoName]).toBeDefined(); + expect(generatedDTOs).toContain(dtoName); + } + }); + + it('should have DashboardApiClient methods matching API endpoints', async () => { + const dashboardApiClientPath = path.join(apiRoot, 'apps/website/lib/api/dashboard/DashboardApiClient.ts'); + const dashboardApiClientContent = await fs.readFile(dashboardApiClientPath, 'utf-8'); + + // Verify getDashboardOverview method exists and uses correct endpoint + expect(dashboardApiClientContent).toContain('async getDashboardOverview'); + expect(dashboardApiClientContent).toContain("return this.get('/dashboard/overview')"); + }); + + it('should have proper error handling in DashboardApiClient', async () => { + const dashboardApiClientPath = path.join(apiRoot, 'apps/website/lib/api/dashboard/DashboardApiClient.ts'); + const dashboardApiClientContent = await fs.readFile(dashboardApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(dashboardApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(dashboardApiClientContent).toContain('this.get<'); + }); + + it('should have consistent type imports in DashboardApiClient', async () => { + const dashboardApiClientPath = path.join(apiRoot, 'apps/website/lib/api/dashboard/DashboardApiClient.ts'); + const dashboardApiClientContent = await fs.readFile(dashboardApiClientPath, 'utf-8'); + + // Verify all required types are imported + expect(dashboardApiClientContent).toContain('DashboardOverviewDTO'); + }); + }); +}); diff --git a/tests/contracts/driver-contract.test.ts b/tests/contracts/driver-contract.test.ts new file mode 100644 index 000000000..5241ea9a5 --- /dev/null +++ b/tests/contracts/driver-contract.test.ts @@ -0,0 +1,1328 @@ +import { describe, expect, it } from 'vitest'; + +/** + * Contract Validation Tests for Driver Module + * + * These tests validate that the driver API DTOs and OpenAPI spec are consistent + * and that the generated types will be compatible with the website driver client. + */ + +import * as fs from 'fs/promises'; +import * as path from 'path'; +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; + responses: Record; + }>; + requestBody?: { + content: Record; + }; + }; + post?: { + parameters?: Array<{ name: string; in: string; schema?: OpenAPISchema }>; + responses: Record; + }>; + requestBody?: { + content: Record; + }; + }; + put?: { + parameters?: Array<{ name: string; in: string; schema?: OpenAPISchema }>; + responses: Record; + }>; + requestBody?: { + content: Record; + }; + }; + patch?: { + parameters?: Array<{ name: string; in: string; schema?: OpenAPISchema }>; + responses: Record; + }>; + requestBody?: { + content: Record; + }; + }; + delete?: { + parameters?: Array<{ name: string; in: string; schema?: OpenAPISchema }>; + responses: Record; + }>; + }; + parameters?: Array<{ name: string; in: string; schema?: OpenAPISchema }>; + }>; + components: { + schemas: Record; + }; +} + +describe('Driver Module Contract Validation', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + + const apiRoot = path.join(__dirname, '../..'); + const openapiPath = path.join(apiRoot, 'apps/api/openapi.json'); + const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated'); + const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types'); + + describe('OpenAPI Spec Integrity for Driver Endpoints', () => { + it('should have driver endpoints defined in OpenAPI spec', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check for driver endpoints + expect(spec.paths['/drivers/leaderboard']).toBeDefined(); + expect(spec.paths['/drivers/total-drivers']).toBeDefined(); + expect(spec.paths['/drivers/current']).toBeDefined(); + expect(spec.paths['/drivers/{driverId}']).toBeDefined(); + expect(spec.paths['/drivers/{driverId}/profile']).toBeDefined(); + expect(spec.paths['/drivers/{driverId}/liveries']).toBeDefined(); + expect(spec.paths['/drivers/{driverId}/races/{raceId}/registration-status']).toBeDefined(); + expect(spec.paths['/drivers/complete-onboarding']).toBeDefined(); + + // Verify GET methods exist + expect(spec.paths['/drivers/leaderboard'].get).toBeDefined(); + expect(spec.paths['/drivers/total-drivers'].get).toBeDefined(); + expect(spec.paths['/drivers/current'].get).toBeDefined(); + expect(spec.paths['/drivers/{driverId}'].get).toBeDefined(); + expect(spec.paths['/drivers/{driverId}/profile'].get).toBeDefined(); + expect(spec.paths['/drivers/{driverId}/liveries'].get).toBeDefined(); + expect(spec.paths['/drivers/{driverId}/races/{raceId}/registration-status'].get).toBeDefined(); + + // Verify POST methods exist + expect(spec.paths['/drivers/complete-onboarding'].post).toBeDefined(); + }); + + it('should have GetDriverOutputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDriverOutputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('iracingId'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('country'); + expect(schema.required).toContain('joinedAt'); + + // Verify optional fields + expect(schema.properties?.bio).toBeDefined(); + expect(schema.properties?.category).toBeDefined(); + expect(schema.properties?.rating).toBeDefined(); + expect(schema.properties?.experienceLevel).toBeDefined(); + expect(schema.properties?.wins).toBeDefined(); + expect(schema.properties?.podiums).toBeDefined(); + expect(schema.properties?.totalRaces).toBeDefined(); + expect(schema.properties?.avatarUrl).toBeDefined(); + }); + + it('should have GetDriverProfileOutputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDriverProfileOutputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('teamMemberships'); + expect(schema.required).toContain('socialSummary'); + + // Verify optional fields + expect(schema.properties?.currentDriver).toBeDefined(); + expect(schema.properties?.stats).toBeDefined(); + expect(schema.properties?.finishDistribution).toBeDefined(); + expect(schema.properties?.extendedProfile).toBeDefined(); + }); + + it('should have DriverRegistrationStatusDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DriverRegistrationStatusDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('isRegistered'); + expect(schema.required).toContain('raceId'); + expect(schema.required).toContain('driverId'); + }); + + it('should have DriversLeaderboardDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DriversLeaderboardDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('drivers'); + expect(schema.required).toContain('totalRaces'); + expect(schema.required).toContain('totalWins'); + expect(schema.required).toContain('activeCount'); + }); + + it('should have DriverLeaderboardItemDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DriverLeaderboardItemDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('rating'); + expect(schema.required).toContain('skillLevel'); + expect(schema.required).toContain('nationality'); + expect(schema.required).toContain('racesCompleted'); + expect(schema.required).toContain('wins'); + expect(schema.required).toContain('podiums'); + expect(schema.required).toContain('isActive'); + expect(schema.required).toContain('rank'); + + // Verify optional fields + expect(schema.properties?.category).toBeDefined(); + expect(schema.properties?.avatarUrl).toBeDefined(); + }); + + it('should have CompleteOnboardingInputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['CompleteOnboardingInputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('firstName'); + expect(schema.required).toContain('lastName'); + expect(schema.required).toContain('displayName'); + expect(schema.required).toContain('country'); + + // Verify optional fields + expect(schema.properties?.timezone).toBeDefined(); + expect(schema.properties?.bio).toBeDefined(); + }); + + it('should have CompleteOnboardingOutputDTO schema defined', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['CompleteOnboardingOutputDTO']; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // Verify required fields + expect(schema.required).toContain('success'); + + // Verify optional fields + expect(schema.properties?.driverId).toBeDefined(); + expect(schema.properties?.errorMessage).toBeDefined(); + }); + + it('should have proper request/response structure for driver leaderboard endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const leaderboardPath = spec.paths['/drivers/leaderboard']?.get; + expect(leaderboardPath).toBeDefined(); + + // Verify response + const response200 = leaderboardPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json']?.schema.$ref).toBe('#/components/schemas/DriversLeaderboardDTO'); + }); + + it('should have proper request/response structure for get driver endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const driverPath = spec.paths['/drivers/{driverId}']?.get; + expect(driverPath).toBeDefined(); + + // Verify path parameters + expect(driverPath.parameters).toBeDefined(); + const driverIdParam = driverPath.parameters.find((p: { name: string }) => p.name === 'driverId'); + expect(driverIdParam).toBeDefined(); + expect(driverIdParam.in).toBe('path'); + + // Verify response + const response200 = driverPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json']?.schema.$ref).toBe('#/components/schemas/GetDriverOutputDTO'); + }); + + it('should have proper request/response structure for get driver profile endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const profilePath = spec.paths['/drivers/{driverId}/profile']?.get; + expect(profilePath).toBeDefined(); + + // Verify path parameters + expect(profilePath.parameters).toBeDefined(); + const driverIdParam = profilePath.parameters.find((p: { name: string }) => p.name === 'driverId'); + expect(driverIdParam).toBeDefined(); + expect(driverIdParam.in).toBe('path'); + + // Verify response + const response200 = profilePath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json']?.schema.$ref).toBe('#/components/schemas/GetDriverProfileOutputDTO'); + }); + + it('should have proper request/response structure for complete onboarding endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const onboardingPath = spec.paths['/drivers/complete-onboarding']?.post; + expect(onboardingPath).toBeDefined(); + + // Verify request body + expect(onboardingPath.requestBody).toBeDefined(); + expect(onboardingPath.requestBody?.content['application/json']?.schema.$ref).toBe('#/components/schemas/CompleteOnboardingInputDTO'); + + // Verify response + const response200 = onboardingPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json']?.schema.$ref).toBe('#/components/schemas/CompleteOnboardingOutputDTO'); + }); + + it('should have proper request/response structure for driver registration status endpoint', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const registrationPath = spec.paths['/drivers/{driverId}/races/{raceId}/registration-status']?.get; + expect(registrationPath).toBeDefined(); + + // Verify path parameters + expect(registrationPath.parameters).toBeDefined(); + const driverIdParam = registrationPath.parameters.find((p: { name: string }) => p.name === 'driverId'); + const raceIdParam = registrationPath.parameters.find((p: { name: string }) => p.name === 'raceId'); + expect(driverIdParam).toBeDefined(); + expect(raceIdParam).toBeDefined(); + expect(driverIdParam.in).toBe('path'); + expect(raceIdParam.in).toBe('path'); + + // Verify response + const response200 = registrationPath.responses['200']; + expect(response200).toBeDefined(); + expect(response200.content['application/json']?.schema.$ref).toBe('#/components/schemas/DriverRegistrationStatusDTO'); + }); + }); + + describe('DTO Consistency', () => { + it('should have generated DTO files for driver 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', '')); + + // Check for driver-related DTOs + const driverDTOs = [ + 'GetDriverOutputDTO', + 'GetDriverProfileOutputDTO', + 'DriverRegistrationStatusDTO', + 'DriversLeaderboardDTO', + 'DriverLeaderboardItemDTO', + 'CompleteOnboardingInputDTO', + 'CompleteOnboardingOutputDTO', + 'GetDriverLiveriesOutputDTO', + ]; + + for (const dtoName of driverDTOs) { + 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; + + // Test GetDriverOutputDTO + const getDriverSchema = schemas['GetDriverOutputDTO']; + const getDriverDtoPath = path.join(generatedTypesDir, 'GetDriverOutputDTO.ts'); + const getDriverDtoExists = await fs.access(getDriverDtoPath).then(() => true).catch(() => false); + + if (getDriverDtoExists) { + const getDriverDtoContent = await fs.readFile(getDriverDtoPath, 'utf-8'); + + // Check that all required properties are present + if (getDriverSchema.required) { + for (const requiredProp of getDriverSchema.required) { + expect(getDriverDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (getDriverSchema.properties) { + for (const propName of Object.keys(getDriverSchema.properties)) { + expect(getDriverDtoContent).toContain(propName); + } + } + } + + // Test GetDriverProfileOutputDTO + const getDriverProfileSchema = schemas['GetDriverProfileOutputDTO']; + const getDriverProfileDtoPath = path.join(generatedTypesDir, 'GetDriverProfileOutputDTO.ts'); + const getDriverProfileDtoExists = await fs.access(getDriverProfileDtoPath).then(() => true).catch(() => false); + + if (getDriverProfileDtoExists) { + const getDriverProfileDtoContent = await fs.readFile(getDriverProfileDtoPath, 'utf-8'); + + // Check that all required properties are present + if (getDriverProfileSchema.required) { + for (const requiredProp of getDriverProfileSchema.required) { + expect(getDriverProfileDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (getDriverProfileSchema.properties) { + for (const propName of Object.keys(getDriverProfileSchema.properties)) { + expect(getDriverProfileDtoContent).toContain(propName); + } + } + } + + // Test DriverRegistrationStatusDTO + const driverRegistrationSchema = schemas['DriverRegistrationStatusDTO']; + const driverRegistrationDtoPath = path.join(generatedTypesDir, 'DriverRegistrationStatusDTO.ts'); + const driverRegistrationDtoExists = await fs.access(driverRegistrationDtoPath).then(() => true).catch(() => false); + + if (driverRegistrationDtoExists) { + const driverRegistrationDtoContent = await fs.readFile(driverRegistrationDtoPath, 'utf-8'); + + // Check that all required properties are present + if (driverRegistrationSchema.required) { + for (const requiredProp of driverRegistrationSchema.required) { + expect(driverRegistrationDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (driverRegistrationSchema.properties) { + for (const propName of Object.keys(driverRegistrationSchema.properties)) { + expect(driverRegistrationDtoContent).toContain(propName); + } + } + } + + // Test DriversLeaderboardDTO + const driversLeaderboardSchema = schemas['DriversLeaderboardDTO']; + const driversLeaderboardDtoPath = path.join(generatedTypesDir, 'DriversLeaderboardDTO.ts'); + const driversLeaderboardDtoExists = await fs.access(driversLeaderboardDtoPath).then(() => true).catch(() => false); + + if (driversLeaderboardDtoExists) { + const driversLeaderboardDtoContent = await fs.readFile(driversLeaderboardDtoPath, 'utf-8'); + + // Check that all required properties are present + if (driversLeaderboardSchema.required) { + for (const requiredProp of driversLeaderboardSchema.required) { + expect(driversLeaderboardDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (driversLeaderboardSchema.properties) { + for (const propName of Object.keys(driversLeaderboardSchema.properties)) { + expect(driversLeaderboardDtoContent).toContain(propName); + } + } + } + + // Test DriverLeaderboardItemDTO + const driverLeaderboardItemSchema = schemas['DriverLeaderboardItemDTO']; + const driverLeaderboardItemDtoPath = path.join(generatedTypesDir, 'DriverLeaderboardItemDTO.ts'); + const driverLeaderboardItemDtoExists = await fs.access(driverLeaderboardItemDtoPath).then(() => true).catch(() => false); + + if (driverLeaderboardItemDtoExists) { + const driverLeaderboardItemDtoContent = await fs.readFile(driverLeaderboardItemDtoPath, 'utf-8'); + + // Check that all required properties are present + if (driverLeaderboardItemSchema.required) { + for (const requiredProp of driverLeaderboardItemSchema.required) { + expect(driverLeaderboardItemDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (driverLeaderboardItemSchema.properties) { + for (const propName of Object.keys(driverLeaderboardItemSchema.properties)) { + expect(driverLeaderboardItemDtoContent).toContain(propName); + } + } + } + + // Test CompleteOnboardingInputDTO + const completeOnboardingInputSchema = schemas['CompleteOnboardingInputDTO']; + const completeOnboardingInputDtoPath = path.join(generatedTypesDir, 'CompleteOnboardingInputDTO.ts'); + const completeOnboardingInputDtoExists = await fs.access(completeOnboardingInputDtoPath).then(() => true).catch(() => false); + + if (completeOnboardingInputDtoExists) { + const completeOnboardingInputDtoContent = await fs.readFile(completeOnboardingInputDtoPath, 'utf-8'); + + // Check that all required properties are present + if (completeOnboardingInputSchema.required) { + for (const requiredProp of completeOnboardingInputSchema.required) { + expect(completeOnboardingInputDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (completeOnboardingInputSchema.properties) { + for (const propName of Object.keys(completeOnboardingInputSchema.properties)) { + expect(completeOnboardingInputDtoContent).toContain(propName); + } + } + } + + // Test CompleteOnboardingOutputDTO + const completeOnboardingOutputSchema = schemas['CompleteOnboardingOutputDTO']; + const completeOnboardingOutputDtoPath = path.join(generatedTypesDir, 'CompleteOnboardingOutputDTO.ts'); + const completeOnboardingOutputDtoExists = await fs.access(completeOnboardingOutputDtoPath).then(() => true).catch(() => false); + + if (completeOnboardingOutputDtoExists) { + const completeOnboardingOutputDtoContent = await fs.readFile(completeOnboardingOutputDtoPath, 'utf-8'); + + // Check that all required properties are present + if (completeOnboardingOutputSchema.required) { + for (const requiredProp of completeOnboardingOutputSchema.required) { + expect(completeOnboardingOutputDtoContent).toContain(requiredProp); + } + } + + // Check that all properties are present + if (completeOnboardingOutputSchema.properties) { + for (const propName of Object.keys(completeOnboardingOutputSchema.properties)) { + expect(completeOnboardingOutputDtoContent).toContain(propName); + } + } + } + }); + + it('should have driver types defined in tbd folder', async () => { + const generatedFiles = await fs.readdir(generatedTypesDir); + const driverGenerated = generatedFiles.filter(f => + f.includes('Driver') || + f.includes('GetDriver') || + f.includes('CompleteOnboarding') + ); + + expect(driverGenerated.length).toBeGreaterThanOrEqual(8); + }); + + it('should have driver types re-exported from main types file', async () => { + // Check if there's a driver.ts file or if types are exported elsewhere + const driverTypesPath = path.join(websiteTypesDir, 'driver.ts'); + const driverTypesExists = await fs.access(driverTypesPath).then(() => true).catch(() => false); + + if (driverTypesExists) { + const driverTypesContent = await fs.readFile(driverTypesPath, 'utf-8'); + + // Verify re-exports + expect(driverTypesContent).toContain('GetDriverOutputDTO'); + expect(driverTypesContent).toContain('GetDriverProfileOutputDTO'); + expect(driverTypesContent).toContain('DriverRegistrationStatusDTO'); + expect(driverTypesContent).toContain('DriversLeaderboardDTO'); + expect(driverTypesContent).toContain('DriverLeaderboardItemDTO'); + expect(driverTypesContent).toContain('CompleteOnboardingInputDTO'); + expect(driverTypesContent).toContain('CompleteOnboardingOutputDTO'); + } + }); + }); + + describe('Driver API Client Contract', () => { + it('should have DriversApiClient defined', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientExists = await fs.access(driversApiClientPath).then(() => true).catch(() => false); + + expect(driversApiClientExists).toBe(true); + + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify class definition + expect(driversApiClientContent).toContain('export class DriversApiClient'); + expect(driversApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods exist + expect(driversApiClientContent).toContain('getLeaderboard'); + expect(driversApiClientContent).toContain('completeOnboarding'); + expect(driversApiClientContent).toContain('getCurrent'); + expect(driversApiClientContent).toContain('getRegistrationStatus'); + expect(driversApiClientContent).toContain('getDriver'); + expect(driversApiClientContent).toContain('getDriverProfile'); + expect(driversApiClientContent).toContain('updateProfile'); + + // Verify method signatures + expect(driversApiClientContent).toContain('getLeaderboard()'); + expect(driversApiClientContent).toContain('completeOnboarding(input: CompleteOnboardingInputDTO)'); + expect(driversApiClientContent).toContain('getCurrent()'); + expect(driversApiClientContent).toContain('getRegistrationStatus(driverId: string, raceId: string)'); + expect(driversApiClientContent).toContain('getDriver(driverId: string)'); + expect(driversApiClientContent).toContain('getDriverProfile(driverId: string)'); + expect(driversApiClientContent).toContain('updateProfile(updates: { bio?: string; country?: string })'); + }); + + it('should have proper request construction in getLeaderboard method', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify GET request + expect(driversApiClientContent).toContain("return this.get('/drivers/leaderboard')"); + }); + + it('should have proper request construction in completeOnboarding method', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify POST request with input + expect(driversApiClientContent).toContain("return this.post('/drivers/complete-onboarding', input)"); + }); + + it('should have proper request construction in getCurrent method', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify GET request with options + expect(driversApiClientContent).toContain("return this.get('/drivers/current'"); + expect(driversApiClientContent).toContain('allowUnauthenticated: true'); + expect(driversApiClientContent).toContain('retry: false'); + }); + + it('should have proper request construction in getRegistrationStatus method', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify GET request with path parameters + expect(driversApiClientContent).toContain("return this.get(`/drivers/${driverId}/races/${raceId}/registration-status`)"); + }); + + it('should have proper request construction in getDriver method', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify GET request with path parameter + expect(driversApiClientContent).toContain("return this.get(`/drivers/${driverId}`)"); + }); + + it('should have proper request construction in getDriverProfile method', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify GET request with path parameter + expect(driversApiClientContent).toContain("return this.get(`/drivers/${driverId}/profile`)"); + }); + + it('should have proper request construction in updateProfile method', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify PUT request with updates + expect(driversApiClientContent).toContain("return this.put('/drivers/profile', updates)"); + }); + }); + + describe('Request Correctness Tests', () => { + it('should validate GetDriverOutputDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDriverOutputDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('id'); + expect(schema.required).toContain('iracingId'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('country'); + expect(schema.required).toContain('joinedAt'); + + // Verify no extra required fields + expect(schema.required.length).toBe(5); + }); + + it('should validate GetDriverOutputDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDriverOutputDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('bio'); + expect(schema.required).not.toContain('category'); + expect(schema.required).not.toContain('rating'); + expect(schema.required).not.toContain('experienceLevel'); + expect(schema.required).not.toContain('wins'); + expect(schema.required).not.toContain('podiums'); + expect(schema.required).not.toContain('totalRaces'); + expect(schema.required).not.toContain('avatarUrl'); + + // Verify optional fields exist + expect(schema.properties?.bio).toBeDefined(); + expect(schema.properties?.category).toBeDefined(); + expect(schema.properties?.rating).toBeDefined(); + expect(schema.properties?.experienceLevel).toBeDefined(); + expect(schema.properties?.wins).toBeDefined(); + expect(schema.properties?.podiums).toBeDefined(); + expect(schema.properties?.totalRaces).toBeDefined(); + expect(schema.properties?.avatarUrl).toBeDefined(); + }); + + it('should validate GetDriverProfileOutputDTO required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDriverProfileOutputDTO']; + + // Verify all required fields are present + expect(schema.required).toContain('teamMemberships'); + expect(schema.required).toContain('socialSummary'); + + // Verify no extra required fields + expect(schema.required.length).toBe(2); + }); + + it('should validate GetDriverProfileOutputDTO optional fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['GetDriverProfileOutputDTO']; + + // Verify optional fields are not required + expect(schema.required).not.toContain('currentDriver'); + expect(schema.required).not.toContain('stats'); + expect(schema.required).not.toContain('finishDistribution'); + expect(schema.required).not.toContain('extendedProfile'); + + // Verify optional fields exist + expect(schema.properties?.currentDriver).toBeDefined(); + expect(schema.properties?.stats).toBeDefined(); + expect(schema.properties?.finishDistribution).toBeDefined(); + expect(schema.properties?.extendedProfile).toBeDefined(); + }); + + it('should validate DriverRegistrationStatusDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DriverRegistrationStatusDTO']; + + // Verify all required fields + expect(schema.required).toContain('isRegistered'); + expect(schema.required).toContain('raceId'); + expect(schema.required).toContain('driverId'); + + // Verify field types + expect(schema.properties?.isRegistered?.type).toBe('boolean'); + expect(schema.properties?.raceId?.type).toBe('string'); + expect(schema.properties?.driverId?.type).toBe('string'); + }); + + it('should validate DriversLeaderboardDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DriversLeaderboardDTO']; + + // Verify all required fields + expect(schema.required).toContain('drivers'); + expect(schema.required).toContain('totalRaces'); + expect(schema.required).toContain('totalWins'); + expect(schema.required).toContain('activeCount'); + + // Verify field types + expect(schema.properties?.drivers?.type).toBe('array'); + expect(schema.properties?.totalRaces?.type).toBe('number'); + expect(schema.properties?.totalWins?.type).toBe('number'); + expect(schema.properties?.activeCount?.type).toBe('number'); + }); + + it('should validate DriverLeaderboardItemDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['DriverLeaderboardItemDTO']; + + // Verify all required fields + expect(schema.required).toContain('id'); + expect(schema.required).toContain('name'); + expect(schema.required).toContain('rating'); + expect(schema.required).toContain('skillLevel'); + expect(schema.required).toContain('nationality'); + expect(schema.required).toContain('racesCompleted'); + expect(schema.required).toContain('wins'); + expect(schema.required).toContain('podiums'); + expect(schema.required).toContain('isActive'); + expect(schema.required).toContain('rank'); + + // Verify field types + expect(schema.properties?.id?.type).toBe('string'); + expect(schema.properties?.name?.type).toBe('string'); + expect(schema.properties?.rating?.type).toBe('number'); + expect(schema.properties?.skillLevel?.type).toBe('string'); + expect(schema.properties?.nationality?.type).toBe('string'); + expect(schema.properties?.racesCompleted?.type).toBe('number'); + expect(schema.properties?.wins?.type).toBe('number'); + expect(schema.properties?.podiums?.type).toBe('number'); + expect(schema.properties?.isActive?.type).toBe('boolean'); + expect(schema.properties?.rank?.type).toBe('number'); + }); + + it('should validate CompleteOnboardingInputDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['CompleteOnboardingInputDTO']; + + // Verify all required fields + expect(schema.required).toContain('firstName'); + expect(schema.required).toContain('lastName'); + expect(schema.required).toContain('displayName'); + expect(schema.required).toContain('country'); + + // Verify field types + expect(schema.properties?.firstName?.type).toBe('string'); + expect(schema.properties?.lastName?.type).toBe('string'); + expect(schema.properties?.displayName?.type).toBe('string'); + expect(schema.properties?.country?.type).toBe('string'); + expect(schema.properties?.timezone?.type).toBe('string'); + expect(schema.properties?.bio?.type).toBe('string'); + }); + + it('should validate CompleteOnboardingOutputDTO structure', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const schema = spec.components.schemas['CompleteOnboardingOutputDTO']; + + // Verify all required fields + expect(schema.required).toContain('success'); + + // Verify field types + expect(schema.properties?.success?.type).toBe('boolean'); + expect(schema.properties?.driverId?.type).toBe('string'); + expect(schema.properties?.errorMessage?.type).toBe('string'); + }); + }); + + describe('Response Handling Tests', () => { + it('should handle successful driver response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const getDriverSchema = spec.components.schemas['GetDriverOutputDTO']; + + // Verify response structure + expect(getDriverSchema.properties?.id).toBeDefined(); + expect(getDriverSchema.properties?.iracingId).toBeDefined(); + expect(getDriverSchema.properties?.name).toBeDefined(); + expect(getDriverSchema.properties?.country).toBeDefined(); + expect(getDriverSchema.properties?.joinedAt).toBeDefined(); + expect(getDriverSchema.properties?.bio).toBeDefined(); + expect(getDriverSchema.properties?.category).toBeDefined(); + expect(getDriverSchema.properties?.rating).toBeDefined(); + expect(getDriverSchema.properties?.experienceLevel).toBeDefined(); + expect(getDriverSchema.properties?.wins).toBeDefined(); + expect(getDriverSchema.properties?.podiums).toBeDefined(); + expect(getDriverSchema.properties?.totalRaces).toBeDefined(); + expect(getDriverSchema.properties?.avatarUrl).toBeDefined(); + }); + + it('should handle response with all required fields', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const getDriverSchema = spec.components.schemas['GetDriverOutputDTO']; + + // Verify all required fields are present + for (const field of ['id', 'iracingId', 'name', 'country', 'joinedAt']) { + expect(getDriverSchema.required).toContain(field); + expect(getDriverSchema.properties?.[field]).toBeDefined(); + } + }); + + it('should handle optional fields in driver response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const driverSchema = spec.components.schemas['GetDriverOutputDTO']; + + // Verify optional fields are nullable + expect(driverSchema.properties?.bio?.nullable).toBe(true); + expect(driverSchema.properties?.category?.nullable).toBe(true); + expect(driverSchema.properties?.rating?.nullable).toBe(true); + expect(driverSchema.properties?.experienceLevel?.nullable).toBe(true); + expect(driverSchema.properties?.wins?.nullable).toBe(true); + expect(driverSchema.properties?.podiums?.nullable).toBe(true); + expect(driverSchema.properties?.totalRaces?.nullable).toBe(true); + expect(driverSchema.properties?.avatarUrl?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(driverSchema.required).not.toContain('bio'); + expect(driverSchema.required).not.toContain('category'); + expect(driverSchema.required).not.toContain('rating'); + expect(driverSchema.required).not.toContain('experienceLevel'); + expect(driverSchema.required).not.toContain('wins'); + expect(driverSchema.required).not.toContain('podiums'); + expect(driverSchema.required).not.toContain('totalRaces'); + expect(driverSchema.required).not.toContain('avatarUrl'); + }); + + it('should handle optional fields in driver profile response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const profileSchema = spec.components.schemas['GetDriverProfileOutputDTO']; + + // Verify optional fields are nullable + expect(profileSchema.properties?.currentDriver?.nullable).toBe(true); + expect(profileSchema.properties?.stats?.nullable).toBe(true); + expect(profileSchema.properties?.finishDistribution?.nullable).toBe(true); + expect(profileSchema.properties?.extendedProfile?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(profileSchema.required).not.toContain('currentDriver'); + expect(profileSchema.required).not.toContain('stats'); + expect(profileSchema.required).not.toContain('finishDistribution'); + expect(profileSchema.required).not.toContain('extendedProfile'); + }); + + it('should handle optional fields in leaderboard item response correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const leaderboardItemSchema = spec.components.schemas['DriverLeaderboardItemDTO']; + + // Verify optional fields are nullable + expect(leaderboardItemSchema.properties?.category?.nullable).toBe(true); + expect(leaderboardItemSchema.properties?.avatarUrl?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(leaderboardItemSchema.required).not.toContain('category'); + expect(leaderboardItemSchema.required).not.toContain('avatarUrl'); + }); + + it('should handle optional fields in complete onboarding output correctly', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const completeOnboardingSchema = spec.components.schemas['CompleteOnboardingOutputDTO']; + + // Verify optional fields are nullable + expect(completeOnboardingSchema.properties?.driverId?.nullable).toBe(true); + expect(completeOnboardingSchema.properties?.errorMessage?.nullable).toBe(true); + + // Verify optional fields are not in required array + expect(completeOnboardingSchema.required).not.toContain('driverId'); + expect(completeOnboardingSchema.required).not.toContain('errorMessage'); + }); + }); + + describe('Error Handling Tests', () => { + it('should document 401 Unauthorized response for driver endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check if 401 response is documented for driver endpoints + const driverEndpoints = [ + '/drivers/current', + '/drivers/{driverId}', + '/drivers/{driverId}/profile', + '/drivers/{driverId}/liveries', + '/drivers/{driverId}/races/{raceId}/registration-status', + ]; + + for (const endpoint of driverEndpoints) { + const driverPath = spec.paths[endpoint]; + if (driverPath) { + const methods = Object.keys(driverPath); + for (const method of methods) { + const operation = driverPath[method]; + if (operation.responses['401']) { + expect(operation.responses['401']).toBeDefined(); + } + } + } + } + }); + + it('should document 404 Not Found response for driver endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check if 404 response is documented for driver endpoints + const driverEndpoints = [ + '/drivers/{driverId}', + '/drivers/{driverId}/profile', + '/drivers/{driverId}/liveries', + '/drivers/{driverId}/races/{raceId}/registration-status', + ]; + + for (const endpoint of driverEndpoints) { + const driverPath = spec.paths[endpoint]; + if (driverPath) { + const methods = Object.keys(driverPath); + for (const method of methods) { + const operation = driverPath[method]; + if (operation.responses['404']) { + expect(operation.responses['404']).toBeDefined(); + } + } + } + } + }); + + it('should document 500 Internal Server Error response', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check if 500 response is documented for driver endpoints + const driverEndpoints = [ + '/drivers/leaderboard', + '/drivers/total-drivers', + '/drivers/current', + '/drivers/{driverId}', + '/drivers/{driverId}/profile', + '/drivers/{driverId}/liveries', + '/drivers/{driverId}/races/{raceId}/registration-status', + '/drivers/complete-onboarding', + ]; + + for (const endpoint of driverEndpoints) { + const driverPath = spec.paths[endpoint]; + if (driverPath) { + const methods = Object.keys(driverPath); + for (const method of methods) { + const operation = driverPath[method]; + if (operation.responses['500']) { + expect(operation.responses['500']).toBeDefined(); + } + } + } + } + }); + + it('should have proper error handling in DriversApiClient', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(driversApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(driversApiClientContent).toContain('this.get<'); + expect(driversApiClientContent).toContain('this.post<'); + expect(driversApiClientContent).toContain('this.put<'); + }); + }); + + describe('Semantic Guarantee Tests', () => { + it('should maintain consistency between request and response schemas', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify driver overview request/response consistency + const getDriverSchema = spec.components.schemas['GetDriverOutputDTO']; + + // Output should contain all required fields + expect(getDriverSchema.properties?.id).toBeDefined(); + expect(getDriverSchema.properties?.iracingId).toBeDefined(); + expect(getDriverSchema.properties?.name).toBeDefined(); + expect(getDriverSchema.properties?.country).toBeDefined(); + expect(getDriverSchema.properties?.joinedAt).toBeDefined(); + }); + + it('should validate semantic consistency in driver data', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const getDriverSchema = spec.components.schemas['GetDriverOutputDTO']; + + // Verify driver has all required fields + expect(getDriverSchema.required).toContain('id'); + expect(getDriverSchema.required).toContain('iracingId'); + expect(getDriverSchema.required).toContain('name'); + expect(getDriverSchema.required).toContain('country'); + expect(getDriverSchema.required).toContain('joinedAt'); + }); + + it('should validate idempotency for driver endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Check if driver leaderboard endpoint exists + const leaderboardPath = spec.paths['/drivers/leaderboard']?.get; + if (leaderboardPath) { + // Verify it's a GET request (idempotent) + expect(leaderboardPath).toBeDefined(); + + // Verify no request body (GET requests are idempotent) + expect(leaderboardPath.requestBody).toBeUndefined(); + } + + // Check if get driver endpoint exists + const driverPath = spec.paths['/drivers/{driverId}']?.get; + if (driverPath) { + // Verify it's a GET request (idempotent) + expect(driverPath).toBeDefined(); + + // Verify no request body (GET requests are idempotent) + expect(driverPath.requestBody).toBeUndefined(); + } + + // Check if get driver profile endpoint exists + const profilePath = spec.paths['/drivers/{driverId}/profile']?.get; + if (profilePath) { + // Verify it's a GET request (idempotent) + expect(profilePath).toBeDefined(); + + // Verify no request body (GET requests are idempotent) + expect(profilePath.requestBody).toBeUndefined(); + } + + // Check if get driver registration status endpoint exists + const registrationPath = spec.paths['/drivers/{driverId}/races/{raceId}/registration-status']?.get; + if (registrationPath) { + // Verify it's a GET request (idempotent) + expect(registrationPath).toBeDefined(); + + // Verify no request body (GET requests are idempotent) + expect(registrationPath.requestBody).toBeUndefined(); + } + }); + + it('should validate uniqueness constraints for driver IDs', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const driverSchema = spec.components.schemas['GetDriverOutputDTO']; + const driverLeaderboardItemSchema = spec.components.schemas['DriverLeaderboardItemDTO']; + + // Verify driver ID is a required field + expect(driverSchema.required).toContain('id'); + expect(driverSchema.properties?.id?.type).toBe('string'); + + // Verify driver leaderboard item ID is a required field + expect(driverLeaderboardItemSchema.required).toContain('id'); + expect(driverLeaderboardItemSchema.properties?.id?.type).toBe('string'); + }); + + it('should validate consistency between request and response types', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Verify all DTOs have consistent type definitions + const dtos = [ + 'GetDriverOutputDTO', + 'GetDriverProfileOutputDTO', + 'DriverRegistrationStatusDTO', + 'DriversLeaderboardDTO', + 'DriverLeaderboardItemDTO', + 'CompleteOnboardingInputDTO', + 'CompleteOnboardingOutputDTO', + ]; + + for (const dtoName of dtos) { + const schema = spec.components.schemas[dtoName]; + expect(schema).toBeDefined(); + expect(schema.type).toBe('object'); + + // All should have properties defined + expect(schema.properties).toBeDefined(); + + // All should have required fields (even if empty array) + expect(schema.required).toBeDefined(); + } + }); + + it('should validate semantic consistency in driver data', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + const driversLeaderboardSchema = spec.components.schemas['DriversLeaderboardDTO']; + + // Verify drivers are arrays + expect(driversLeaderboardSchema.properties?.drivers?.type).toBe('array'); + + // Verify counts are numbers + expect(driversLeaderboardSchema.properties?.totalRaces?.type).toBe('number'); + expect(driversLeaderboardSchema.properties?.totalWins?.type).toBe('number'); + expect(driversLeaderboardSchema.properties?.activeCount?.type).toBe('number'); + }); + + it('should validate pagination is not applicable for driver endpoints', async () => { + const content = await fs.readFile(openapiPath, 'utf-8'); + const spec: OpenAPISpec = JSON.parse(content); + + // Driver endpoints should not have pagination + const driverEndpoints = [ + '/drivers/leaderboard', + '/drivers/total-drivers', + '/drivers/current', + '/drivers/{driverId}', + '/drivers/{driverId}/profile', + '/drivers/{driverId}/liveries', + '/drivers/{driverId}/races/{raceId}/registration-status', + '/drivers/complete-onboarding', + ]; + + for (const endpoint of driverEndpoints) { + const driverPath = spec.paths[endpoint]; + if (driverPath) { + const methods = Object.keys(driverPath); + for (const method of methods) { + const operation = driverPath[method]; + if (operation.parameters) { + const paramNames = operation.parameters.map((p: { name: string }) => p.name); + // Driver endpoints should not have page/limit parameters + expect(paramNames).not.toContain('page'); + expect(paramNames).not.toContain('limit'); + } + } + } + } + }); + }); + + describe('Driver Module Integration Tests', () => { + it('should have consistent types between API DTOs and website types', 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', '')); + + // Check all driver DTOs exist in generated types + const driverDTOs = [ + 'GetDriverOutputDTO', + 'GetDriverProfileOutputDTO', + 'DriverRegistrationStatusDTO', + 'DriversLeaderboardDTO', + 'DriverLeaderboardItemDTO', + 'CompleteOnboardingInputDTO', + 'CompleteOnboardingOutputDTO', + 'GetDriverLiveriesOutputDTO', + ]; + + for (const dtoName of driverDTOs) { + expect(spec.components.schemas[dtoName]).toBeDefined(); + expect(generatedDTOs).toContain(dtoName); + } + }); + + it('should have DriversApiClient methods matching API endpoints', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify getLeaderboard method exists and uses correct endpoint + expect(driversApiClientContent).toContain('async getLeaderboard'); + expect(driversApiClientContent).toContain("return this.get('/drivers/leaderboard')"); + + // Verify completeOnboarding method exists and uses correct endpoint + expect(driversApiClientContent).toContain('async completeOnboarding'); + expect(driversApiClientContent).toContain("return this.post('/drivers/complete-onboarding', input)"); + + // Verify getCurrent method exists and uses correct endpoint + expect(driversApiClientContent).toContain('async getCurrent'); + expect(driversApiClientContent).toContain("return this.get('/drivers/current'"); + + // Verify getRegistrationStatus method exists and uses correct endpoint + expect(driversApiClientContent).toContain('async getRegistrationStatus'); + expect(driversApiClientContent).toContain("return this.get(`/drivers/${driverId}/races/${raceId}/registration-status`)"); + + // Verify getDriver method exists and uses correct endpoint + expect(driversApiClientContent).toContain('async getDriver'); + expect(driversApiClientContent).toContain("return this.get(`/drivers/${driverId}`)"); + + // Verify getDriverProfile method exists and uses correct endpoint + expect(driversApiClientContent).toContain('async getDriverProfile'); + expect(driversApiClientContent).toContain("return this.get(`/drivers/${driverId}/profile`)"); + + // Verify updateProfile method exists and uses correct endpoint + expect(driversApiClientContent).toContain('async updateProfile'); + expect(driversApiClientContent).toContain("return this.put('/drivers/profile', updates)"); + }); + + it('should have proper error handling in DriversApiClient', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify BaseApiClient is extended (which provides error handling) + expect(driversApiClientContent).toContain('extends BaseApiClient'); + + // Verify methods use BaseApiClient methods (which handle errors) + expect(driversApiClientContent).toContain('this.get<'); + expect(driversApiClientContent).toContain('this.post<'); + expect(driversApiClientContent).toContain('this.put<'); + }); + + it('should have consistent type imports in DriversApiClient', async () => { + const driversApiClientPath = path.join(apiRoot, 'apps/website/lib/api/drivers/DriversApiClient.ts'); + const driversApiClientContent = await fs.readFile(driversApiClientPath, 'utf-8'); + + // Verify all required types are imported + expect(driversApiClientContent).toContain('CompleteOnboardingInputDTO'); + expect(driversApiClientContent).toContain('CompleteOnboardingOutputDTO'); + expect(driversApiClientContent).toContain('DriverRegistrationStatusDTO'); + expect(driversApiClientContent).toContain('DriverLeaderboardItemDTO'); + expect(driversApiClientContent).toContain('GetDriverOutputDTO'); + expect(driversApiClientContent).toContain('GetDriverProfileOutputDTO'); + }); + }); +});