diff --git a/BackendAcademy/src/app.controller.spec.ts b/BackendAcademy/src/app.controller.spec.ts index 4f5dfe55e..e11c1cb15 100644 --- a/BackendAcademy/src/app.controller.spec.ts +++ b/BackendAcademy/src/app.controller.spec.ts @@ -1,7 +1,24 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; +import { ArgumentMetadata, BadRequestException, ValidationPipe, ValidationError } from '@nestjs/common'; +import { IsNotEmpty, IsString, MinLength } from 'class-validator'; +import { + AppController, + formatValidationErrors, + validationExceptionFactory, +} from './app.controller'; import { AppService } from './app.service'; +class SampleUserDto { + @IsString() + @IsNotEmpty() + @MinLength(3) + username!: string; + + @IsString() + @IsNotEmpty() + email!: string; +} + describe('AppController', () => { let appController: AppController; @@ -19,4 +36,109 @@ describe('AppController', () => { expect(appController.getHello()).toBe('RustAcademy BackendAcademy API'); }); }); + + describe('Standardized API Validation Error Responses (BA-114)', () => { + describe('formatValidationErrors & validationExceptionFactory', () => { + it('should format ValidationError items into stable field and constraints structure', () => { + const mockError: ValidationError = { + property: 'username', + value: 'ab', + constraints: { + minLength: 'username must be longer than or equal to 3 characters', + }, + children: [], + }; + + const formatted = formatValidationErrors([mockError]); + expect(formatted).toHaveLength(1); + expect(formatted[0].field).toBe('username'); + expect(formatted[0].value).toBe('ab'); + expect(formatted[0].constraints).toContain( + 'username must be longer than or equal to 3 characters', + ); + }); + + it('should construct a BadRequestException with stable code VALIDATION_FAILED', () => { + const mockError: ValidationError = { + property: 'email', + value: '', + constraints: { + isNotEmpty: 'email should not be empty', + }, + children: [], + }; + + const exception = validationExceptionFactory([mockError]); + expect(exception).toBeInstanceOf(BadRequestException); + expect(exception.getStatus()).toBe(400); + + const response: any = exception.getResponse(); + expect(response.statusCode).toBe(400); + expect(response.code).toBe('VALIDATION_FAILED'); + expect(response.message).toBe('Validation failed for request payload'); + expect(response.errors).toBeDefined(); + expect(response.errors[0].field).toBe('email'); + }); + }); + + describe('ValidationPipe with validationExceptionFactory', () => { + let pipe: ValidationPipe; + + beforeEach(() => { + pipe = new ValidationPipe({ + whitelist: true, + transform: true, + forbidNonWhitelisted: true, + exceptionFactory: validationExceptionFactory, + }); + }); + + it('should return stable error code and field details when payload fails DTO validation', async () => { + const metadata: ArgumentMetadata = { + type: 'body', + metatype: SampleUserDto, + data: '', + }; + + const invalidPayload = { + username: 'a', // Too short + email: '', // Empty + }; + + try { + await pipe.transform(invalidPayload, metadata); + fail('Expected ValidationPipe to throw BadRequestException'); + } catch (err: any) { + expect(err).toBeInstanceOf(BadRequestException); + const response = err.getResponse(); + expect(response.statusCode).toBe(400); + expect(response.code).toBe('VALIDATION_FAILED'); + expect(response.errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ field: 'username' }), + expect.objectContaining({ field: 'email' }), + ]), + ); + } + }); + + it('should cleanly accept valid DTO payloads without errors', async () => { + const metadata: ArgumentMetadata = { + type: 'body', + metatype: SampleUserDto, + data: '', + }; + + const validPayload = { + username: 'rustacean', + email: 'rust@academy.io', + }; + + const result = await pipe.transform(validPayload, metadata); + expect(result).toBeInstanceOf(SampleUserDto); + expect(result.username).toBe('rustacean'); + expect(result.email).toBe('rust@academy.io'); + }); + }); + }); }); diff --git a/BackendAcademy/src/app.controller.ts b/BackendAcademy/src/app.controller.ts index cce879ee6..8f17fd39a 100644 --- a/BackendAcademy/src/app.controller.ts +++ b/BackendAcademy/src/app.controller.ts @@ -1,6 +1,39 @@ -import { Controller, Get } from '@nestjs/common'; +import { BadRequestException, Controller, Get, ValidationError } from '@nestjs/common'; import { AppService } from './app.service'; +export interface ValidationErrorFieldDetail { + field: string; + value?: any; + constraints: string[]; +} + +export interface StandardValidationErrorPayload { + statusCode: 400; + code: 'VALIDATION_FAILED'; + message: string; + errors: ValidationErrorFieldDetail[]; +} + +export function formatValidationErrors( + errors: ValidationError[], +): ValidationErrorFieldDetail[] { + return errors.map((err) => ({ + field: err.property, + value: err.value, + constraints: Object.values(err.constraints || {}), + })); +} + +export function validationExceptionFactory(errors: ValidationError[]): BadRequestException { + const formatted = formatValidationErrors(errors); + return new BadRequestException({ + statusCode: 400, + code: 'VALIDATION_FAILED', + message: 'Validation failed for request payload', + errors: formatted, + }); +} + @Controller() export class AppController { constructor(private readonly appService: AppService) {} diff --git a/BackendAcademy/src/main.ts b/BackendAcademy/src/main.ts index 71c010075..e1a971a96 100644 --- a/BackendAcademy/src/main.ts +++ b/BackendAcademy/src/main.ts @@ -5,6 +5,7 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import helmet from 'helmet'; import { AppModule } from './app.module'; +import { validationExceptionFactory } from './app.controller'; // Application entry point: creates the Nest app, applies global // middleware/pipes, optionally sets up Swagger docs, and starts @@ -31,11 +32,13 @@ async function bootstrap() { // - transform: converts payloads into instances of their DTO classes // - forbidNonWhitelisted: rejects requests containing any property // not defined on the DTO (rather than silently dropping them) + // - exceptionFactory: standardizes validation error response format (BA-114) app.useGlobalPipes( new ValidationPipe({ whitelist: true, transform: true, forbidNonWhitelisted: true, + exceptionFactory: validationExceptionFactory, }), );