Skip to content
17 changes: 5 additions & 12 deletions backend/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ import { GracefulShutdownService } from './common/services/graceful-shutdown.ser
import { ApmModule } from './modules/apm/apm.module';
import { PerformanceModule } from './modules/performance/performance.module';
import { SandboxModule } from './modules/sandbox/sandbox.module';
import { FeedbackModule } from './modules/feedback/feedback.module';
import { StatisticsModule } from './modules/statistics/statistics.module';
import { FeatureFlagsModule } from './modules/feature-flags/feature-flags.module';

const envValidationSchema = Joi.object({
NODE_ENV: Joi.string().valid('development', 'production', 'test').required(),
Expand Down Expand Up @@ -229,15 +232,6 @@ const envValidationSchema = Joi.object({
};
},
}),
BullModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
redis: {
uri: config.get<string>('REDIS_URL') || 'redis://localhost:6379',
},
}),
}),
ConfigModule.forRoot({
isGlobal: true,
load: [configuration],
Expand Down Expand Up @@ -285,9 +279,7 @@ const envValidationSchema = Joi.object({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
redis: {
uri: config.get<string>('REDIS_URL') || 'redis://localhost:6379',
},
redis: config.get<string>('REDIS_URL') || 'redis://localhost:6379',
}),
}),
EventEmitterModule.forRoot(),
Expand Down Expand Up @@ -333,6 +325,7 @@ const envValidationSchema = Joi.object({
FeatureFlagsModule,
JobsModule,
SandboxModule,
FeedbackModule,
CommonModule,
ThrottlerModule.forRoot([
{
Expand Down
4 changes: 3 additions & 1 deletion backend/src/common/decorators/shutdown-task.decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function ShutdownTrackedTask(taskName?: string): MethodDecorator {
return descriptor;
}

descriptor.value = async function (...args: unknown[]) {
const wrapped = async function (this: unknown, ...args: unknown[]) {
const resolvedTaskName =
taskName ?? `${target.constructor.name}.${String(propertyKey)}`;

Expand All @@ -17,6 +17,8 @@ export function ShutdownTrackedTask(taskName?: string): MethodDecorator {
);
};

descriptor.value = wrapped as typeof originalMethod;

return descriptor;
};
}
2 changes: 1 addition & 1 deletion backend/src/common/dto/api-error-response.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export class ValidationErrorDto extends ApiErrorResponseDto {
],
description: 'Validation errors',
})
errors?: Array<{
declare errors?: Array<{
field: string;
value?: unknown;
constraints: Record<string, string>;
Expand Down
31 changes: 18 additions & 13 deletions backend/src/common/dto/api-responses.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,34 @@ export class ErrorResponseDto {

export class UnauthorizedResponseDto extends ErrorResponseDto {
@ApiProperty({ example: 401 })
statusCode: number;
declare statusCode: number;

@ApiProperty({ example: 'Unauthorized' })
message: string;
declare message: string;
}

export class ForbiddenResponseDto extends ErrorResponseDto {
@ApiProperty({ example: 403 })
statusCode: number;
declare statusCode: number;

@ApiProperty({ example: 'Forbidden resource' })
message: string;
declare message: string;
}

export class NotFoundResponseDto extends ErrorResponseDto {
@ApiProperty({ example: 404 })
statusCode: number;
declare statusCode: number;

@ApiProperty({ example: 'Resource not found' })
message: string;
declare message: string;
}

export class ConflictResponseDto extends ErrorResponseDto {
@ApiProperty({ example: 409 })
statusCode: number;
declare statusCode: number;

@ApiProperty({ example: 'Resource already exists' })
message: string;
declare message: string;
}

export class TooManyRequestsResponseDto {
Expand All @@ -57,24 +57,29 @@ export class TooManyRequestsResponseDto {
@ApiProperty({ example: 429 })
statusCode: number;

@ApiProperty({ example: 'Rate limit exceeded for free tier. Maximum 60 requests per 60 seconds.' })
@ApiProperty({
example:
'Rate limit exceeded for free tier. Maximum 60 requests per 60 seconds.',
})
message: string;

@ApiProperty({
description: 'Seconds to wait before retrying (also returned in Retry-After header)',
description:
'Seconds to wait before retrying (also returned in Retry-After header)',
example: 60,
})
retryAfter: number;
}

export class ValidationErrorResponseDto extends ErrorResponseDto {
@ApiProperty({ example: 422 })
statusCode: number;
declare statusCode: number;

@ApiProperty({
example: 'targetAmount must be a positive number; goalName should not be empty',
example:
'targetAmount must be a positive number; goalName should not be empty',
})
message: string;
declare message: string;
}

/** Generic paginated wrapper. */
Expand Down
5 changes: 1 addition & 4 deletions backend/src/common/exceptions/application.exception.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export class ApplicationException extends HttpException {
constructor(
public readonly errorCode: string,
messageOrStatus?: string | number,
public readonly context?: Record<string, unknown>,
public readonly context: Record<string, unknown> = {},
) {
const message =
typeof messageOrStatus === 'string' ? messageOrStatus : errorCode;
Expand All @@ -23,9 +23,6 @@ export class ApplicationException extends HttpException {
* Add contextual information (method chaining)
*/
withContext(key: string, value: unknown): this {
if (!this.context) {
(this as any).context = {};
}
this.context[key] = value;
return this;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { EMPTY, of } from 'rxjs';
import { EMPTY, of, firstValueFrom } from 'rxjs';
import { GracefulShutdownInterceptor } from './graceful-shutdown.interceptor';
import { GracefulShutdownService } from '../services/graceful-shutdown.service';

Expand Down Expand Up @@ -29,16 +29,19 @@ describe('GracefulShutdownInterceptor', () => {
const interceptor = new GracefulShutdownInterceptor(gracefulShutdown);
const { context, response } = createContext();

const result = interceptor.intercept(context as never, {
handle: () => of('ok'),
} as never);
const result = interceptor.intercept(
context as never,
{
handle: () => of('ok'),
} as never,
);

expect(result).toBe(EMPTY);
expect(response.status).toHaveBeenCalledWith(503);
expect(gracefulShutdown.incrementActiveRequests).not.toHaveBeenCalled();
});

it('tracks accepted requests until completion', (done) => {
it('tracks accepted requests until completion', async () => {
const gracefulShutdown = {
isShutdown: jest.fn().mockReturnValue(false),
incrementActiveRequests: jest.fn(),
Expand All @@ -47,16 +50,16 @@ describe('GracefulShutdownInterceptor', () => {
const interceptor = new GracefulShutdownInterceptor(gracefulShutdown);
const { context } = createContext();

interceptor
.intercept(context as never, {
handle: () => of('ok'),
} as never)
.subscribe({
complete: () => {
expect(gracefulShutdown.incrementActiveRequests).toHaveBeenCalled();
expect(gracefulShutdown.decrementActiveRequests).toHaveBeenCalled();
done();
},
});
await firstValueFrom(
interceptor.intercept(
context as never,
{
handle: () => of('ok'),
} as never,
),
);

expect(gracefulShutdown.incrementActiveRequests).toHaveBeenCalled();
expect(gracefulShutdown.decrementActiveRequests).toHaveBeenCalled();
});
});
98 changes: 61 additions & 37 deletions backend/src/common/interceptors/idempotency.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { ExecutionContext, CallHandler, ConflictException, BadRequestException } from '@nestjs/common';
import {
ExecutionContext,
CallHandler,
ConflictException,
BadRequestException,
} from '@nestjs/common';
import { IdempotencyInterceptor } from './idempotency.interceptor';
import { IdempotencyService } from '../services/idempotency.service';
import { of, throwError } from 'rxjs';
import { of, throwError, firstValueFrom } from 'rxjs';

describe('IdempotencyInterceptor', () => {
let interceptor: IdempotencyInterceptor;
Expand All @@ -19,15 +24,20 @@ describe('IdempotencyInterceptor', () => {
interceptor = new IdempotencyInterceptor(idempotencyService);
});

const createMockContext = (method: string, headers: any, user: any = { id: 'user1' }): ExecutionContext => ({
switchToHttp: () => ({
getRequest: () => ({
method,
headers,
user,
const createMockContext = (
method: string,
headers: any,
user: any = { id: 'user1' },
): ExecutionContext =>
({
switchToHttp: () => ({
getRequest: () => ({
method,
headers,
user,
}),
}),
}),
} as any);
}) as any;

const mockCallHandler: CallHandler = {
handle: () => of({ success: true }),
Expand All @@ -53,17 +63,20 @@ describe('IdempotencyInterceptor', () => {
expect(idempotencyService.getResponse).not.toHaveBeenCalled();
});

it('should return cached response if key exists', async (done) => {
it('should return cached response if key exists', (done) => {
const context = createMockContext('POST', { 'x-idempotency-key': 'key1' });
const cachedResponse = { success: true, fromCache: true };
idempotencyService.getResponse.mockResolvedValue(cachedResponse);

const result$ = await interceptor.intercept(context, mockCallHandler);

result$.subscribe(response => {
expect(response).toEqual(cachedResponse);
expect(idempotencyService.getResponse).toHaveBeenCalledWith('key1', 'user1');
done();
interceptor.intercept(context, mockCallHandler).then((result$) => {
result$.subscribe((response) => {
expect(response).toEqual(cachedResponse);
expect(idempotencyService.getResponse).toHaveBeenCalledWith(
'key1',
'user1',
);
done();
});
});
});

Expand All @@ -72,41 +85,52 @@ describe('IdempotencyInterceptor', () => {
idempotencyService.getResponse.mockResolvedValue(null);
idempotencyService.isProcessing.mockResolvedValue(true);

await expect(interceptor.intercept(context, mockCallHandler)).rejects.toThrow(ConflictException);
await expect(
interceptor.intercept(context, mockCallHandler),
).rejects.toThrow(ConflictException);
});

it('should process request and cache response if key is new', async (done) => {
it('should process request and cache response if key is new', async () => {
const context = createMockContext('POST', { 'x-idempotency-key': 'key1' });
idempotencyService.getResponse.mockResolvedValue(null);
idempotencyService.isProcessing.mockResolvedValue(false);

const result$ = await interceptor.intercept(context, mockCallHandler);

result$.subscribe(() => {
expect(idempotencyService.setProcessing).toHaveBeenCalledWith('key1', 'user1');
expect(idempotencyService.saveResponse).toHaveBeenCalledWith('key1', 'user1', { success: true });
expect(idempotencyService.removeProcessing).toHaveBeenCalledWith('key1', 'user1');
done();
});
await firstValueFrom(await interceptor.intercept(context, mockCallHandler));
await Promise.resolve();

expect(idempotencyService.setProcessing).toHaveBeenCalledWith(
'key1',
'user1',
);
expect(idempotencyService.saveResponse).toHaveBeenCalledWith(
'key1',
'user1',
{ success: true },
);
expect(idempotencyService.removeProcessing).toHaveBeenCalledWith(
'key1',
'user1',
);
});

it('should remove processing lock even if request fails', async (done) => {
it('should remove processing lock even if request fails', async () => {
const context = createMockContext('POST', { 'x-idempotency-key': 'key1' });
idempotencyService.getResponse.mockResolvedValue(null);
idempotencyService.isProcessing.mockResolvedValue(false);

const failingHandler: CallHandler = {
handle: () => throwError(() => new Error('API Error')),
};

const result$ = await interceptor.intercept(context, failingHandler);
await expect(
firstValueFrom(await interceptor.intercept(context, failingHandler)),
).rejects.toThrow('API Error');
await Promise.resolve();

result$.subscribe({
error: () => {
expect(idempotencyService.removeProcessing).toHaveBeenCalledWith('key1', 'user1');
expect(idempotencyService.saveResponse).not.toHaveBeenCalled();
done();
}
});
expect(idempotencyService.removeProcessing).toHaveBeenCalledWith(
'key1',
'user1',
);
expect(idempotencyService.saveResponse).not.toHaveBeenCalled();
});
});
Loading
Loading