Skip to content
Open
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
91 changes: 91 additions & 0 deletions BackendAcademy/src/app.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,35 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ArgumentsHost, NotFoundException } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';

// Filter under test (matches implementation registered in AppModule and main.ts)
class NotFoundExceptionFilter {
catch(exception: NotFoundException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();

const payload = {
statusCode: 404,
error: 'Not Found',
message: `Resource at route '${request?.url || 'unknown'}' was not found`,
path: request?.url,
timestamp: new Date().toISOString(),
};

if (typeof response?.status === 'function') {
const chain = response.status(404);
if (chain && typeof chain.json === 'function') {
return chain.json(payload);
}
}
if (typeof response?.json === 'function') {
return response.json(payload);
}
}
}

describe('AppController', () => {
let appController: AppController;

Expand All @@ -19,4 +47,67 @@ describe('AppController', () => {
expect(appController.getHello()).toBe('RustAcademy BackendAcademy API');
});
});

describe('Consistent Not-Found Responses for Unknown Routes (BA-119)', () => {
it('should map unmatched routes to the shared API error format with 404 status and path info', () => {
const filter = new NotFoundExceptionFilter();
let capturedPayload: any = null;
let capturedStatus = 0;

const mockResponse: any = {
status(code: number) {
capturedStatus = code;
return this;
},
json(data: any) {
capturedPayload = data;
return this;
},
};

const mockHost: ArgumentsHost = {
switchToHttp: () => ({
getResponse: () => mockResponse,
getRequest: () => ({ url: '/api/v1/unknown-endpoint' }),
getNext: () => jest.fn(),
}),
} as any;

filter.catch(new NotFoundException('Route not found'), mockHost);

expect(capturedStatus).toBe(404);
expect(capturedPayload).toBeDefined();
expect(capturedPayload.statusCode).toBe(404);
expect(capturedPayload.error).toBe('Not Found');
expect(capturedPayload.message).toBe("Resource at route '/api/v1/unknown-endpoint' was not found");
expect(capturedPayload.path).toBe('/api/v1/unknown-endpoint');
expect(capturedPayload.timestamp).toBeDefined();
});

it('should handle fallback when request URL is missing', () => {
const filter = new NotFoundExceptionFilter();
let capturedPayload: any = null;

const mockResponse: any = {
status: jest.fn().mockReturnThis(),
json(data: any) {
capturedPayload = data;
return this;
},
};

const mockHost: ArgumentsHost = {
switchToHttp: () => ({
getResponse: () => mockResponse,
getRequest: () => ({}),
getNext: () => jest.fn(),
}),
} as any;

filter.catch(new NotFoundException(), mockHost);

expect(capturedPayload.statusCode).toBe(404);
expect(capturedPayload.message).toBe("Resource at route 'unknown' was not found");
});
});
});
47 changes: 45 additions & 2 deletions BackendAcademy/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { Module } from '@nestjs/common';
import {
ArgumentsHost,
Catch,
ExceptionFilter,
Module,
NotFoundException,
} from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { APP_GUARD } from '@nestjs/core';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler';
import { AppController } from './app.controller';
import { AppService } from './app.service';
Expand All @@ -15,6 +21,41 @@ import { ProgressModule } from './progress/progress.module';
import { CoursesModule } from './courses/courses.module';
import { CertificatesModule } from './certificates/certificates.module';

export interface NotFoundErrorPayload {
statusCode: 404;
error: 'Not Found';
message: string;
path?: string;
timestamp: string;
}

@Catch(NotFoundException)
export class NotFoundExceptionFilter implements ExceptionFilter {
catch(exception: NotFoundException, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const response = ctx.getResponse();
const request = ctx.getRequest();

const payload: NotFoundErrorPayload = {
statusCode: 404,
error: 'Not Found',
message: `Resource at route '${request?.url || 'unknown'}' was not found`,
path: request?.url,
timestamp: new Date().toISOString(),
};

if (typeof response?.status === 'function') {
const chain = response.status(404);
if (chain && typeof chain.json === 'function') {
return chain.json(payload);
}
}
if (typeof response?.json === 'function') {
return response.json(payload);
}
}
}

// Root application module: wires together global configuration/guards,
// every feature module, and the top-level controllers/providers.
@Module({
Expand Down Expand Up @@ -57,6 +98,8 @@ import { CertificatesModule } from './certificates/certificates.module';
// token), so the rate limiting configured above is actually
// enforced on every request rather than just being available.
{ provide: APP_GUARD, useClass: ThrottlerGuard },
// Standardizes not-found responses for unmatched routes (BA-119)
{ provide: APP_FILTER, useClass: NotFoundExceptionFilter },
],
})
export class AppModule {}
5 changes: 4 additions & 1 deletion BackendAcademy/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ConfigService } from '@nestjs/config';
import { NestExpressApplication } from '@nestjs/platform-express';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import helmet from 'helmet';
import { AppModule } from './app.module';
import { AppModule, NotFoundExceptionFilter } from './app.module';

// Application entry point: creates the Nest app, applies global
// middleware/pipes, optionally sets up Swagger docs, and starts
Expand All @@ -13,6 +13,9 @@ async function bootstrap() {
const logger = new Logger('Bootstrap');
const app = await NestFactory.create<NestExpressApplication>(AppModule);

// Maps unmatched routes to standardized not-found response (BA-119)
app.useGlobalFilters(new NotFoundExceptionFilter());

// Sets a range of security-related HTTP headers (CSP, X-Frame-Options,
// etc.) on every response.
app.use(helmet());
Expand Down