tests cleanup
This commit is contained in:
@@ -1,408 +0,0 @@
|
||||
/**
|
||||
* Contract Validation Tests for API
|
||||
*
|
||||
* These tests validate that the API DTOs and OpenAPI spec are consistent
|
||||
* and that the generated types will be compatible with the website.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
interface OpenAPISchema {
|
||||
type?: string;
|
||||
format?: string;
|
||||
$ref?: string;
|
||||
items?: OpenAPISchema;
|
||||
properties?: Record<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('API Contract Validation', () => {
|
||||
const apiRoot = path.join(__dirname, '../../..');
|
||||
const openapiPath = path.join(apiRoot, 'openapi.json');
|
||||
const generatedTypesDir = path.join(apiRoot, '../website/lib/types/generated');
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
describe('OpenAPI Spec Integrity', () => {
|
||||
it('should have a valid OpenAPI spec file', async () => {
|
||||
const specExists = await fs.access(openapiPath).then(() => true).catch(() => false);
|
||||
expect(specExists).toBe(true);
|
||||
});
|
||||
|
||||
it('should have a valid JSON structure', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
expect(() => JSON.parse(content)).not.toThrow();
|
||||
});
|
||||
|
||||
it('should have required OpenAPI fields', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
expect(spec.openapi).toMatch(/^3\.\d+\.\d+$/);
|
||||
expect(spec.info).toBeDefined();
|
||||
expect(spec.info.title).toBeDefined();
|
||||
expect(spec.info.version).toBeDefined();
|
||||
expect(spec.components).toBeDefined();
|
||||
expect(spec.components.schemas).toBeDefined();
|
||||
});
|
||||
|
||||
it('committed openapi.json should match generator output', async () => {
|
||||
const repoRoot = path.join(apiRoot, '../..');
|
||||
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gridpilot-openapi-'));
|
||||
const generatedOpenapiPath = path.join(tmpDir, 'openapi.json');
|
||||
|
||||
await execFileAsync(
|
||||
'npx',
|
||||
['--no-install', 'tsx', 'scripts/generate-openapi-spec.ts', '--output', generatedOpenapiPath],
|
||||
{ cwd: repoRoot, maxBuffer: 20 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
const committed: OpenAPISpec = JSON.parse(await fs.readFile(openapiPath, 'utf-8'));
|
||||
const generated: OpenAPISpec = JSON.parse(await fs.readFile(generatedOpenapiPath, 'utf-8'));
|
||||
|
||||
expect(generated).toEqual(committed);
|
||||
});
|
||||
|
||||
it('should include real HTTP paths for known routes', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const pathKeys = Object.keys(spec.paths ?? {});
|
||||
expect(pathKeys.length).toBeGreaterThan(0);
|
||||
|
||||
// A couple of stable routes to detect "empty/stale" specs.
|
||||
expect(spec.paths['/drivers/leaderboard']).toBeDefined();
|
||||
expect(spec.paths['/dashboard/overview']).toBeDefined();
|
||||
|
||||
// Sanity-check the operation objects exist (method keys are lowercase in OpenAPI).
|
||||
expect(spec.paths['/drivers/leaderboard'].get).toBeDefined();
|
||||
expect(spec.paths['/dashboard/overview'].get).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include league schedule publish/unpublish endpoints and published state', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/publish'].post).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/seasons/{seasonId}/schedule/unpublish'].post).toBeDefined();
|
||||
|
||||
const scheduleSchema = spec.components.schemas['LeagueScheduleDTO'];
|
||||
if (!scheduleSchema) {
|
||||
throw new Error('Expected LeagueScheduleDTO schema to be present in OpenAPI spec');
|
||||
}
|
||||
|
||||
expect(scheduleSchema.properties?.published).toBeDefined();
|
||||
expect(scheduleSchema.properties?.published?.type).toBe('boolean');
|
||||
expect(scheduleSchema.required ?? []).toContain('published');
|
||||
});
|
||||
|
||||
it('should include league roster admin read endpoints and schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/members']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/members'].get).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests'].get).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/approve'].post).toBeDefined();
|
||||
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject']).toBeDefined();
|
||||
expect(spec.paths['/leagues/{leagueId}/admin/roster/join-requests/{joinRequestId}/reject'].post).toBeDefined();
|
||||
|
||||
const memberSchema = spec.components.schemas['LeagueRosterMemberDTO'];
|
||||
if (!memberSchema) {
|
||||
throw new Error('Expected LeagueRosterMemberDTO schema to be present in OpenAPI spec');
|
||||
}
|
||||
|
||||
expect(memberSchema.properties?.driverId).toBeDefined();
|
||||
expect(memberSchema.properties?.role).toBeDefined();
|
||||
expect(memberSchema.properties?.joinedAt).toBeDefined();
|
||||
expect(memberSchema.required ?? []).toContain('driverId');
|
||||
expect(memberSchema.required ?? []).toContain('role');
|
||||
expect(memberSchema.required ?? []).toContain('joinedAt');
|
||||
expect(memberSchema.required ?? []).toContain('driver');
|
||||
|
||||
const joinRequestSchema = spec.components.schemas['LeagueRosterJoinRequestDTO'];
|
||||
if (!joinRequestSchema) {
|
||||
throw new Error('Expected LeagueRosterJoinRequestDTO schema to be present in OpenAPI spec');
|
||||
}
|
||||
|
||||
expect(joinRequestSchema.properties?.id).toBeDefined();
|
||||
expect(joinRequestSchema.properties?.leagueId).toBeDefined();
|
||||
expect(joinRequestSchema.properties?.driverId).toBeDefined();
|
||||
expect(joinRequestSchema.properties?.requestedAt).toBeDefined();
|
||||
expect(joinRequestSchema.required ?? []).toContain('id');
|
||||
expect(joinRequestSchema.required ?? []).toContain('leagueId');
|
||||
expect(joinRequestSchema.required ?? []).toContain('driverId');
|
||||
expect(joinRequestSchema.required ?? []).toContain('requestedAt');
|
||||
expect(joinRequestSchema.required ?? []).toContain('driver');
|
||||
});
|
||||
|
||||
it('should have no circular references in schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>();
|
||||
|
||||
function detectCircular(schemaName: string): boolean {
|
||||
if (visiting.has(schemaName)) return true;
|
||||
if (visited.has(schemaName)) return false;
|
||||
|
||||
visiting.add(schemaName);
|
||||
const schema = schemas[schemaName];
|
||||
|
||||
if (!schema) {
|
||||
visiting.delete(schemaName);
|
||||
visited.add(schemaName);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check properties for references
|
||||
if (schema.properties) {
|
||||
for (const prop of Object.values(schema.properties)) {
|
||||
if (prop.$ref) {
|
||||
const refName = prop.$ref.split('/').pop();
|
||||
if (refName && detectCircular(refName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (prop.items?.$ref) {
|
||||
const refName = prop.items.$ref.split('/').pop();
|
||||
if (refName && detectCircular(refName)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visiting.delete(schemaName);
|
||||
visited.add(schemaName);
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const schemaName of Object.keys(schemas)) {
|
||||
expect(detectCircular(schemaName)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('DTO Consistency', () => {
|
||||
it('should have generated DTO files for critical schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
const generatedFiles = await fs.readdir(generatedTypesDir);
|
||||
const generatedDTOs = generatedFiles
|
||||
.filter(f => f.endsWith('.ts'))
|
||||
.map(f => f.replace('.ts', ''));
|
||||
|
||||
// We intentionally do NOT require a 1:1 mapping for *all* schemas here.
|
||||
// OpenAPI generation and type generation can be run as separate steps,
|
||||
// and new schemas should not break API contract validation by themselves.
|
||||
const criticalDTOs = [
|
||||
'RequestAvatarGenerationInputDTO',
|
||||
'RequestAvatarGenerationOutputDTO',
|
||||
'UploadMediaInputDTO',
|
||||
'UploadMediaOutputDTO',
|
||||
'RaceDTO',
|
||||
'DriverDTO',
|
||||
];
|
||||
|
||||
for (const dtoName of criticalDTOs) {
|
||||
expect(spec.components.schemas[dtoName]).toBeDefined();
|
||||
expect(generatedDTOs).toContain(dtoName);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have consistent property types between DTOs and schemas', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
for (const [schemaName, schema] of Object.entries(schemas)) {
|
||||
const dtoPath = path.join(generatedTypesDir, `${schemaName}.ts`);
|
||||
const dtoExists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
||||
|
||||
if (!dtoExists) continue;
|
||||
|
||||
const dtoContent = await fs.readFile(dtoPath, 'utf-8');
|
||||
|
||||
// Check that all required properties are present
|
||||
if (schema.required) {
|
||||
for (const requiredProp of schema.required) {
|
||||
expect(dtoContent).toContain(requiredProp);
|
||||
}
|
||||
}
|
||||
|
||||
// Check that all properties are present
|
||||
if (schema.properties) {
|
||||
for (const propName of Object.keys(schema.properties)) {
|
||||
expect(dtoContent).toContain(propName);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type Generation Integrity', () => {
|
||||
it('should have valid TypeScript syntax in generated files', async () => {
|
||||
const files = await fs.readdir(generatedTypesDir);
|
||||
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
||||
|
||||
for (const file of dtos) {
|
||||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||
|
||||
// `index.ts` is a generated barrel file (no interfaces).
|
||||
if (file === 'index.ts') {
|
||||
expect(content).toContain('export type {');
|
||||
expect(content).toContain("from './");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Basic TypeScript syntax checks (DTO interfaces)
|
||||
expect(content).toContain('export interface');
|
||||
expect(content).toContain('{');
|
||||
expect(content).toContain('}');
|
||||
|
||||
// Should not have syntax errors (basic check)
|
||||
expect(content).not.toContain('undefined;');
|
||||
expect(content).not.toContain('any;');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have proper imports for dependencies', async () => {
|
||||
const files = await fs.readdir(generatedTypesDir);
|
||||
const dtos = files.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts'));
|
||||
|
||||
for (const file of dtos) {
|
||||
const content = await fs.readFile(path.join(generatedTypesDir, file), 'utf-8');
|
||||
const importMatches = content.match(/import type \{ (\w+) \} from '\.\/(\w+)';/g) || [];
|
||||
|
||||
for (const importLine of importMatches) {
|
||||
const match = importLine.match(/import type \{ (\w+) \} from '\.\/(\w+)';/);
|
||||
if (match) {
|
||||
const [, importedType, fromFile] = match;
|
||||
expect(importedType).toBe(fromFile);
|
||||
|
||||
// Check that the imported file exists
|
||||
const importedPath = path.join(generatedTypesDir, `${fromFile}.ts`);
|
||||
const exists = await fs.access(importedPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Contract Compatibility', () => {
|
||||
it('should maintain backward compatibility for existing DTOs', async () => {
|
||||
// This test ensures that when regenerating types, existing properties aren't removed
|
||||
// unless explicitly intended
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
|
||||
// Check critical DTOs that are likely used in production
|
||||
const criticalDTOs = [
|
||||
'RequestAvatarGenerationInputDTO',
|
||||
'RequestAvatarGenerationOutputDTO',
|
||||
'UploadMediaInputDTO',
|
||||
'UploadMediaOutputDTO',
|
||||
'RaceDTO',
|
||||
'DriverDTO'
|
||||
];
|
||||
|
||||
for (const dtoName of criticalDTOs) {
|
||||
if (spec.components.schemas[dtoName]) {
|
||||
const dtoPath = path.join(generatedTypesDir, `${dtoName}.ts`);
|
||||
const exists = await fs.access(dtoPath).then(() => true).catch(() => false);
|
||||
expect(exists).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle nullable fields correctly', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
for (const [, schema] of Object.entries(schemas)) {
|
||||
const required = new Set(schema.required ?? []);
|
||||
if (!schema.properties) continue;
|
||||
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
if (!propSchema.nullable) continue;
|
||||
|
||||
// In OpenAPI 3.0, a `nullable: true` property should not be listed as required,
|
||||
// otherwise downstream generators can't represent it safely.
|
||||
expect(required.has(propName)).toBe(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have no empty string defaults for avatar/logo URLs', async () => {
|
||||
const content = await fs.readFile(openapiPath, 'utf-8');
|
||||
const spec: OpenAPISpec = JSON.parse(content);
|
||||
const schemas = spec.components.schemas;
|
||||
|
||||
// Check DTOs that should use URL|null pattern
|
||||
const mediaRelatedDTOs = [
|
||||
'GetAvatarOutputDTO',
|
||||
'UpdateAvatarInputDTO',
|
||||
'DashboardDriverSummaryDTO',
|
||||
'DriverProfileDriverSummaryDTO',
|
||||
'DriverLeaderboardItemDTO',
|
||||
'TeamListItemDTO',
|
||||
'LeagueSummaryDTO',
|
||||
'SponsorDTO',
|
||||
];
|
||||
|
||||
for (const dtoName of mediaRelatedDTOs) {
|
||||
const schema = schemas[dtoName];
|
||||
if (!schema || !schema.properties) continue;
|
||||
|
||||
// Check for avatarUrl, logoUrl properties
|
||||
for (const [propName, propSchema] of Object.entries(schema.properties)) {
|
||||
if (propName === 'avatarUrl' || propName === 'logoUrl') {
|
||||
// Should be string type, nullable (no empty string defaults)
|
||||
expect(propSchema.type).toBe('string');
|
||||
expect(propSchema.nullable).toBe(true);
|
||||
// Should not have default value of empty string
|
||||
if (propSchema.default !== undefined) {
|
||||
expect(propSchema.default).not.toBe('');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import type { Provider, Type } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
|
||||
export type ProviderOverride = {
|
||||
provide: unknown;
|
||||
useValue: unknown;
|
||||
};
|
||||
|
||||
export type HttpContractHarness = {
|
||||
// Avoid exporting INestApplication here because this repo can end up with
|
||||
// multiple @nestjs/common type roots (workspace hoisting), which makes the
|
||||
// INestApplication types incompatible.
|
||||
app: unknown;
|
||||
module: TestingModule;
|
||||
http: ReturnType<typeof request>;
|
||||
close: () => Promise<void>;
|
||||
};
|
||||
|
||||
export async function createHttpContractHarness(options: {
|
||||
controllers: Array<Type<unknown>>;
|
||||
providers?: Provider[];
|
||||
overrides?: ProviderOverride[];
|
||||
}): Promise<HttpContractHarness> {
|
||||
let moduleBuilder = Test.createTestingModule({
|
||||
controllers: options.controllers,
|
||||
providers: options.providers ?? [],
|
||||
});
|
||||
|
||||
for (const override of options.overrides ?? []) {
|
||||
moduleBuilder = moduleBuilder.overrideProvider(override.provide).useValue(override.useValue);
|
||||
}
|
||||
|
||||
const module = await moduleBuilder.compile();
|
||||
const app = module.createNestApplication();
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await app.init();
|
||||
|
||||
return {
|
||||
app,
|
||||
module,
|
||||
http: request(app.getHttpServer()),
|
||||
close: async () => {
|
||||
await app.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user