Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import { APP_FILTER } from '@nestjs/core';
PropertyComparisonModule,
OpenHouseModule,
MortgageCalculatorModule,
InvestmentsModule,
SupportTicketsModule,
AuditModule,
MetricsModule,
Expand Down
27 changes: 27 additions & 0 deletions src/investments/dto/create-investment.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsPositive, IsString } from 'class-validator';

export class CreateInvestmentDto {
@ApiProperty({ example: 'property-123', description: 'ID of the property being invested in' })
@IsString()
propertyId: string;

@ApiProperty({ example: 5000, description: 'Investment amount' })
@IsNumber()
@IsPositive()
amount: number;
}

export class InvestmentResponseDto {
@ApiProperty({ example: 'a1b2c3' })
id: string;

@ApiProperty({ example: 'property-123' })
propertyId: string;

@ApiProperty({ example: 5000 })
amount: number;

@ApiProperty({ example: '2026-01-01T00:00:00.000Z' })
createdAt: string;
}
27 changes: 27 additions & 0 deletions src/investments/investment-rate-limit.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Injectable } from '@nestjs/common';

export const INVESTMENT_RATE_LIMIT = { windowMs: 60_000, max: 30 };

/** Simple in-memory, per-IP fixed-window rate limiter for the Investment API. */
@Injectable()
export class InvestmentRateLimitGuard implements CanActivate {
private readonly hits = new Map<string, { count: number; resetAt: number }>();

canActivate(context: ExecutionContext): boolean {
const req = context.switchToHttp().getRequest();
const key = req.ip || 'unknown';
const now = Date.now();
const entry = this.hits.get(key);

if (!entry || entry.resetAt <= now) {
this.hits.set(key, { count: 1, resetAt: now + INVESTMENT_RATE_LIMIT.windowMs });
return true;
}

entry.count++;
if (entry.count > INVESTMENT_RATE_LIMIT.max) {
throw new HttpException('Too many requests, please try again later', HttpStatus.TOO_MANY_REQUESTS);
}
return true;
}
}
77 changes: 77 additions & 0 deletions test/e2e/investments-controller.integration.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* Integration tests: InvestmentsController over HTTP with a mocked CacheService.
*/

import { INestApplication, ValidationPipe } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { InvestmentsController } from '../../src/investments/investments.controller';
import { InvestmentsService } from '../../src/investments/investments.service';
import {
InvestmentRateLimitGuard,
INVESTMENT_RATE_LIMIT,
} from '../../src/investments/investment-rate-limit.guard';
import { CacheService } from '../../src/cache/cache.service';

describe('InvestmentsController (integration)', () => {
let app: INestApplication;
const cacheService = {
getOrSetLegacy: jest.fn((_key: string, factory: () => Promise<unknown>) => factory()),
invalidateByTag: jest.fn().mockResolvedValue({ success: true }),
};

beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [InvestmentsController],
providers: [
InvestmentsService,
InvestmentRateLimitGuard,
{ provide: CacheService, useValue: cacheService },
],
}).compile();

app = moduleRef.createNestApplication();
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
await app.init();
});

afterEach(async () => {
await app.close();
jest.clearAllMocks();
});

it('creates and fetches an investment', async () => {
const created = await request(app.getHttpServer())
.post('/investments')
.send({ propertyId: 'property-1', amount: 1000 })
.expect(201);

expect(cacheService.invalidateByTag).toHaveBeenCalledWith('investments');

const fetched = await request(app.getHttpServer())
.get(`/investments/${created.body.id}`)
.expect(200);
expect(fetched.body.amount).toBe(1000);

const list = await request(app.getHttpServer()).get('/investments').expect(200);
expect(list.body).toHaveLength(1);
});

it('returns 400 for invalid payload', async () => {
await request(app.getHttpServer())
.post('/investments')
.send({ propertyId: 'p', amount: -5 })
.expect(400);
});

it('returns 404 for unknown investment', async () => {
await request(app.getHttpServer()).get('/investments/missing').expect(404);
});

it('returns 429 when the rate limit is exceeded', async () => {
for (let i = 0; i < INVESTMENT_RATE_LIMIT.max; i++) {
await request(app.getHttpServer()).get('/investments').expect(200);
}
await request(app.getHttpServer()).get('/investments').expect(429);
});
});