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
13 changes: 13 additions & 0 deletions docs/api/listings-investments.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions src/investments/investments.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions src/listings/listings.controller.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
13 changes: 13 additions & 0 deletions src/listings/listings.module.ts
Original file line number Diff line number Diff line change
@@ -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 {}
41 changes: 41 additions & 0 deletions src/listings/listings.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
40 changes: 40 additions & 0 deletions src/listings/listings.service.ts
Original file line number Diff line number Diff line change
@@ -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() };
}
}
}