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:
Ho Ngoc Hai
2026-04-16 17:24:52 +07:00
parent 8f2d325d60
commit ac4191cdf0
9 changed files with 1164 additions and 0 deletions

View File

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