test(reports): add unit tests for report handlers and domain entity
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 21s
CI / E2E Tests (push) Has been skipped
Deploy / Build API Image (push) Failing after 3m40s
Deploy / Build Web Image (push) Failing after 15s
Deploy / Build AI Services Image (push) Failing after 16s
E2E Tests / Playwright E2E (push) Failing after 2m3s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 23m49s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 16s
Security Scanning / Trivy Scan — API Image (push) Failing after 1m24s
Security Scanning / Trivy Scan — Web Image (push) Failing after 34s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 22s
Security Scanning / Trivy Filesystem Scan (push) Failing after 18s
Security Scanning / Security Gate (push) Failing after 1s

Add tests for GenerateReport, GetReport, DeleteReport command/query
handlers and Report entity domain logic.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-16 09:18:32 +07:00
parent 3a9325719a
commit 25f415f3bc
5 changed files with 254 additions and 2 deletions

View File

@@ -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<typeof vi.fn> };
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);
});
});

View File

@@ -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<typeof vi.fn> };
let mockQueue: { add: ReturnType<typeof vi.fn> };
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 }),
);
});
});

View File

@@ -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<typeof vi.fn> };
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);
});
});

View File

@@ -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();
});
});

View File

@@ -72,7 +72,7 @@ export function ReportChart({
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number) => [formatValue(value, unit), title]}
formatter={(value) => [formatValue(Number(value), unit), title]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="value" fill={color} radius={[4, 4, 0, 0]} />
@@ -83,7 +83,7 @@ export function ReportChart({
<XAxis dataKey="name" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip
formatter={(value: number) => [formatValue(value, unit), title]}
formatter={(value) => [formatValue(Number(value), unit), title]}
contentStyle={{ fontSize: 12 }}
/>
<Area