From 815d059fb2eff3e9c07761f69864302beaf51887 Mon Sep 17 00:00:00 2001 From: devzakari-sketch Date: Sun, 18 Oct 2026 00:06:08 +0100 Subject: [PATCH] feat(investments): add InvestmentAPI with caching, rate limiting, OpenAPI docs and integration tests Closes #1396 Closes #1401 Closes #1402 Closes #1403 Co-Authored-By: Claude Opus 5.5 --- docs/api/investments.md | 28 +++++++ src/app.module.ts | 2 + src/investments/dto/create-investment.dto.ts | 27 +++++++ .../investment-rate-limit.guard.ts | 27 +++++++ src/investments/investments.controller.ts | 38 +++++++++ src/investments/investments.module.ts | 10 +++ src/investments/investments.service.ts | 47 +++++++++++ ...investments-controller.integration.spec.ts | 77 +++++++++++++++++++ 8 files changed, 256 insertions(+) create mode 100644 docs/api/investments.md create mode 100644 src/investments/dto/create-investment.dto.ts create mode 100644 src/investments/investment-rate-limit.guard.ts create mode 100644 src/investments/investments.controller.ts create mode 100644 src/investments/investments.module.ts create mode 100644 src/investments/investments.service.ts create mode 100644 test/e2e/investments-controller.integration.spec.ts diff --git a/docs/api/investments.md b/docs/api/investments.md new file mode 100644 index 00000000..b6efb5f8 --- /dev/null +++ b/docs/api/investments.md @@ -0,0 +1,28 @@ +# Investment API + +Base path: `/investments` (Swagger tag: `investments`). The full OpenAPI spec is served by the Swagger UI. + +| Method | Path | Description | Responses | +| ------ | ------------------ | -------------------------------- | ------------- | +| POST | `/investments` | Create an investment | 201, 400, 429 | +| GET | `/investments` | List investments (cached) | 200, 429 | +| GET | `/investments/:id` | Get an investment by ID (cached) | 200, 404, 429 | + +## Request body (POST) + +```json +{ "propertyId": "property-123", "amount": 5000 } +``` + +## Caching + +Reads go through the shared Redis-backed `CacheService` under the `investments` tag. +Creating an investment invalidates that tag. + +## Rate limiting + +`InvestmentRateLimitGuard` allows 30 requests per minute per IP. Exceeding it returns `429 Too Many Requests`. + +## Tests + +See `test/e2e/investments-controller.integration.spec.ts`. diff --git a/src/app.module.ts b/src/app.module.ts index 1cd91a8b..befcc5ae 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -31,6 +31,7 @@ import { PropertyViewsModule } from './property-views/property-views.module'; import { PropertyComparisonModule } from './property-comparison/property-comparison.module'; import { OpenHouseModule } from './open-house/open-house.module'; import { MortgageCalculatorModule } from './mortgage-calculator/mortgage-calculator.module'; +import { InvestmentsModule } from './investments/investments.module'; import { SupportTicketsModule } from './support-tickets/support-tickets.module'; import { AuditModule } from './audit/audit.module'; import { MetricsModule } from './metrics/metrics.module'; @@ -88,6 +89,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/src/investments/investments.controller.ts b/src/investments/investments.controller.ts new file mode 100644 index 00000000..ed48d398 --- /dev/null +++ b/src/investments/investments.controller.ts @@ -0,0 +1,38 @@ +import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger'; +import { InvestmentsService } from './investments.service'; +import { CreateInvestmentDto, InvestmentResponseDto } from './dto/create-investment.dto'; +import { InvestmentRateLimitGuard } from './investment-rate-limit.guard'; + +@ApiTags('investments') +@Controller('investments') +@UseGuards(InvestmentRateLimitGuard) +export class InvestmentsController { + constructor(private readonly investmentsService: InvestmentsService) {} + + @Post() + @ApiOperation({ summary: 'Create an investment' }) + @ApiResponse({ status: 201, type: InvestmentResponseDto }) + @ApiResponse({ status: 400, description: 'Validation failed' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) + create(@Body() dto: CreateInvestmentDto) { + return this.investmentsService.create(dto); + } + + @Get() + @ApiOperation({ summary: 'List investments (cached)' }) + @ApiResponse({ status: 200, type: [InvestmentResponseDto] }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) + findAll() { + return this.investmentsService.findAll(); + } + + @Get(':id') + @ApiOperation({ summary: 'Get an investment by ID (cached)' }) + @ApiResponse({ status: 200, type: InvestmentResponseDto }) + @ApiResponse({ status: 404, description: 'Investment not found' }) + @ApiResponse({ status: 429, description: 'Rate limit exceeded' }) + findOne(@Param('id') id: string) { + return this.investmentsService.findOne(id); + } +} diff --git a/src/investments/investments.module.ts b/src/investments/investments.module.ts new file mode 100644 index 00000000..d97c57d6 --- /dev/null +++ b/src/investments/investments.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { InvestmentsController } from './investments.controller'; +import { InvestmentsService } from './investments.service'; +import { InvestmentRateLimitGuard } from './investment-rate-limit.guard'; + +@Module({ + controllers: [InvestmentsController], + providers: [InvestmentsService, InvestmentRateLimitGuard], +}) +export class InvestmentsModule {} diff --git a/src/investments/investments.service.ts b/src/investments/investments.service.ts new file mode 100644 index 00000000..3120e8d1 --- /dev/null +++ b/src/investments/investments.service.ts @@ -0,0 +1,47 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { CacheService } from '../cache/cache.service'; +import { CreateInvestmentDto, InvestmentResponseDto } from './dto/create-investment.dto'; + +const CACHE_TAG = 'investments'; + +@Injectable() +export class InvestmentsService { + private readonly investments = new Map(); + + constructor(private readonly cacheService: CacheService) {} + + async create(dto: CreateInvestmentDto): Promise { + const investment: InvestmentResponseDto = { + id: randomUUID(), + propertyId: dto.propertyId, + amount: dto.amount, + createdAt: new Date().toISOString(), + }; + this.investments.set(investment.id, investment); + await this.cacheService.invalidateByTag(CACHE_TAG); + return investment; + } + + findAll(): Promise { + return this.cacheService.getOrSetLegacy( + 'investments:all', + async () => Array.from(this.investments.values()), + undefined, + CACHE_TAG, + ); + } + + async findOne(id: string): Promise { + const investment = await this.cacheService.getOrSetLegacy( + `investments:${id}`, + async () => this.investments.get(id) ?? null, + undefined, + CACHE_TAG, + ); + if (!investment) { + throw new NotFoundException(`Investment ${id} not found`); + } + return investment; + } +} 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); + }); +});