Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion BackendAcademy/src/app.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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');
});
});
});
});
35 changes: 34 additions & 1 deletion BackendAcademy/src/app.controller.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down
3 changes: 3 additions & 0 deletions BackendAcademy/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
}),
);

Expand Down