924 lines
40 KiB
TypeScript
924 lines
40 KiB
TypeScript
/**
|
|
* Contract Validation Tests for Admin Module
|
|
*
|
|
* These tests validate that the admin API DTOs and OpenAPI spec are consistent
|
|
* and that the generated types will be compatible with the website admin client.
|
|
*/
|
|
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
interface OpenAPISchema {
|
|
type?: string;
|
|
format?: string;
|
|
$ref?: string;
|
|
items?: OpenAPISchema;
|
|
properties?: Record<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('Admin Module Contract Validation', () => {
|
|
const apiRoot = path.join(__dirname, '../..');
|
|
const openapiPath = path.join(apiRoot, 'apps/api/openapi.json');
|
|
const generatedTypesDir = path.join(apiRoot, 'apps/website/lib/types/generated');
|
|
const websiteTypesDir = path.join(apiRoot, 'apps/website/lib/types');
|
|
|
|
describe('OpenAPI Spec Integrity for Admin Endpoints', () => {
|
|
it('should have admin endpoints defined in OpenAPI spec', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
// Check for admin endpoints
|
|
expect(spec.paths['/admin/users']).toBeDefined();
|
|
expect(spec.paths['/admin/dashboard/stats']).toBeDefined();
|
|
|
|
// Verify GET methods exist
|
|
expect(spec.paths['/admin/users'].get).toBeDefined();
|
|
expect(spec.paths['/admin/dashboard/stats'].get).toBeDefined();
|
|
});
|
|
|
|
it('should have ListUsersRequestDto schema defined', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const schema = spec.components.schemas['ListUsersRequestDto'];
|
|
expect(schema).toBeDefined();
|
|
expect(schema.type).toBe('object');
|
|
|
|
// Verify optional query parameters
|
|
expect(schema.properties?.role).toBeDefined();
|
|
expect(schema.properties?.status).toBeDefined();
|
|
expect(schema.properties?.email).toBeDefined();
|
|
expect(schema.properties?.search).toBeDefined();
|
|
expect(schema.properties?.page).toBeDefined();
|
|
expect(schema.properties?.limit).toBeDefined();
|
|
expect(schema.properties?.sortBy).toBeDefined();
|
|
expect(schema.properties?.sortDirection).toBeDefined();
|
|
});
|
|
|
|
it('should have UserResponseDto schema defined', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const schema = spec.components.schemas['UserResponseDto'];
|
|
expect(schema).toBeDefined();
|
|
expect(schema.type).toBe('object');
|
|
|
|
// Verify required fields
|
|
expect(schema.required).toContain('id');
|
|
expect(schema.required).toContain('email');
|
|
expect(schema.required).toContain('displayName');
|
|
expect(schema.required).toContain('roles');
|
|
expect(schema.required).toContain('status');
|
|
expect(schema.required).toContain('isSystemAdmin');
|
|
expect(schema.required).toContain('createdAt');
|
|
expect(schema.required).toContain('updatedAt');
|
|
|
|
// Verify field types
|
|
expect(schema.properties?.id?.type).toBe('string');
|
|
expect(schema.properties?.email?.type).toBe('string');
|
|
expect(schema.properties?.displayName?.type).toBe('string');
|
|
expect(schema.properties?.roles?.type).toBe('array');
|
|
expect(schema.properties?.status?.type).toBe('string');
|
|
expect(schema.properties?.isSystemAdmin?.type).toBe('boolean');
|
|
expect(schema.properties?.createdAt?.type).toBe('string');
|
|
expect(schema.properties?.updatedAt?.type).toBe('string');
|
|
|
|
// Verify optional fields
|
|
expect(schema.properties?.lastLoginAt).toBeDefined();
|
|
expect(schema.properties?.lastLoginAt?.nullable).toBe(true);
|
|
expect(schema.properties?.primaryDriverId).toBeDefined();
|
|
expect(schema.properties?.primaryDriverId?.nullable).toBe(true);
|
|
});
|
|
|
|
it('should have UserListResponseDto schema defined', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const schema = spec.components.schemas['UserListResponseDto'];
|
|
expect(schema).toBeDefined();
|
|
expect(schema.type).toBe('object');
|
|
|
|
// Verify required fields
|
|
expect(schema.required).toContain('users');
|
|
expect(schema.required).toContain('total');
|
|
expect(schema.required).toContain('page');
|
|
expect(schema.required).toContain('limit');
|
|
expect(schema.required).toContain('totalPages');
|
|
|
|
// Verify field types
|
|
expect(schema.properties?.users?.type).toBe('array');
|
|
expect(schema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
|
|
expect(schema.properties?.total?.type).toBe('number');
|
|
expect(schema.properties?.page?.type).toBe('number');
|
|
expect(schema.properties?.limit?.type).toBe('number');
|
|
expect(schema.properties?.totalPages?.type).toBe('number');
|
|
});
|
|
|
|
it('should have DashboardStatsResponseDto schema defined', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const schema = spec.components.schemas['DashboardStatsResponseDto'];
|
|
expect(schema).toBeDefined();
|
|
expect(schema.type).toBe('object');
|
|
|
|
// Verify required fields
|
|
expect(schema.required).toContain('totalUsers');
|
|
expect(schema.required).toContain('activeUsers');
|
|
expect(schema.required).toContain('suspendedUsers');
|
|
expect(schema.required).toContain('deletedUsers');
|
|
expect(schema.required).toContain('systemAdmins');
|
|
expect(schema.required).toContain('recentLogins');
|
|
expect(schema.required).toContain('newUsersToday');
|
|
expect(schema.required).toContain('userGrowth');
|
|
expect(schema.required).toContain('roleDistribution');
|
|
expect(schema.required).toContain('statusDistribution');
|
|
expect(schema.required).toContain('activityTimeline');
|
|
|
|
// Verify field types
|
|
expect(schema.properties?.totalUsers?.type).toBe('number');
|
|
expect(schema.properties?.activeUsers?.type).toBe('number');
|
|
expect(schema.properties?.suspendedUsers?.type).toBe('number');
|
|
expect(schema.properties?.deletedUsers?.type).toBe('number');
|
|
expect(schema.properties?.systemAdmins?.type).toBe('number');
|
|
expect(schema.properties?.recentLogins?.type).toBe('number');
|
|
expect(schema.properties?.newUsersToday?.type).toBe('number');
|
|
|
|
// Verify nested objects
|
|
expect(schema.properties?.userGrowth?.type).toBe('array');
|
|
expect(schema.properties?.roleDistribution?.type).toBe('array');
|
|
expect(schema.properties?.statusDistribution?.type).toBe('object');
|
|
expect(schema.properties?.activityTimeline?.type).toBe('array');
|
|
});
|
|
|
|
it('should have proper query parameter validation in OpenAPI', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const listUsersPath = spec.paths['/admin/users']?.get;
|
|
expect(listUsersPath).toBeDefined();
|
|
|
|
// Verify query parameters are documented
|
|
const params = listUsersPath.parameters || [];
|
|
const paramNames = params.map((p: any) => p.name);
|
|
|
|
// These should be query parameters based on the DTO
|
|
expect(paramNames).toContain('role');
|
|
expect(paramNames).toContain('status');
|
|
expect(paramNames).toContain('email');
|
|
expect(paramNames).toContain('search');
|
|
expect(paramNames).toContain('page');
|
|
expect(paramNames).toContain('limit');
|
|
expect(paramNames).toContain('sortBy');
|
|
expect(paramNames).toContain('sortDirection');
|
|
});
|
|
});
|
|
|
|
describe('DTO Consistency', () => {
|
|
it('should have generated DTO files for admin schemas', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const generatedFiles = await fs.readdir(generatedTypesDir);
|
|
const generatedDTOs = generatedFiles
|
|
.filter(f => f.endsWith('.ts'))
|
|
.map(f => f.replace('.ts', ''));
|
|
|
|
// Check for admin-related DTOs
|
|
const adminDTOs = [
|
|
'ListUsersRequestDto',
|
|
'UserResponseDto',
|
|
'UserListResponseDto',
|
|
'DashboardStatsResponseDto',
|
|
];
|
|
|
|
for (const dtoName of adminDTOs) {
|
|
expect(spec.components.schemas[dtoName]).toBeDefined();
|
|
expect(generatedDTOs).toContain(dtoName);
|
|
}
|
|
});
|
|
|
|
it('should have consistent property types between DTOs and schemas', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
const schemas = spec.components.schemas;
|
|
|
|
// Test ListUsersRequestDto
|
|
const listUsersSchema = schemas['ListUsersRequestDto'];
|
|
const listUsersDtoPath = path.join(generatedTypesDir, 'ListUsersRequestDto.ts');
|
|
const listUsersDtoExists = await fs.access(listUsersDtoPath).then(() => true).catch(() => false);
|
|
|
|
if (listUsersDtoExists) {
|
|
const listUsersDtoContent = await fs.readFile(listUsersDtoPath, 'utf-8');
|
|
|
|
// Check that all properties are present
|
|
if (listUsersSchema.properties) {
|
|
for (const propName of Object.keys(listUsersSchema.properties)) {
|
|
expect(listUsersDtoContent).toContain(propName);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test UserResponseDto
|
|
const userSchema = schemas['UserResponseDto'];
|
|
const userDtoPath = path.join(generatedTypesDir, 'UserResponseDto.ts');
|
|
const userDtoExists = await fs.access(userDtoPath).then(() => true).catch(() => false);
|
|
|
|
if (userDtoExists) {
|
|
const userDtoContent = await fs.readFile(userDtoPath, 'utf-8');
|
|
|
|
// Check that all required properties are present
|
|
if (userSchema.required) {
|
|
for (const requiredProp of userSchema.required) {
|
|
expect(userDtoContent).toContain(requiredProp);
|
|
}
|
|
}
|
|
|
|
// Check that all properties are present
|
|
if (userSchema.properties) {
|
|
for (const propName of Object.keys(userSchema.properties)) {
|
|
expect(userDtoContent).toContain(propName);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test UserListResponseDto
|
|
const userListSchema = schemas['UserListResponseDto'];
|
|
const userListDtoPath = path.join(generatedTypesDir, 'UserListResponseDto.ts');
|
|
const userListDtoExists = await fs.access(userListDtoPath).then(() => true).catch(() => false);
|
|
|
|
if (userListDtoExists) {
|
|
const userListDtoContent = await fs.readFile(userListDtoPath, 'utf-8');
|
|
|
|
// Check that all required properties are present
|
|
if (userListSchema.required) {
|
|
for (const requiredProp of userListSchema.required) {
|
|
expect(userListDtoContent).toContain(requiredProp);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Test DashboardStatsResponseDto
|
|
const dashboardSchema = schemas['DashboardStatsResponseDto'];
|
|
const dashboardDtoPath = path.join(generatedTypesDir, 'DashboardStatsResponseDto.ts');
|
|
const dashboardDtoExists = await fs.access(dashboardDtoPath).then(() => true).catch(() => false);
|
|
|
|
if (dashboardDtoExists) {
|
|
const dashboardDtoContent = await fs.readFile(dashboardDtoPath, 'utf-8');
|
|
|
|
// Check that all required properties are present
|
|
if (dashboardSchema.required) {
|
|
for (const requiredProp of dashboardSchema.required) {
|
|
expect(dashboardDtoContent).toContain(requiredProp);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have TBD admin types defined', async () => {
|
|
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
|
const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false);
|
|
|
|
expect(adminTypesExists).toBe(true);
|
|
|
|
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
|
|
|
// Verify UserDto interface
|
|
expect(adminTypesContent).toContain('export interface AdminUserDto');
|
|
expect(adminTypesContent).toContain('id: string');
|
|
expect(adminTypesContent).toContain('email: string');
|
|
expect(adminTypesContent).toContain('displayName: string');
|
|
expect(adminTypesContent).toContain('roles: string[]');
|
|
expect(adminTypesContent).toContain('status: string');
|
|
expect(adminTypesContent).toContain('isSystemAdmin: boolean');
|
|
expect(adminTypesContent).toContain('createdAt: string');
|
|
expect(adminTypesContent).toContain('updatedAt: string');
|
|
expect(adminTypesContent).toContain('lastLoginAt?: string');
|
|
expect(adminTypesContent).toContain('primaryDriverId?: string');
|
|
|
|
// Verify UserListResponse interface
|
|
expect(adminTypesContent).toContain('export interface AdminUserListResponseDto');
|
|
expect(adminTypesContent).toContain('users: AdminUserDto[]');
|
|
expect(adminTypesContent).toContain('total: number');
|
|
expect(adminTypesContent).toContain('page: number');
|
|
expect(adminTypesContent).toContain('limit: number');
|
|
expect(adminTypesContent).toContain('totalPages: number');
|
|
|
|
// Verify DashboardStats interface
|
|
expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto');
|
|
expect(adminTypesContent).toContain('totalUsers: number');
|
|
expect(adminTypesContent).toContain('activeUsers: number');
|
|
expect(adminTypesContent).toContain('suspendedUsers: number');
|
|
expect(adminTypesContent).toContain('deletedUsers: number');
|
|
expect(adminTypesContent).toContain('systemAdmins: number');
|
|
expect(adminTypesContent).toContain('recentLogins: number');
|
|
expect(adminTypesContent).toContain('newUsersToday: number');
|
|
|
|
// Verify ListUsersQuery interface
|
|
expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto');
|
|
expect(adminTypesContent).toContain('role?: string');
|
|
expect(adminTypesContent).toContain('status?: string');
|
|
expect(adminTypesContent).toContain('email?: string');
|
|
expect(adminTypesContent).toContain('search?: string');
|
|
expect(adminTypesContent).toContain('page?: number');
|
|
expect(adminTypesContent).toContain('limit?: number');
|
|
expect(adminTypesContent).toContain("sortBy?: 'email' | 'displayName' | 'createdAt' | 'lastLoginAt' | 'status'");
|
|
expect(adminTypesContent).toContain("sortDirection?: 'asc' | 'desc'");
|
|
});
|
|
|
|
it('should have admin types re-exported from main types file', async () => {
|
|
const adminTypesPath = path.join(websiteTypesDir, 'admin.ts');
|
|
const adminTypesExists = await fs.access(adminTypesPath).then(() => true).catch(() => false);
|
|
|
|
expect(adminTypesExists).toBe(true);
|
|
|
|
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
|
|
|
// Verify re-exports
|
|
expect(adminTypesContent).toContain('AdminUserDto as UserDto');
|
|
expect(adminTypesContent).toContain('AdminUserListResponseDto as UserListResponse');
|
|
expect(adminTypesContent).toContain('AdminListUsersQueryDto as ListUsersQuery');
|
|
expect(adminTypesContent).toContain('AdminDashboardStatsDto as DashboardStats');
|
|
});
|
|
});
|
|
|
|
describe('Admin API Client Contract', () => {
|
|
it('should have AdminApiClient defined', async () => {
|
|
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
|
const adminApiClientExists = await fs.access(adminApiClientPath).then(() => true).catch(() => false);
|
|
|
|
expect(adminApiClientExists).toBe(true);
|
|
|
|
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
|
|
|
// Verify class definition
|
|
expect(adminApiClientContent).toContain('export class AdminApiClient');
|
|
expect(adminApiClientContent).toContain('extends BaseApiClient');
|
|
|
|
// Verify methods exist
|
|
expect(adminApiClientContent).toContain('async listUsers');
|
|
expect(adminApiClientContent).toContain('async getUser');
|
|
expect(adminApiClientContent).toContain('async updateUserRoles');
|
|
expect(adminApiClientContent).toContain('async updateUserStatus');
|
|
expect(adminApiClientContent).toContain('async deleteUser');
|
|
expect(adminApiClientContent).toContain('async createUser');
|
|
expect(adminApiClientContent).toContain('async getDashboardStats');
|
|
|
|
// Verify method signatures
|
|
expect(adminApiClientContent).toContain('listUsers(query: ListUsersQuery = {})');
|
|
expect(adminApiClientContent).toContain('getUser(userId: string)');
|
|
expect(adminApiClientContent).toContain('updateUserRoles(userId: string, roles: string[])');
|
|
expect(adminApiClientContent).toContain('updateUserStatus(userId: string, status: string)');
|
|
expect(adminApiClientContent).toContain('deleteUser(userId: string)');
|
|
expect(adminApiClientContent).toContain('createUser(userData: {');
|
|
expect(adminApiClientContent).toContain('getDashboardStats()');
|
|
});
|
|
|
|
it('should have proper request construction in listUsers method', async () => {
|
|
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
|
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
|
|
|
// Verify query parameter construction
|
|
expect(adminApiClientContent).toContain('const params = new URLSearchParams()');
|
|
expect(adminApiClientContent).toContain("params.append('role', query.role)");
|
|
expect(adminApiClientContent).toContain("params.append('status', query.status)");
|
|
expect(adminApiClientContent).toContain("params.append('email', query.email)");
|
|
expect(adminApiClientContent).toContain("params.append('search', query.search)");
|
|
expect(adminApiClientContent).toContain("params.append('page', query.page.toString())");
|
|
expect(adminApiClientContent).toContain("params.append('limit', query.limit.toString())");
|
|
expect(adminApiClientContent).toContain("params.append('sortBy', query.sortBy)");
|
|
expect(adminApiClientContent).toContain("params.append('sortDirection', query.sortDirection)");
|
|
|
|
// Verify endpoint construction
|
|
expect(adminApiClientContent).toContain("return this.get<UserListResponse>(`/admin/users?${params.toString()}`)");
|
|
});
|
|
|
|
it('should have proper request construction in createUser method', async () => {
|
|
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
|
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
|
|
|
// Verify POST request with userData
|
|
expect(adminApiClientContent).toContain("return this.post<UserDto>(`/admin/users`, userData)");
|
|
});
|
|
|
|
it('should have proper request construction in getDashboardStats method', async () => {
|
|
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
|
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
|
|
|
// Verify GET request
|
|
expect(adminApiClientContent).toContain("return this.get<DashboardStats>(`/admin/dashboard/stats`)");
|
|
});
|
|
});
|
|
|
|
describe('Request Correctness Tests', () => {
|
|
it('should validate ListUsersRequestDto query parameters', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const schema = spec.components.schemas['ListUsersRequestDto'];
|
|
|
|
// Verify all query parameters are optional (no required fields)
|
|
expect(schema.required).toBeUndefined();
|
|
|
|
// Verify enum values for role
|
|
expect(schema.properties?.role?.enum).toBeUndefined(); // No enum constraint in DTO
|
|
|
|
// Verify enum values for status
|
|
expect(schema.properties?.status?.enum).toBeUndefined(); // No enum constraint in DTO
|
|
|
|
// Verify enum values for sortBy
|
|
expect(schema.properties?.sortBy?.enum).toEqual([
|
|
'email',
|
|
'displayName',
|
|
'createdAt',
|
|
'lastLoginAt',
|
|
'status'
|
|
]);
|
|
|
|
// Verify enum values for sortDirection
|
|
expect(schema.properties?.sortDirection?.enum).toEqual(['asc', 'desc']);
|
|
expect(schema.properties?.sortDirection?.default).toBe('asc');
|
|
|
|
// Verify numeric constraints
|
|
expect(schema.properties?.page?.minimum).toBe(1);
|
|
expect(schema.properties?.page?.default).toBe(1);
|
|
expect(schema.properties?.limit?.minimum).toBe(1);
|
|
expect(schema.properties?.limit?.maximum).toBe(100);
|
|
expect(schema.properties?.limit?.default).toBe(10);
|
|
});
|
|
|
|
it('should validate UserResponseDto field constraints', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const schema = spec.components.schemas['UserResponseDto'];
|
|
|
|
// Verify required fields
|
|
expect(schema.required).toContain('id');
|
|
expect(schema.required).toContain('email');
|
|
expect(schema.required).toContain('displayName');
|
|
expect(schema.required).toContain('roles');
|
|
expect(schema.required).toContain('status');
|
|
expect(schema.required).toContain('isSystemAdmin');
|
|
expect(schema.required).toContain('createdAt');
|
|
expect(schema.required).toContain('updatedAt');
|
|
|
|
// Verify optional fields are nullable
|
|
expect(schema.properties?.lastLoginAt?.nullable).toBe(true);
|
|
expect(schema.properties?.primaryDriverId?.nullable).toBe(true);
|
|
|
|
// Verify roles is an array
|
|
expect(schema.properties?.roles?.type).toBe('array');
|
|
expect(schema.properties?.roles?.items?.type).toBe('string');
|
|
});
|
|
|
|
it('should validate DashboardStatsResponseDto structure', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const schema = spec.components.schemas['DashboardStatsResponseDto'];
|
|
|
|
// Verify all required fields
|
|
const requiredFields = [
|
|
'totalUsers',
|
|
'activeUsers',
|
|
'suspendedUsers',
|
|
'deletedUsers',
|
|
'systemAdmins',
|
|
'recentLogins',
|
|
'newUsersToday',
|
|
'userGrowth',
|
|
'roleDistribution',
|
|
'statusDistribution',
|
|
'activityTimeline'
|
|
];
|
|
|
|
for (const field of requiredFields) {
|
|
expect(schema.required).toContain(field);
|
|
}
|
|
|
|
// Verify nested object structures
|
|
expect(schema.properties?.userGrowth?.items?.$ref).toBe('#/components/schemas/UserGrowthDto');
|
|
expect(schema.properties?.roleDistribution?.items?.$ref).toBe('#/components/schemas/RoleDistributionDto');
|
|
expect(schema.properties?.statusDistribution?.$ref).toBe('#/components/schemas/StatusDistributionDto');
|
|
expect(schema.properties?.activityTimeline?.items?.$ref).toBe('#/components/schemas/ActivityTimelineDto');
|
|
});
|
|
});
|
|
|
|
describe('Response Handling Tests', () => {
|
|
it('should handle successful user list response', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
|
const userSchema = spec.components.schemas['UserResponseDto'];
|
|
|
|
// Verify response structure
|
|
expect(userListSchema.properties?.users).toBeDefined();
|
|
expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
|
|
|
|
// Verify user object has all required fields
|
|
expect(userSchema.required).toContain('id');
|
|
expect(userSchema.required).toContain('email');
|
|
expect(userSchema.required).toContain('displayName');
|
|
expect(userSchema.required).toContain('roles');
|
|
expect(userSchema.required).toContain('status');
|
|
expect(userSchema.required).toContain('isSystemAdmin');
|
|
expect(userSchema.required).toContain('createdAt');
|
|
expect(userSchema.required).toContain('updatedAt');
|
|
|
|
// Verify optional fields
|
|
expect(userSchema.properties?.lastLoginAt).toBeDefined();
|
|
expect(userSchema.properties?.primaryDriverId).toBeDefined();
|
|
});
|
|
|
|
it('should handle successful dashboard stats response', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
|
|
|
|
// Verify all required fields are present
|
|
expect(dashboardSchema.required).toContain('totalUsers');
|
|
expect(dashboardSchema.required).toContain('activeUsers');
|
|
expect(dashboardSchema.required).toContain('suspendedUsers');
|
|
expect(dashboardSchema.required).toContain('deletedUsers');
|
|
expect(dashboardSchema.required).toContain('systemAdmins');
|
|
expect(dashboardSchema.required).toContain('recentLogins');
|
|
expect(dashboardSchema.required).toContain('newUsersToday');
|
|
expect(dashboardSchema.required).toContain('userGrowth');
|
|
expect(dashboardSchema.required).toContain('roleDistribution');
|
|
expect(dashboardSchema.required).toContain('statusDistribution');
|
|
expect(dashboardSchema.required).toContain('activityTimeline');
|
|
|
|
// Verify nested objects are properly typed
|
|
expect(dashboardSchema.properties?.userGrowth?.type).toBe('array');
|
|
expect(dashboardSchema.properties?.roleDistribution?.type).toBe('array');
|
|
expect(dashboardSchema.properties?.statusDistribution?.type).toBe('object');
|
|
expect(dashboardSchema.properties?.activityTimeline?.type).toBe('array');
|
|
});
|
|
|
|
it('should handle optional fields in user response', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const userSchema = spec.components.schemas['UserResponseDto'];
|
|
|
|
// Verify optional fields are nullable
|
|
expect(userSchema.properties?.lastLoginAt?.nullable).toBe(true);
|
|
expect(userSchema.properties?.primaryDriverId?.nullable).toBe(true);
|
|
|
|
// Verify optional fields are not in required array
|
|
expect(userSchema.required).not.toContain('lastLoginAt');
|
|
expect(userSchema.required).not.toContain('primaryDriverId');
|
|
});
|
|
|
|
it('should handle pagination fields correctly', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
|
|
|
// Verify pagination fields are required
|
|
expect(userListSchema.required).toContain('total');
|
|
expect(userListSchema.required).toContain('page');
|
|
expect(userListSchema.required).toContain('limit');
|
|
expect(userListSchema.required).toContain('totalPages');
|
|
|
|
// Verify pagination field types
|
|
expect(userListSchema.properties?.total?.type).toBe('number');
|
|
expect(userListSchema.properties?.page?.type).toBe('number');
|
|
expect(userListSchema.properties?.limit?.type).toBe('number');
|
|
expect(userListSchema.properties?.totalPages?.type).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('Error Handling Tests', () => {
|
|
it('should document 403 Forbidden response for admin endpoints', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const listUsersPath = spec.paths['/admin/users']?.get;
|
|
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
|
|
|
|
// Verify 403 response is documented
|
|
expect(listUsersPath.responses['403']).toBeDefined();
|
|
expect(dashboardStatsPath.responses['403']).toBeDefined();
|
|
});
|
|
|
|
it('should document 401 Unauthorized response for admin endpoints', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const listUsersPath = spec.paths['/admin/users']?.get;
|
|
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
|
|
|
|
// Verify 401 response is documented
|
|
expect(listUsersPath.responses['401']).toBeDefined();
|
|
expect(dashboardStatsPath.responses['401']).toBeDefined();
|
|
});
|
|
|
|
it('should document 400 Bad Request response for invalid query parameters', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const listUsersPath = spec.paths['/admin/users']?.get;
|
|
|
|
// Verify 400 response is documented
|
|
expect(listUsersPath.responses['400']).toBeDefined();
|
|
});
|
|
|
|
it('should document 500 Internal Server Error response', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const listUsersPath = spec.paths['/admin/users']?.get;
|
|
const dashboardStatsPath = spec.paths['/admin/dashboard/stats']?.get;
|
|
|
|
// Verify 500 response is documented
|
|
expect(listUsersPath.responses['500']).toBeDefined();
|
|
expect(dashboardStatsPath.responses['500']).toBeDefined();
|
|
});
|
|
|
|
it('should document 404 Not Found response for user operations', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
// Check for user-specific endpoints (if they exist)
|
|
const getUserPath = spec.paths['/admin/users/{userId}']?.get;
|
|
if (getUserPath) {
|
|
expect(getUserPath.responses['404']).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('should document 409 Conflict response for duplicate operations', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
// Check for create user endpoint (if it exists)
|
|
const createUserPath = spec.paths['/admin/users']?.post;
|
|
if (createUserPath) {
|
|
expect(createUserPath.responses['409']).toBeDefined();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('Semantic Guarantee Tests', () => {
|
|
it('should maintain ordering guarantees for user list', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const listUsersPath = spec.paths['/admin/users']?.get;
|
|
|
|
// Verify sortBy and sortDirection parameters are documented
|
|
const params = listUsersPath.parameters || [];
|
|
const sortByParam = params.find((p: any) => p.name === 'sortBy');
|
|
const sortDirectionParam = params.find((p: any) => p.name === 'sortDirection');
|
|
|
|
expect(sortByParam).toBeDefined();
|
|
expect(sortDirectionParam).toBeDefined();
|
|
|
|
// Verify sortDirection has default value
|
|
expect(sortDirectionParam?.schema?.default).toBe('asc');
|
|
});
|
|
|
|
it('should validate pagination consistency', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
|
|
|
// Verify pagination fields are all required
|
|
expect(userListSchema.required).toContain('page');
|
|
expect(userListSchema.required).toContain('limit');
|
|
expect(userListSchema.required).toContain('total');
|
|
expect(userListSchema.required).toContain('totalPages');
|
|
|
|
// Verify page and limit have constraints
|
|
const listUsersPath = spec.paths['/admin/users']?.get;
|
|
const params = listUsersPath.parameters || [];
|
|
const pageParam = params.find((p: any) => p.name === 'page');
|
|
const limitParam = params.find((p: any) => p.name === 'limit');
|
|
|
|
expect(pageParam?.schema?.minimum).toBe(1);
|
|
expect(pageParam?.schema?.default).toBe(1);
|
|
expect(limitParam?.schema?.minimum).toBe(1);
|
|
expect(limitParam?.schema?.maximum).toBe(100);
|
|
expect(limitParam?.schema?.default).toBe(10);
|
|
});
|
|
|
|
it('should validate idempotency for user status updates', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
// Check for user status update endpoint (if it exists)
|
|
const updateUserStatusPath = spec.paths['/admin/users/{userId}/status']?.patch;
|
|
if (updateUserStatusPath) {
|
|
// Verify it accepts a status parameter
|
|
const params = updateUserStatusPath.parameters || [];
|
|
const statusParam = params.find((p: any) => p.name === 'status');
|
|
expect(statusParam).toBeDefined();
|
|
}
|
|
});
|
|
|
|
it('should validate uniqueness constraints for user email', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const userSchema = spec.components.schemas['UserResponseDto'];
|
|
|
|
// Verify email is a required field
|
|
expect(userSchema.required).toContain('email');
|
|
expect(userSchema.properties?.email?.type).toBe('string');
|
|
|
|
// Check for create user endpoint (if it exists)
|
|
const createUserPath = spec.paths['/admin/users']?.post;
|
|
if (createUserPath) {
|
|
// Verify email is required in request body
|
|
const requestBody = createUserPath.requestBody;
|
|
if (requestBody && requestBody.content && requestBody.content['application/json']) {
|
|
const schema = requestBody.content['application/json'].schema;
|
|
if (schema && schema.$ref) {
|
|
// This would reference a CreateUserDto which should have email as required
|
|
expect(schema.$ref).toContain('CreateUserDto');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should validate consistency between request and response schemas', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const userSchema = spec.components.schemas['UserResponseDto'];
|
|
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
|
|
|
// Verify UserListResponse contains array of UserResponse
|
|
expect(userListSchema.properties?.users?.items?.$ref).toBe('#/components/schemas/UserResponseDto');
|
|
|
|
// Verify UserResponse has consistent field types
|
|
expect(userSchema.properties?.id?.type).toBe('string');
|
|
expect(userSchema.properties?.email?.type).toBe('string');
|
|
expect(userSchema.properties?.displayName?.type).toBe('string');
|
|
expect(userSchema.properties?.roles?.type).toBe('array');
|
|
expect(userSchema.properties?.status?.type).toBe('string');
|
|
expect(userSchema.properties?.isSystemAdmin?.type).toBe('boolean');
|
|
});
|
|
|
|
it('should validate semantic consistency in dashboard stats', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
|
|
|
|
// Verify totalUsers >= activeUsers + suspendedUsers + deletedUsers
|
|
// (This is a semantic guarantee that should be enforced by the backend)
|
|
expect(dashboardSchema.properties?.totalUsers).toBeDefined();
|
|
expect(dashboardSchema.properties?.activeUsers).toBeDefined();
|
|
expect(dashboardSchema.properties?.suspendedUsers).toBeDefined();
|
|
expect(dashboardSchema.properties?.deletedUsers).toBeDefined();
|
|
|
|
// Verify systemAdmins is a subset of totalUsers
|
|
expect(dashboardSchema.properties?.systemAdmins).toBeDefined();
|
|
|
|
// Verify recentLogins and newUsersToday are non-negative
|
|
expect(dashboardSchema.properties?.recentLogins).toBeDefined();
|
|
expect(dashboardSchema.properties?.newUsersToday).toBeDefined();
|
|
});
|
|
|
|
it('should validate pagination metadata consistency', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
|
|
|
// Verify pagination metadata is always present
|
|
expect(userListSchema.required).toContain('page');
|
|
expect(userListSchema.required).toContain('limit');
|
|
expect(userListSchema.required).toContain('total');
|
|
expect(userListSchema.required).toContain('totalPages');
|
|
|
|
// Verify totalPages calculation is consistent
|
|
// totalPages should be >= 1 and should be calculated as Math.ceil(total / limit)
|
|
expect(userListSchema.properties?.totalPages?.type).toBe('number');
|
|
});
|
|
});
|
|
|
|
describe('Admin Module Integration Tests', () => {
|
|
it('should have consistent types between API DTOs and website types', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
|
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
|
|
|
// Verify UserDto interface matches UserResponseDto schema
|
|
const userSchema = spec.components.schemas['UserResponseDto'];
|
|
expect(adminTypesContent).toContain('export interface AdminUserDto');
|
|
|
|
// Check all required fields from schema are in interface
|
|
for (const field of userSchema.required || []) {
|
|
expect(adminTypesContent).toContain(`${field}:`);
|
|
}
|
|
|
|
// Check all properties from schema are in interface
|
|
if (userSchema.properties) {
|
|
for (const propName of Object.keys(userSchema.properties)) {
|
|
expect(adminTypesContent).toContain(propName);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have consistent query types between API and website', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
|
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
|
|
|
// Verify ListUsersQuery interface matches ListUsersRequestDto schema
|
|
const listUsersSchema = spec.components.schemas['ListUsersRequestDto'];
|
|
expect(adminTypesContent).toContain('export interface AdminListUsersQueryDto');
|
|
|
|
// Check all properties from schema are in interface
|
|
if (listUsersSchema.properties) {
|
|
for (const propName of Object.keys(listUsersSchema.properties)) {
|
|
expect(adminTypesContent).toContain(propName);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should have consistent response types between API and website', async () => {
|
|
const content = await fs.readFile(openapiPath, 'utf-8');
|
|
const spec: OpenAPISpec = JSON.parse(content);
|
|
|
|
const adminTypesPath = path.join(websiteTypesDir, 'tbd', 'AdminUserDto.ts');
|
|
const adminTypesContent = await fs.readFile(adminTypesPath, 'utf-8');
|
|
|
|
// Verify UserListResponse interface matches UserListResponseDto schema
|
|
const userListSchema = spec.components.schemas['UserListResponseDto'];
|
|
expect(adminTypesContent).toContain('export interface AdminUserListResponseDto');
|
|
|
|
// Check all required fields from schema are in interface
|
|
for (const field of userListSchema.required || []) {
|
|
expect(adminTypesContent).toContain(`${field}:`);
|
|
}
|
|
|
|
// Verify DashboardStats interface matches DashboardStatsResponseDto schema
|
|
const dashboardSchema = spec.components.schemas['DashboardStatsResponseDto'];
|
|
expect(adminTypesContent).toContain('export interface AdminDashboardStatsDto');
|
|
|
|
// Check all required fields from schema are in interface
|
|
for (const field of dashboardSchema.required || []) {
|
|
expect(adminTypesContent).toContain(`${field}:`);
|
|
}
|
|
});
|
|
|
|
it('should have AdminApiClient methods matching API endpoints', async () => {
|
|
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
|
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
|
|
|
// Verify listUsers method exists and uses correct endpoint
|
|
expect(adminApiClientContent).toContain('async listUsers');
|
|
expect(adminApiClientContent).toContain("return this.get<UserListResponse>(`/admin/users?${params.toString()}`)");
|
|
|
|
// Verify getDashboardStats method exists and uses correct endpoint
|
|
expect(adminApiClientContent).toContain('async getDashboardStats');
|
|
expect(adminApiClientContent).toContain("return this.get<DashboardStats>(`/admin/dashboard/stats`)");
|
|
});
|
|
|
|
it('should have proper error handling in AdminApiClient', async () => {
|
|
const adminApiClientPath = path.join(apiRoot, 'apps/website/lib/api/admin/AdminApiClient.ts');
|
|
const adminApiClientContent = await fs.readFile(adminApiClientPath, 'utf-8');
|
|
|
|
// Verify BaseApiClient is extended (which provides error handling)
|
|
expect(adminApiClientContent).toContain('extends BaseApiClient');
|
|
|
|
// Verify methods use BaseApiClient methods (which handle errors)
|
|
expect(adminApiClientContent).toContain('this.get<');
|
|
expect(adminApiClientContent).toContain('this.post<');
|
|
expect(adminApiClientContent).toContain('this.patch<');
|
|
expect(adminApiClientContent).toContain('this.delete<');
|
|
});
|
|
});
|
|
});
|