/** * 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'); }); }); });