From 25f415f3bcc525b813850d1cd2cea23968762737 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 09:18:32 +0700 Subject: [PATCH] test(reports): add unit tests for report handlers and domain entity Add tests for GenerateReport, GetReport, DeleteReport command/query handlers and Report entity domain logic. Co-Authored-By: Paperclip --- .../__tests__/delete-report.handler.spec.ts | 62 ++++++++++++++++ .../__tests__/generate-report.handler.spec.ts | 54 ++++++++++++++ .../__tests__/get-report.handler.spec.ts | 65 +++++++++++++++++ .../domain/__tests__/report.entity.spec.ts | 71 +++++++++++++++++++ apps/web/components/reports/report-chart.tsx | 4 +- 5 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/modules/reports/application/__tests__/delete-report.handler.spec.ts create mode 100644 apps/api/src/modules/reports/application/__tests__/generate-report.handler.spec.ts create mode 100644 apps/api/src/modules/reports/application/__tests__/get-report.handler.spec.ts create mode 100644 apps/api/src/modules/reports/domain/__tests__/report.entity.spec.ts diff --git a/apps/api/src/modules/reports/application/__tests__/delete-report.handler.spec.ts b/apps/api/src/modules/reports/application/__tests__/delete-report.handler.spec.ts new file mode 100644 index 0000000..f6b38a3 --- /dev/null +++ b/apps/api/src/modules/reports/application/__tests__/delete-report.handler.spec.ts @@ -0,0 +1,62 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ReportEntity } from '../../domain/entities/report.entity'; +import { ReportStatus } from '../../domain/enums/report-status.enum'; +import { ReportType } from '../../domain/enums/report-type.enum'; +import { type IReportRepository } from '../../domain/repositories/report.repository'; +import { DeleteReportCommand } from '../commands/delete-report/delete-report.command'; +import { DeleteReportHandler } from '../commands/delete-report/delete-report.handler'; + +function createReport(userId = 'user-1'): ReportEntity { + return new ReportEntity('rpt-1', { + userId, + type: ReportType.RESIDENTIAL_MARKET, + title: 'Test Report', + params: {}, + content: null, + pdfUrl: null, + status: ReportStatus.READY, + errorMsg: null, + }); +} + +describe('DeleteReportHandler', () => { + let handler: DeleteReportHandler; + let mockReportRepo: { [K in keyof IReportRepository]: ReturnType }; + + beforeEach(() => { + mockReportRepo = { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + countByUserInPeriod: vi.fn(), + }; + + handler = new DeleteReportHandler(mockReportRepo as any); + }); + + it('deletes a report owned by the user', async () => { + mockReportRepo.findById.mockResolvedValue(createReport('user-1')); + + await handler.execute(new DeleteReportCommand('rpt-1', 'user-1')); + + expect(mockReportRepo.delete).toHaveBeenCalledWith('rpt-1'); + }); + + it('throws NotFoundException when report does not exist', async () => { + mockReportRepo.findById.mockResolvedValue(null); + + await expect( + handler.execute(new DeleteReportCommand('nonexistent', 'user-1')), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ForbiddenException when user does not own report', async () => { + mockReportRepo.findById.mockResolvedValue(createReport('other-user')); + + await expect( + handler.execute(new DeleteReportCommand('rpt-1', 'user-1')), + ).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/apps/api/src/modules/reports/application/__tests__/generate-report.handler.spec.ts b/apps/api/src/modules/reports/application/__tests__/generate-report.handler.spec.ts new file mode 100644 index 0000000..03a0b21 --- /dev/null +++ b/apps/api/src/modules/reports/application/__tests__/generate-report.handler.spec.ts @@ -0,0 +1,54 @@ +import type { ReportEntity } from '../../domain/entities/report.entity'; +import { ReportStatus } from '../../domain/enums/report-status.enum'; +import { ReportType } from '../../domain/enums/report-type.enum'; +import { type IReportRepository } from '../../domain/repositories/report.repository'; +import { GenerateReportCommand } from '../commands/generate-report/generate-report.command'; +import { GenerateReportHandler } from '../commands/generate-report/generate-report.handler'; + +describe('GenerateReportHandler', () => { + let handler: GenerateReportHandler; + let mockReportRepo: { [K in keyof IReportRepository]: ReturnType }; + let mockQueue: { add: ReturnType }; + + beforeEach(() => { + mockReportRepo = { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + countByUserInPeriod: vi.fn(), + }; + + mockQueue = { + add: vi.fn().mockResolvedValue(undefined), + }; + + handler = new GenerateReportHandler(mockReportRepo as any, mockQueue as any); + }); + + it('creates a report and enqueues generation job', async () => { + const command = new GenerateReportCommand( + 'user-1', + ReportType.INDUSTRIAL_LOCATION, + 'Báo cáo KCN Bình Dương', + { province: 'Bình Dương' }, + ); + + const result = await handler.execute(command); + + expect(result.reportId).toBeDefined(); + expect(mockReportRepo.save).toHaveBeenCalledOnce(); + + const savedReport = mockReportRepo.save.mock.calls[0][0] as ReportEntity; + expect(savedReport.userId).toBe('user-1'); + expect(savedReport.type).toBe(ReportType.INDUSTRIAL_LOCATION); + expect(savedReport.status).toBe(ReportStatus.GENERATING); + + expect(mockQueue.add).toHaveBeenCalledWith( + 'generate', + { reportId: result.reportId }, + expect.objectContaining({ attempts: 2 }), + ); + }); +}); diff --git a/apps/api/src/modules/reports/application/__tests__/get-report.handler.spec.ts b/apps/api/src/modules/reports/application/__tests__/get-report.handler.spec.ts new file mode 100644 index 0000000..148af8a --- /dev/null +++ b/apps/api/src/modules/reports/application/__tests__/get-report.handler.spec.ts @@ -0,0 +1,65 @@ +import { ForbiddenException, NotFoundException } from '@nestjs/common'; +import { ReportEntity } from '../../domain/entities/report.entity'; +import { ReportStatus } from '../../domain/enums/report-status.enum'; +import { ReportType } from '../../domain/enums/report-type.enum'; +import { type IReportRepository } from '../../domain/repositories/report.repository'; +import { GetReportHandler } from '../queries/get-report/get-report.handler'; +import { GetReportQuery } from '../queries/get-report/get-report.query'; + +function createReport(userId = 'user-1'): ReportEntity { + return new ReportEntity('rpt-1', { + userId, + type: ReportType.DISTRICT_ANALYSIS, + title: 'Phân tích Quận 1', + params: { city: 'Hồ Chí Minh', district: 'Quận 1' }, + content: { sections: {} }, + pdfUrl: null, + status: ReportStatus.READY, + errorMsg: null, + }); +} + +describe('GetReportHandler', () => { + let handler: GetReportHandler; + let mockReportRepo: { [K in keyof IReportRepository]: ReturnType }; + + beforeEach(() => { + mockReportRepo = { + findById: vi.fn(), + findByUserId: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + countByUserInPeriod: vi.fn(), + }; + + handler = new GetReportHandler(mockReportRepo as any); + }); + + it('returns report for the owner', async () => { + const report = createReport('user-1'); + mockReportRepo.findById.mockResolvedValue(report); + + const result = await handler.execute(new GetReportQuery('rpt-1', 'user-1')); + + expect(result.id).toBe('rpt-1'); + expect(result.type).toBe(ReportType.DISTRICT_ANALYSIS); + expect(result.status).toBe(ReportStatus.READY); + }); + + it('throws NotFoundException when report does not exist', async () => { + mockReportRepo.findById.mockResolvedValue(null); + + await expect( + handler.execute(new GetReportQuery('nonexistent', 'user-1')), + ).rejects.toThrow(NotFoundException); + }); + + it('throws ForbiddenException when user does not own report', async () => { + mockReportRepo.findById.mockResolvedValue(createReport('other-user')); + + await expect( + handler.execute(new GetReportQuery('rpt-1', 'user-1')), + ).rejects.toThrow(ForbiddenException); + }); +}); diff --git a/apps/api/src/modules/reports/domain/__tests__/report.entity.spec.ts b/apps/api/src/modules/reports/domain/__tests__/report.entity.spec.ts new file mode 100644 index 0000000..8437282 --- /dev/null +++ b/apps/api/src/modules/reports/domain/__tests__/report.entity.spec.ts @@ -0,0 +1,71 @@ +import { ReportEntity } from '../entities/report.entity'; +import { ReportStatus } from '../enums/report-status.enum'; +import { ReportType } from '../enums/report-type.enum'; + +describe('ReportEntity', () => { + it('creates a new report in GENERATING status', () => { + const report = ReportEntity.createNew( + 'rpt-1', + 'user-1', + ReportType.INDUSTRIAL_LOCATION, + 'KCN Bình Dương', + { province: 'Bình Dương' }, + ); + + expect(report.id).toBe('rpt-1'); + expect(report.userId).toBe('user-1'); + expect(report.type).toBe(ReportType.INDUSTRIAL_LOCATION); + expect(report.status).toBe(ReportStatus.GENERATING); + expect(report.content).toBeNull(); + expect(report.pdfUrl).toBeNull(); + expect(report.errorMsg).toBeNull(); + }); + + it('transitions to READY with content and PDF URL', () => { + const report = ReportEntity.createNew( + 'rpt-2', + 'user-1', + ReportType.RESIDENTIAL_MARKET, + 'Thị trường Q1 2026', + { city: 'HCM' }, + ); + + const content = { sections: { summary: { title: 'Tóm tắt', content: 'Test' } } }; + report.markReady(content, 'https://minio/reports/rpt-2.pdf'); + + expect(report.status).toBe(ReportStatus.READY); + expect(report.content).toEqual(content); + expect(report.pdfUrl).toBe('https://minio/reports/rpt-2.pdf'); + }); + + it('transitions to READY without PDF URL', () => { + const report = ReportEntity.createNew( + 'rpt-3', + 'user-1', + ReportType.DISTRICT_ANALYSIS, + 'Phân tích Quận 1', + {}, + ); + + report.markReady({ sections: {} }, null); + + expect(report.status).toBe(ReportStatus.READY); + expect(report.pdfUrl).toBeNull(); + }); + + it('transitions to FAILED with error message', () => { + const report = ReportEntity.createNew( + 'rpt-4', + 'user-1', + ReportType.PORTFOLIO, + 'Portfolio', + {}, + ); + + report.markFailed('Claude API timeout'); + + expect(report.status).toBe(ReportStatus.FAILED); + expect(report.errorMsg).toBe('Claude API timeout'); + expect(report.content).toBeNull(); + }); +}); diff --git a/apps/web/components/reports/report-chart.tsx b/apps/web/components/reports/report-chart.tsx index 78c669a..8d71f1c 100644 --- a/apps/web/components/reports/report-chart.tsx +++ b/apps/web/components/reports/report-chart.tsx @@ -72,7 +72,7 @@ export function ReportChart({ [formatValue(value, unit), title]} + formatter={(value) => [formatValue(Number(value), unit), title]} contentStyle={{ fontSize: 12 }} /> @@ -83,7 +83,7 @@ export function ReportChart({ [formatValue(value, unit), title]} + formatter={(value) => [formatValue(Number(value), unit), title]} contentStyle={{ fontSize: 12 }} />