897 lines
39 KiB
TypeScript
897 lines
39 KiB
TypeScript
/**
|
|
* 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<string, OpenAPISchema>;
|
|
required?: string[];
|
|
enum?: string[];
|
|
nullable?: boolean;
|
|
description?: string;
|
|
default?: unknown;
|
|
}
|
|
|
|
interface OpenAPISpec {
|
|
openapi: string;
|
|
info: {
|
|
title: string;
|
|
description: string;
|
|
version: string;
|
|
};
|
|
paths: Record<string, any>;
|
|
components: {
|
|
schemas: Record<string, OpenAPISchema>;
|
|
};
|
|
}
|
|
|
|
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<RecordPageViewOutputDTO>('/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<RecordEngagementOutputDTO>('/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<GetDashboardDataOutputDTO>('/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<GetAnalyticsMetricsOutputDTO>('/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<RecordPageViewOutputDTO>('/analytics/page-view', input)");
|
|
|
|
// Verify recordEngagement method exists and uses correct endpoint
|
|
expect(analyticsApiClientContent).toContain('async recordEngagement');
|
|
expect(analyticsApiClientContent).toContain("return this.post<RecordEngagementOutputDTO>('/analytics/engagement', input)");
|
|
|
|
// Verify getDashboardData method exists and uses correct endpoint
|
|
expect(analyticsApiClientContent).toContain('async getDashboardData');
|
|
expect(analyticsApiClientContent).toContain("return this.get<GetDashboardDataOutputDTO>('/analytics/dashboard')");
|
|
|
|
// Verify getAnalyticsMetrics method exists and uses correct endpoint
|
|
expect(analyticsApiClientContent).toContain('async getAnalyticsMetrics');
|
|
expect(analyticsApiClientContent).toContain("return this.get<GetAnalyticsMetricsOutputDTO>('/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');
|
|
});
|
|
});
|
|
}); |