diff --git a/src/app.module.ts b/src/app.module.ts index d2ea22ea..c17b1b73 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -92,6 +92,7 @@ import { APP_FILTER } from '@nestjs/core'; PropertyComparisonModule, OpenHouseModule, MortgageCalculatorModule, + InvestmentsModule, SupportTicketsModule, AuditModule, MetricsModule, diff --git a/src/investments/dto/create-investment.dto.ts b/src/investments/dto/create-investment.dto.ts new file mode 100644 index 00000000..aff82835 --- /dev/null +++ b/src/investments/dto/create-investment.dto.ts @@ -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; +} diff --git a/src/investments/investment-rate-limit.guard.ts b/src/investments/investment-rate-limit.guard.ts new file mode 100644 index 00000000..51d32ba6 --- /dev/null +++ b/src/investments/investment-rate-limit.guard.ts @@ -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(); + + 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; + } +} diff --git a/test/e2e/investments-controller.integration.spec.ts b/test/e2e/investments-controller.integration.spec.ts new file mode 100644 index 00000000..fa6b2f8f --- /dev/null +++ b/test/e2e/investments-controller.integration.spec.ts @@ -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) => 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); + }); +});