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
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user