diff --git a/docs/api/listings-investments.md b/docs/api/listings-investments.md new file mode 100644 index 00000000..4ff0577f --- /dev/null +++ b/docs/api/listings-investments.md @@ -0,0 +1,13 @@ +# Listing & Investment API + +## ListingAPI + +- `GET /listings?page=1&limit=10` – paginated listings. `limit` is capped at 100. + Response: `{ data: [...], meta: { page, limit, total, totalPages } }` +- `GET /listings/health` – health check for the listing service (`status: ok | error`). +- All listing endpoints use `TraceInterceptor`, so each request gets an `X-Trace-Id` response header and trace logs. + +## InvestmentAPI + +- `InvestmentsService.calculateRoi(amountInvested, currentValue)` returns profit and ROI percentage. +- Unit tests: `src/investments/investments.service.spec.ts`. diff --git a/src/app.module.ts b/src/app.module.ts index 61371855..d2ea22ea 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -46,6 +46,7 @@ import { HealthModule } from './health/health.module'; import { InvestmentsModule } from './investments/investments.module'; // Issue #919 – Data archival strategy import { ArchiveModule } from './archive/archive.module'; +import { ListingsModule } from './listings/listings.module'; // Issue #920 – Automated cleanup of expired records import { CleanupService } from './database/cleanup.service'; // Issue #964 – Localized error messages with i18n diff --git a/src/investments/investments.service.spec.ts b/src/investments/investments.service.spec.ts new file mode 100644 index 00000000..7154324a --- /dev/null +++ b/src/investments/investments.service.spec.ts @@ -0,0 +1,27 @@ +import { BadRequestException } from '@nestjs/common'; +import { InvestmentsService } from './investments.service'; + +describe('InvestmentsService', () => { + let service: InvestmentsService; + + beforeEach(() => { + service = new InvestmentsService(); + }); + + it('calculates a positive ROI', () => { + expect(service.calculateRoi(1000, 1200)).toEqual({ + amountInvested: 1000, + currentValue: 1200, + profit: 200, + roiPercent: 20, + }); + }); + + it('calculates a negative ROI', () => { + expect(service.calculateRoi(1000, 800).roiPercent).toBe(-20); + }); + + it('throws when amountInvested is not positive', () => { + expect(() => service.calculateRoi(0, 100)).toThrow(BadRequestException); + }); +}); diff --git a/src/listings/listings.controller.ts b/src/listings/listings.controller.ts new file mode 100644 index 00000000..f70b41f2 --- /dev/null +++ b/src/listings/listings.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Query, UseInterceptors } from '@nestjs/common'; +import { ListingsService } from './listings.service'; +import { TraceInterceptor } from '../tracing/trace.interceptor'; + +@Controller('listings') +@UseInterceptors(TraceInterceptor) +export class ListingsController { + constructor(private readonly listingsService: ListingsService) {} + + @Get() + findAll(@Query('page') page?: string, @Query('limit') limit?: string) { + return this.listingsService.findAll(Number(page) || 1, Number(limit) || 10); + } + + @Get('health') + health() { + return this.listingsService.checkHealth(); + } +} diff --git a/src/listings/listings.module.ts b/src/listings/listings.module.ts new file mode 100644 index 00000000..c11a63b3 --- /dev/null +++ b/src/listings/listings.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from '../database/prisma.module'; +import { ListingsController } from './listings.controller'; +import { ListingsService } from './listings.service'; +import { TraceInterceptor } from '../tracing/trace.interceptor'; + +@Module({ + imports: [PrismaModule], + controllers: [ListingsController], + providers: [ListingsService, TraceInterceptor], + exports: [ListingsService], +}) +export class ListingsModule {} diff --git a/src/listings/listings.service.spec.ts b/src/listings/listings.service.spec.ts new file mode 100644 index 00000000..86ccb020 --- /dev/null +++ b/src/listings/listings.service.spec.ts @@ -0,0 +1,41 @@ +import { ListingsService } from './listings.service'; +import { PrismaService } from '../database/prisma.service'; + +describe('ListingsService', () => { + let service: ListingsService; + let prisma: any; + + beforeEach(() => { + prisma = { + property: { + findMany: jest.fn().mockResolvedValue([{ id: 'p1' }]), + count: jest.fn().mockResolvedValue(25), + }, + }; + service = new ListingsService(prisma as unknown as PrismaService); + }); + + it('returns paginated listings with meta', async () => { + const result = await service.findAll(2, 10); + expect(prisma.property.findMany).toHaveBeenCalledWith( + expect.objectContaining({ skip: 10, take: 10 }), + ); + expect(result.meta).toEqual({ page: 2, limit: 10, total: 25, totalPages: 3 }); + }); + + it('caps limit at 100', async () => { + const result = await service.findAll(1, 500); + expect(result.meta.limit).toBe(100); + }); + + it('health returns ok when database is reachable', async () => { + const result = await service.checkHealth(); + expect(result.status).toBe('ok'); + }); + + it('health returns error when database fails', async () => { + prisma.property.count.mockRejectedValueOnce(new Error('db down')); + const result = await service.checkHealth(); + expect(result.status).toBe('error'); + }); +}); diff --git a/src/listings/listings.service.ts b/src/listings/listings.service.ts new file mode 100644 index 00000000..db0b2ab9 --- /dev/null +++ b/src/listings/listings.service.ts @@ -0,0 +1,40 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '../database/prisma.service'; + +@Injectable() +export class ListingsService { + constructor(private readonly prisma: PrismaService) {} + + async findAll(page = 1, limit = 10) { + const safePage = Math.max(1, Number(page) || 1); + const safeLimit = Math.min(100, Math.max(1, Number(limit) || 10)); + + const [data, total] = await Promise.all([ + this.prisma.property.findMany({ + skip: (safePage - 1) * safeLimit, + take: safeLimit, + orderBy: { createdAt: 'desc' }, + }), + this.prisma.property.count(), + ]); + + return { + data, + meta: { + page: safePage, + limit: safeLimit, + total, + totalPages: Math.ceil(total / safeLimit), + }, + }; + } + + async checkHealth() { + try { + await this.prisma.property.count(); + return { status: 'ok', service: 'listings', timestamp: new Date().toISOString() }; + } catch { + return { status: 'error', service: 'listings', timestamp: new Date().toISOString() }; + } + } +}