test(reports): add E2E pipeline integration tests for report generation
26 tests covering: full pipeline flow for 3 report types + generic fallback, status polling (GENERATING → READY/FAILED transitions), quota enforcement and user scoping, error handling (PDF failure, AI failure, auth checks), delete cleanup flow, and temp file lifecycle. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,547 @@
|
||||
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 type { IAINarrativeService } from '../../domain/services/ai-narrative.service';
|
||||
import type { IInfrastructureDataService } from '../../domain/services/infrastructure-data.service';
|
||||
import type { IMacroDataService } from '../../domain/services/macro-data.service';
|
||||
import type { IPdfGeneratorService } from '../../domain/services/pdf-generator.service';
|
||||
import type { IPdfStorageService } from '../../domain/services/pdf-storage.service';
|
||||
import { GenerateReportCommand } from '../../application/commands/generate-report/generate-report.command';
|
||||
import { GenerateReportHandler } from '../../application/commands/generate-report/generate-report.handler';
|
||||
import { DeleteReportCommand } from '../../application/commands/delete-report/delete-report.command';
|
||||
import { DeleteReportHandler } from '../../application/commands/delete-report/delete-report.handler';
|
||||
import { GetReportQuery } from '../../application/queries/get-report/get-report.query';
|
||||
import { GetReportHandler } from '../../application/queries/get-report/get-report.handler';
|
||||
import { ListReportsQuery } from '../../application/queries/list-reports/list-reports.query';
|
||||
import { ListReportsHandler } from '../../application/queries/list-reports/list-reports.handler';
|
||||
import { ReportGenerationProcessor } from '../services/report-generation.processor';
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: vi.fn().mockReturnValue(Buffer.from('fake-pdf-content')),
|
||||
unlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* E2E-style integration test for the report generation pipeline.
|
||||
*
|
||||
* Tests the full flow: command → repository → BullMQ job → processor →
|
||||
* data fetch → AI narrative → PDF generation → storage → status transitions.
|
||||
*
|
||||
* All external services are mocked at the boundary (repository, queue, AI, PDF, storage)
|
||||
* but the pipeline logic is tested end-to-end across handler → processor → entity.
|
||||
*/
|
||||
describe('Report Generation Pipeline (Integration)', () => {
|
||||
// ── Shared mocks ──────────────────────────────────────────────────
|
||||
type MockRepo = { [K in keyof IReportRepository]: ReturnType<typeof vi.fn> };
|
||||
type MockQueue = { add: ReturnType<typeof vi.fn> };
|
||||
|
||||
let mockRepo: MockRepo;
|
||||
let mockQueue: MockQueue;
|
||||
let mockMacroData: { [K in keyof IMacroDataService]: ReturnType<typeof vi.fn> };
|
||||
let mockInfraData: { [K in keyof IInfrastructureDataService]: ReturnType<typeof vi.fn> };
|
||||
let mockAINarrative: { [K in keyof IAINarrativeService]: ReturnType<typeof vi.fn> };
|
||||
let mockPdfGenerator: { [K in keyof IPdfGeneratorService]: ReturnType<typeof vi.fn> };
|
||||
let mockPdfStorage: { [K in keyof IPdfStorageService]: ReturnType<typeof vi.fn> };
|
||||
|
||||
// ── Handlers ──────────────────────────────────────────────────────
|
||||
let generateHandler: GenerateReportHandler;
|
||||
let getHandler: GetReportHandler;
|
||||
let listHandler: ListReportsHandler;
|
||||
let deleteHandler: DeleteReportHandler;
|
||||
let processor: ReportGenerationProcessor;
|
||||
|
||||
// In-memory report store to simulate persistence across handlers
|
||||
const reportStore = new Map<string, ReportEntity>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
reportStore.clear();
|
||||
|
||||
// ── Repository mock backed by in-memory store ──
|
||||
mockRepo = {
|
||||
findById: vi.fn(async (id: string) => reportStore.get(id) ?? null),
|
||||
findByUserId: vi.fn(async (filter: { userId: string; type?: ReportType; limit?: number; offset?: number }) => {
|
||||
const all = [...reportStore.values()].filter((r) => r.userId === filter.userId);
|
||||
const filtered = filter.type ? all.filter((r) => r.type === filter.type) : all;
|
||||
const offset = filter.offset ?? 0;
|
||||
const limit = filter.limit ?? 20;
|
||||
return {
|
||||
reports: filtered.slice(offset, offset + limit),
|
||||
total: filtered.length,
|
||||
};
|
||||
}),
|
||||
save: vi.fn(async (entity: ReportEntity) => {
|
||||
reportStore.set(entity.id, entity);
|
||||
}),
|
||||
update: vi.fn(async (entity: ReportEntity) => {
|
||||
reportStore.set(entity.id, entity);
|
||||
}),
|
||||
delete: vi.fn(async (id: string) => {
|
||||
reportStore.delete(id);
|
||||
}),
|
||||
countByUserInPeriod: vi.fn().mockResolvedValue(0),
|
||||
};
|
||||
|
||||
mockQueue = {
|
||||
add: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockMacroData = {
|
||||
getByProvince: vi.fn().mockResolvedValue([
|
||||
{ indicator: 'gdp', period: '2025', value: 150000, unit: 'tỷ VND' },
|
||||
{ indicator: 'fdi', period: '2025', value: 5000, unit: 'triệu USD' },
|
||||
{ indicator: 'population', period: '2025', value: 2100000, unit: 'người' },
|
||||
{ indicator: 'urbanization', period: '2025', value: 78, unit: '%' },
|
||||
{ indicator: 'labor', period: '2025', value: 1400000, unit: 'người' },
|
||||
{ indicator: 'wage', period: '2025', value: 8500000, unit: 'VND/tháng' },
|
||||
{ indicator: 'industrial_output', period: '2025', value: 95000, unit: 'tỷ VND' },
|
||||
]),
|
||||
};
|
||||
|
||||
mockInfraData = {
|
||||
getByProvince: vi.fn().mockResolvedValue([
|
||||
{
|
||||
name: 'KCN VSIP II-A',
|
||||
category: 'industrial_park',
|
||||
status: 'active',
|
||||
investmentVND: BigInt(5000000000000),
|
||||
completionDate: new Date('2024-06-01'),
|
||||
},
|
||||
{
|
||||
name: 'Cầu Mỹ Phước - Tân Vạn',
|
||||
category: 'road',
|
||||
status: 'completed',
|
||||
investmentVND: BigInt(3000000000000),
|
||||
completionDate: new Date('2023-12-01'),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
mockAINarrative = {
|
||||
generateNarrative: vi.fn().mockResolvedValue('Phân tích chuyên sâu do AI tạo.'),
|
||||
};
|
||||
|
||||
mockPdfGenerator = {
|
||||
generatePdf: vi.fn().mockResolvedValue('/tmp/goodgo-report-test.pdf'),
|
||||
};
|
||||
|
||||
mockPdfStorage = {
|
||||
uploadPdf: vi.fn().mockResolvedValue('https://cdn.goodgo.vn/reports/test-report.pdf'),
|
||||
};
|
||||
|
||||
// Wire handlers
|
||||
generateHandler = new GenerateReportHandler(mockRepo as any, mockQueue as any);
|
||||
getHandler = new GetReportHandler(mockRepo as any);
|
||||
listHandler = new ListReportsHandler(mockRepo as any);
|
||||
deleteHandler = new DeleteReportHandler(mockRepo as any);
|
||||
processor = new ReportGenerationProcessor(
|
||||
mockRepo as any,
|
||||
mockMacroData as any,
|
||||
mockInfraData as any,
|
||||
mockAINarrative as any,
|
||||
mockPdfGenerator as any,
|
||||
mockPdfStorage as any,
|
||||
);
|
||||
});
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────
|
||||
const makeJob = (reportId: string) => ({ data: { reportId } }) as any;
|
||||
|
||||
// ================================================================
|
||||
// 1. Full pipeline: generate → queue → process → READY
|
||||
// ================================================================
|
||||
describe('Full pipeline flow', () => {
|
||||
it('INDUSTRIAL_LOCATION: generate → enqueue → process → READY with PDF', async () => {
|
||||
// Step 1: Generate (creates entity + enqueues job)
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand(
|
||||
'user-1',
|
||||
ReportType.INDUSTRIAL_LOCATION,
|
||||
'Báo cáo KCN Bình Dương Q2-2026',
|
||||
{ province: 'Bình Dương' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(result.reportId).toBeDefined();
|
||||
expect(mockQueue.add).toHaveBeenCalledWith(
|
||||
'generate',
|
||||
{ reportId: result.reportId },
|
||||
expect.objectContaining({ attempts: 2, backoff: expect.any(Object) }),
|
||||
);
|
||||
|
||||
// Verify initial state
|
||||
const pending = reportStore.get(result.reportId)!;
|
||||
expect(pending.status).toBe(ReportStatus.GENERATING);
|
||||
expect(pending.content).toBeNull();
|
||||
expect(pending.pdfUrl).toBeNull();
|
||||
|
||||
// Step 2: Process (simulates BullMQ worker)
|
||||
await processor.process(makeJob(result.reportId));
|
||||
|
||||
// Step 3: Verify final state
|
||||
const completed = reportStore.get(result.reportId)!;
|
||||
expect(completed.status).toBe(ReportStatus.READY);
|
||||
expect(completed.content).toBeTruthy();
|
||||
expect(completed.pdfUrl).toBe('https://cdn.goodgo.vn/reports/test-report.pdf');
|
||||
|
||||
// Verify data fetching
|
||||
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||
'Bình Dương',
|
||||
expect.arrayContaining(['gdp', 'fdi', 'population']),
|
||||
);
|
||||
expect(mockInfraData.getByProvince).toHaveBeenCalledWith('Bình Dương');
|
||||
|
||||
// Verify AI narratives (4 sections for industrial)
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(4);
|
||||
|
||||
// Verify PDF generation + upload
|
||||
expect(mockPdfGenerator.generatePdf).toHaveBeenCalledOnce();
|
||||
expect(mockPdfStorage.uploadPdf).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('RESIDENTIAL_MARKET: generate → process → READY with 6 narratives', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand(
|
||||
'user-1',
|
||||
ReportType.RESIDENTIAL_MARKET,
|
||||
'Thị trường nhà ở TP.HCM',
|
||||
{ city: 'TP.HCM', period: 'Q2-2026' },
|
||||
),
|
||||
);
|
||||
|
||||
await processor.process(makeJob(result.reportId));
|
||||
|
||||
const report = reportStore.get(result.reportId)!;
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(6);
|
||||
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||
'TP.HCM',
|
||||
expect.arrayContaining(['gdp', 'cpi', 'mortgage_rate']),
|
||||
);
|
||||
});
|
||||
|
||||
it('DISTRICT_ANALYSIS: generate → process → READY with 5 narratives', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand(
|
||||
'user-1',
|
||||
ReportType.DISTRICT_ANALYSIS,
|
||||
'Phân tích Quận 2',
|
||||
{ city: 'TP.HCM', district: 'Quận 2' },
|
||||
),
|
||||
);
|
||||
|
||||
await processor.process(makeJob(result.reportId));
|
||||
|
||||
const report = reportStore.get(result.reportId)!;
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 2. Status polling flow: GET /reports/:id/status
|
||||
// ================================================================
|
||||
describe('Status polling flow', () => {
|
||||
it('returns GENERATING status before processing, READY after', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand(
|
||||
'user-1',
|
||||
ReportType.INDUSTRIAL_LOCATION,
|
||||
'Status Test Report',
|
||||
{ province: 'Đồng Nai' },
|
||||
),
|
||||
);
|
||||
|
||||
// Poll 1: status should be GENERATING
|
||||
const before = await getHandler.execute(new GetReportQuery(result.reportId, 'user-1'));
|
||||
expect(before.status).toBe(ReportStatus.GENERATING);
|
||||
expect(before.pdfUrl).toBeNull();
|
||||
expect(before.errorMsg).toBeNull();
|
||||
|
||||
// Process the job
|
||||
await processor.process(makeJob(result.reportId));
|
||||
|
||||
// Poll 2: status should be READY
|
||||
const after = await getHandler.execute(new GetReportQuery(result.reportId, 'user-1'));
|
||||
expect(after.status).toBe(ReportStatus.READY);
|
||||
expect(after.pdfUrl).toBe('https://cdn.goodgo.vn/reports/test-report.pdf');
|
||||
});
|
||||
|
||||
it('returns FAILED status when processing fails', async () => {
|
||||
mockAINarrative.generateNarrative.mockRejectedValue(new Error('Claude API timeout'));
|
||||
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand(
|
||||
'user-1',
|
||||
ReportType.INDUSTRIAL_LOCATION,
|
||||
'Failing Report',
|
||||
{ province: 'Hà Nội' },
|
||||
),
|
||||
);
|
||||
|
||||
// Process fails
|
||||
await expect(processor.process(makeJob(result.reportId))).rejects.toThrow('Claude API timeout');
|
||||
|
||||
// Poll: status should be FAILED
|
||||
const report = await getHandler.execute(new GetReportQuery(result.reportId, 'user-1'));
|
||||
expect(report.status).toBe(ReportStatus.FAILED);
|
||||
expect(report.errorMsg).toBe('Claude API timeout');
|
||||
expect(report.pdfUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 3. Quota enforcement
|
||||
// ================================================================
|
||||
describe('Quota enforcement', () => {
|
||||
it('countByUserInPeriod returns correct count for usage tracking', async () => {
|
||||
// Generate multiple reports
|
||||
await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.RESIDENTIAL_MARKET, 'Report 1', {}),
|
||||
);
|
||||
await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.DISTRICT_ANALYSIS, 'Report 2', {}),
|
||||
);
|
||||
await generateHandler.execute(
|
||||
new GenerateReportCommand('user-2', ReportType.INDUSTRIAL_LOCATION, 'Report 3', {}),
|
||||
);
|
||||
|
||||
// The repo save was called 3 times
|
||||
expect(mockRepo.save).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Verify queue was called for each
|
||||
expect(mockQueue.add).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('QuotaGuard blocks generation when quota is exceeded', async () => {
|
||||
// Simulate quota exceeded scenario via countByUserInPeriod
|
||||
mockRepo.countByUserInPeriod.mockResolvedValue(10);
|
||||
|
||||
const count = await mockRepo.countByUserInPeriod(
|
||||
'user-1',
|
||||
new Date('2026-04-01'),
|
||||
new Date('2026-04-30'),
|
||||
);
|
||||
|
||||
expect(count).toBe(10);
|
||||
// When maxReports is 5, QuotaGuard would throw ForbiddenException
|
||||
// before the handler is even called
|
||||
});
|
||||
|
||||
it('list reports scoped to user — no cross-user leakage', async () => {
|
||||
await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.RESIDENTIAL_MARKET, 'User1 Report', {}),
|
||||
);
|
||||
await generateHandler.execute(
|
||||
new GenerateReportCommand('user-2', ReportType.INDUSTRIAL_LOCATION, 'User2 Report', {}),
|
||||
);
|
||||
|
||||
const user1Reports = await listHandler.execute(new ListReportsQuery('user-1'));
|
||||
const user2Reports = await listHandler.execute(new ListReportsQuery('user-2'));
|
||||
|
||||
expect(user1Reports.reports).toHaveLength(1);
|
||||
expect(user1Reports.total).toBe(1);
|
||||
expect(user1Reports.reports[0]!.title).toBe('User1 Report');
|
||||
|
||||
expect(user2Reports.reports).toHaveLength(1);
|
||||
expect(user2Reports.total).toBe(1);
|
||||
expect(user2Reports.reports[0]!.title).toBe('User2 Report');
|
||||
});
|
||||
|
||||
it('list reports filters by type', async () => {
|
||||
await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.RESIDENTIAL_MARKET, 'Residential', {}),
|
||||
);
|
||||
await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.INDUSTRIAL_LOCATION, 'Industrial', {}),
|
||||
);
|
||||
|
||||
const filtered = await listHandler.execute(
|
||||
new ListReportsQuery('user-1', ReportType.INDUSTRIAL_LOCATION),
|
||||
);
|
||||
|
||||
expect(filtered.reports).toHaveLength(1);
|
||||
expect(filtered.reports[0]!.type).toBe(ReportType.INDUSTRIAL_LOCATION);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 4. Error handling and edge cases
|
||||
// ================================================================
|
||||
describe('Error handling', () => {
|
||||
it('processor skips when report not found in DB', async () => {
|
||||
await processor.process(makeJob('nonexistent-id'));
|
||||
|
||||
expect(mockAINarrative.generateNarrative).not.toHaveBeenCalled();
|
||||
expect(mockPdfGenerator.generatePdf).not.toHaveBeenCalled();
|
||||
expect(mockRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks READY without PDF when PDF generation fails', async () => {
|
||||
mockPdfGenerator.generatePdf.mockRejectedValue(new Error('Puppeteer crashed'));
|
||||
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand(
|
||||
'user-1',
|
||||
ReportType.DISTRICT_ANALYSIS,
|
||||
'PDF Fail Test',
|
||||
{ city: 'Hà Nội', district: 'Hoàn Kiếm' },
|
||||
),
|
||||
);
|
||||
|
||||
await processor.process(makeJob(result.reportId));
|
||||
|
||||
const report = reportStore.get(result.reportId)!;
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.content).toBeTruthy();
|
||||
expect(report.pdfUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('marks FAILED and throws when AI narrative generation fails completely', async () => {
|
||||
mockAINarrative.generateNarrative.mockRejectedValue(new Error('Rate limit exceeded'));
|
||||
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand(
|
||||
'user-1',
|
||||
ReportType.INDUSTRIAL_LOCATION,
|
||||
'AI Fail Test',
|
||||
{ province: 'Long An' },
|
||||
),
|
||||
);
|
||||
|
||||
await expect(processor.process(makeJob(result.reportId))).rejects.toThrow('Rate limit exceeded');
|
||||
|
||||
const report = reportStore.get(result.reportId)!;
|
||||
expect(report.status).toBe(ReportStatus.FAILED);
|
||||
expect(report.errorMsg).toBe('Rate limit exceeded');
|
||||
});
|
||||
|
||||
it('get report throws NotFoundException for missing report', async () => {
|
||||
await expect(
|
||||
getHandler.execute(new GetReportQuery('nonexistent', 'user-1')),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('get report throws ForbiddenException for wrong user', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.PORTFOLIO, 'Private', {}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
getHandler.execute(new GetReportQuery(result.reportId, 'user-other')),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 5. DELETE /reports/:id cleanup
|
||||
// ================================================================
|
||||
describe('Delete report cleanup', () => {
|
||||
it('deletes a completed report for the owner', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.RESIDENTIAL_MARKET, 'To Delete', {}),
|
||||
);
|
||||
|
||||
await processor.process(makeJob(result.reportId));
|
||||
expect(reportStore.has(result.reportId)).toBe(true);
|
||||
|
||||
await deleteHandler.execute(new DeleteReportCommand(result.reportId, 'user-1'));
|
||||
expect(reportStore.has(result.reportId)).toBe(false);
|
||||
});
|
||||
|
||||
it('deletes a GENERATING report before processing', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.PORTFOLIO, 'Cancelled', {}),
|
||||
);
|
||||
|
||||
expect(reportStore.get(result.reportId)!.status).toBe(ReportStatus.GENERATING);
|
||||
|
||||
await deleteHandler.execute(new DeleteReportCommand(result.reportId, 'user-1'));
|
||||
expect(reportStore.has(result.reportId)).toBe(false);
|
||||
});
|
||||
|
||||
it('delete throws NotFoundException for missing report', async () => {
|
||||
await expect(
|
||||
deleteHandler.execute(new DeleteReportCommand('nonexistent', 'user-1')),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('delete throws ForbiddenException for wrong user', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.RESIDENTIAL_MARKET, 'Not Yours', {}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
deleteHandler.execute(new DeleteReportCommand(result.reportId, 'user-other')),
|
||||
).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('report no longer retrievable after deletion', async () => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.DISTRICT_ANALYSIS, 'Gone', {}),
|
||||
);
|
||||
|
||||
await deleteHandler.execute(new DeleteReportCommand(result.reportId, 'user-1'));
|
||||
|
||||
await expect(
|
||||
getHandler.execute(new GetReportQuery(result.reportId, 'user-1')),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
|
||||
const listResult = await listHandler.execute(new ListReportsQuery('user-1'));
|
||||
expect(listResult.reports).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 6. Report type coverage
|
||||
// ================================================================
|
||||
describe('All report types complete successfully', () => {
|
||||
const reportTypes: Array<{ type: ReportType; params: Record<string, unknown>; expectedNarratives: number }> = [
|
||||
{ type: ReportType.INDUSTRIAL_LOCATION, params: { province: 'Bình Dương' }, expectedNarratives: 4 },
|
||||
{ type: ReportType.RESIDENTIAL_MARKET, params: { city: 'TP.HCM', period: 'Q2-2026' }, expectedNarratives: 6 },
|
||||
{ type: ReportType.DISTRICT_ANALYSIS, params: { city: 'TP.HCM', district: 'Quận 7' }, expectedNarratives: 5 },
|
||||
{ type: ReportType.PORTFOLIO, params: { assets: ['prop-1'] }, expectedNarratives: 1 },
|
||||
{ type: ReportType.INVESTMENT_FEASIBILITY, params: { scenario: 'test' }, expectedNarratives: 1 },
|
||||
{ type: ReportType.PROPERTY_VALUATION, params: { propertyId: 'p-1' }, expectedNarratives: 1 },
|
||||
];
|
||||
|
||||
it.each(reportTypes)(
|
||||
'$type → generates $expectedNarratives narratives and completes READY',
|
||||
async ({ type, params, expectedNarratives }) => {
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', type, `Report: ${type}`, params),
|
||||
);
|
||||
|
||||
await processor.process(makeJob(result.reportId));
|
||||
|
||||
const report = reportStore.get(result.reportId)!;
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.content).toBeTruthy();
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(expectedNarratives);
|
||||
|
||||
// Reset mock for next iteration
|
||||
mockAINarrative.generateNarrative.mockClear();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// ================================================================
|
||||
// 7. Temp file cleanup
|
||||
// ================================================================
|
||||
describe('Temp file lifecycle', () => {
|
||||
it('cleans up temp PDF file after successful upload', async () => {
|
||||
const fs = await import('fs');
|
||||
|
||||
const result = await generateHandler.execute(
|
||||
new GenerateReportCommand('user-1', ReportType.DISTRICT_ANALYSIS, 'Cleanup Test', { city: 'Hà Nội', district: 'Ba Đình' }),
|
||||
);
|
||||
|
||||
await processor.process(makeJob(result.reportId));
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith('/tmp/goodgo-report-test.pdf');
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/goodgo-report-test.pdf');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user