feat: implement project development module, transfer management features, and industrial AVM model integration
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
import { PuppeteerPdfGeneratorService } from '../services/pdf-generator.service';
|
||||
|
||||
const { mockPdf, mockSetContent, mockNewPage, mockClose } = vi.hoisted(() => {
|
||||
const mockPdf = vi.fn();
|
||||
const mockSetContent = vi.fn();
|
||||
const mockNewPage = vi.fn().mockResolvedValue({
|
||||
setContent: mockSetContent,
|
||||
pdf: mockPdf,
|
||||
});
|
||||
const mockClose = vi.fn();
|
||||
return { mockPdf, mockSetContent, mockNewPage, mockClose };
|
||||
});
|
||||
|
||||
vi.mock('puppeteer', () => ({
|
||||
default: {
|
||||
launch: vi.fn().mockResolvedValue({
|
||||
newPage: mockNewPage,
|
||||
close: mockClose,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('fs', () => ({
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('PuppeteerPdfGeneratorService', () => {
|
||||
let service: PuppeteerPdfGeneratorService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
service = new PuppeteerPdfGeneratorService();
|
||||
});
|
||||
|
||||
const buildContent = (overrides: Record<string, unknown> = {}): Record<string, unknown> => ({
|
||||
reportType: 'INDUSTRIAL_LOCATION',
|
||||
province: 'Bình Dương',
|
||||
generatedAt: '2026-04-01T00:00:00.000Z',
|
||||
sections: {
|
||||
executive_summary: {
|
||||
title: 'Tóm tắt',
|
||||
content: 'Báo cáo tổng quan thị trường KCN Bình Dương.',
|
||||
},
|
||||
economic_indicators: {
|
||||
title: 'Chỉ số kinh tế',
|
||||
data: {
|
||||
gdp: [
|
||||
{ period: '2024', value: 150000, unit: 'tỷ VND' },
|
||||
{ period: '2025', value: 165000, unit: 'tỷ VND' },
|
||||
],
|
||||
},
|
||||
charts: {
|
||||
gdp_trend: [
|
||||
{ period: '2024', value: 150000, unit: 'tỷ VND' },
|
||||
{ period: '2025', value: 165000, unit: 'tỷ VND' },
|
||||
],
|
||||
},
|
||||
},
|
||||
infrastructure: {
|
||||
title: 'Hạ tầng',
|
||||
projects: [
|
||||
{ name: 'KCN VSIP III', category: 'industrial_park', status: 'under_construction', investmentVND: 5000000000000 },
|
||||
],
|
||||
summary: {
|
||||
total: 1,
|
||||
byCategory: { industrial_park: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
it('generates a PDF and returns the file path', async () => {
|
||||
const pdfBuffer = Buffer.from('fake-pdf-content');
|
||||
mockPdf.mockResolvedValue(pdfBuffer);
|
||||
|
||||
const result = await service.generatePdf('report-123', buildContent());
|
||||
|
||||
expect(result).toMatch(/goodgo-report-report-123-\d+\.pdf$/);
|
||||
expect(mockNewPage).toHaveBeenCalledOnce();
|
||||
expect(mockSetContent).toHaveBeenCalledOnce();
|
||||
expect(mockPdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
format: 'A4',
|
||||
printBackground: true,
|
||||
displayHeaderFooter: true,
|
||||
}),
|
||||
);
|
||||
expect(mockClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('sets page content with waitUntil networkidle0', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-456', buildContent());
|
||||
|
||||
expect(mockSetContent).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
{ waitUntil: 'networkidle0' },
|
||||
);
|
||||
});
|
||||
|
||||
it('includes cover page with title, type label, and date', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-789', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Bình Dương');
|
||||
expect(html).toContain('Vị trí khu công nghiệp');
|
||||
expect(html).toContain('class="cover"');
|
||||
expect(html).toContain('GoodGo');
|
||||
});
|
||||
|
||||
it('includes table of contents', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-toc', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Mục lục');
|
||||
expect(html).toContain('class="toc"');
|
||||
expect(html).toContain('Tóm tắt');
|
||||
expect(html).toContain('Chỉ số kinh tế');
|
||||
expect(html).toContain('Hạ tầng');
|
||||
});
|
||||
|
||||
it('renders SVG charts from chart data', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-charts', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('<svg');
|
||||
expect(html).toContain('chart-container');
|
||||
expect(html).toContain('Gdp Trend');
|
||||
});
|
||||
|
||||
it('renders data tables', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-tables', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('data-table');
|
||||
expect(html).toContain('Kỳ');
|
||||
expect(html).toContain('Giá trị');
|
||||
});
|
||||
|
||||
it('renders infrastructure projects table', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-infra', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('KCN VSIP III');
|
||||
expect(html).toContain('Dự án');
|
||||
expect(html).toContain('Vốn đầu tư (VND)');
|
||||
});
|
||||
|
||||
it('includes Be Vietnam Pro font import', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-font', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Be+Vietnam+Pro');
|
||||
expect(html).toContain("font-family: 'Be Vietnam Pro'");
|
||||
});
|
||||
|
||||
it('includes methodology and disclaimer section', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-method', buildContent());
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('Phương pháp');
|
||||
expect(html).toContain('Miễn trừ trách nhiệm');
|
||||
expect(html).toContain('research@goodgo.vn');
|
||||
});
|
||||
|
||||
it('includes page number footer', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-footer', buildContent());
|
||||
|
||||
const pdfOptions = mockPdf.mock.calls[0][0];
|
||||
expect(pdfOptions.footerTemplate).toContain('pageNumber');
|
||||
expect(pdfOptions.footerTemplate).toContain('totalPages');
|
||||
expect(pdfOptions.footerTemplate).toContain('GoodGo AI Report');
|
||||
});
|
||||
|
||||
it('escapes HTML in user-provided content', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
const content = buildContent({
|
||||
province: '<script>alert("xss")</script>',
|
||||
});
|
||||
|
||||
await service.generatePdf('report-xss', content);
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).not.toContain('<script>');
|
||||
expect(html).toContain('<script>');
|
||||
});
|
||||
|
||||
it('closes browser even if PDF generation fails', async () => {
|
||||
mockPdf.mockRejectedValue(new Error('Render failed'));
|
||||
|
||||
await expect(service.generatePdf('report-fail', buildContent())).rejects.toThrow('Render failed');
|
||||
|
||||
expect(mockClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('handles empty sections gracefully', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
const content = buildContent({ sections: {} });
|
||||
await service.generatePdf('report-empty', content);
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('class="cover"');
|
||||
expect(html).toContain('class="toc"');
|
||||
});
|
||||
|
||||
it('handles missing content fields with defaults', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-defaults', {});
|
||||
|
||||
const html = mockSetContent.mock.calls[0][0] as string;
|
||||
expect(html).toContain('class="cover"');
|
||||
});
|
||||
|
||||
it('uses A4 format with 2cm margins', async () => {
|
||||
mockPdf.mockResolvedValue(Buffer.from('pdf'));
|
||||
|
||||
await service.generatePdf('report-margins', buildContent());
|
||||
|
||||
expect(mockPdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
format: 'A4',
|
||||
margin: { top: '2cm', right: '2cm', bottom: '2cm', left: '2cm' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
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 { ReportGenerationProcessor } from '../services/report-generation.processor';
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
readFileSync: vi.fn().mockReturnValue(Buffer.from('fake-pdf')),
|
||||
unlinkSync: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('ReportGenerationProcessor', () => {
|
||||
let processor: ReportGenerationProcessor;
|
||||
let mockReportRepo: { [K in keyof IReportRepository]: ReturnType<typeof vi.fn> };
|
||||
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> };
|
||||
|
||||
const createReport = (type: ReportType, params: Record<string, unknown>) =>
|
||||
ReportEntity.createNew('report-1', 'user-1', type, 'Test Report', params);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockReportRepo = {
|
||||
findById: vi.fn(),
|
||||
findByUserId: vi.fn(),
|
||||
save: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
countByUserInPeriod: vi.fn(),
|
||||
};
|
||||
|
||||
mockMacroData = {
|
||||
getByProvince: vi.fn().mockResolvedValue([
|
||||
{ indicator: 'gdp', period: '2025', value: 150000, unit: 'tỷ VND' },
|
||||
{ indicator: 'fdi', period: '2025', value: 5000, unit: 'triệu USD' },
|
||||
]),
|
||||
};
|
||||
|
||||
mockInfraData = {
|
||||
getByProvince: vi.fn().mockResolvedValue([
|
||||
{ name: 'KCN VSIP', category: 'industrial_park', status: 'active', investmentVND: BigInt(5000000000000), completionDate: new Date('2024-01-01') },
|
||||
]),
|
||||
};
|
||||
|
||||
mockAINarrative = {
|
||||
generateNarrative: vi.fn().mockResolvedValue('AI-generated analysis text.'),
|
||||
};
|
||||
|
||||
mockPdfGenerator = {
|
||||
generatePdf: vi.fn().mockResolvedValue('/tmp/report.pdf'),
|
||||
};
|
||||
|
||||
mockPdfStorage = {
|
||||
uploadPdf: vi.fn().mockResolvedValue('https://storage.example.com/reports/report-1.pdf'),
|
||||
};
|
||||
|
||||
processor = new ReportGenerationProcessor(
|
||||
mockReportRepo as any,
|
||||
mockMacroData as any,
|
||||
mockInfraData as any,
|
||||
mockAINarrative as any,
|
||||
mockPdfGenerator as any,
|
||||
mockPdfStorage as any,
|
||||
);
|
||||
});
|
||||
|
||||
const makeJob = (reportId: string) => ({ data: { reportId } }) as any;
|
||||
|
||||
it('skips processing when report is not found', async () => {
|
||||
mockReportRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await processor.process(makeJob('nonexistent'));
|
||||
|
||||
expect(mockPdfGenerator.generatePdf).not.toHaveBeenCalled();
|
||||
expect(mockReportRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('INDUSTRIAL_LOCATION report', () => {
|
||||
it('fetches macro data and infra projects, generates narratives, creates PDF', async () => {
|
||||
const report = createReport(ReportType.INDUSTRIAL_LOCATION, { province: 'Bình Dương' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
// Fetches data
|
||||
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||
'Bình Dương',
|
||||
expect.arrayContaining(['gdp', 'fdi', 'population']),
|
||||
);
|
||||
expect(mockInfraData.getByProvince).toHaveBeenCalledWith('Bình Dương');
|
||||
|
||||
// Generates AI narratives for 4 sections
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(4);
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sectionKey: 'executive_summary' }),
|
||||
);
|
||||
|
||||
// Generates and uploads PDF
|
||||
expect(mockPdfGenerator.generatePdf).toHaveBeenCalledOnce();
|
||||
expect(mockPdfStorage.uploadPdf).toHaveBeenCalledOnce();
|
||||
|
||||
// Marks report as ready with pdfUrl
|
||||
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.pdfUrl).toBe('https://storage.example.com/reports/report-1.pdf');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RESIDENTIAL_MARKET report', () => {
|
||||
it('fetches macro data and generates narratives for market sections', async () => {
|
||||
const report = createReport(ReportType.RESIDENTIAL_MARKET, { city: 'TP.HCM', period: 'Q1-2026' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
expect(mockMacroData.getByProvince).toHaveBeenCalledWith(
|
||||
'TP.HCM',
|
||||
expect.arrayContaining(['gdp', 'cpi', 'mortgage_rate']),
|
||||
);
|
||||
|
||||
// 6 narrative sections for residential market
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(6);
|
||||
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.content).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DISTRICT_ANALYSIS report', () => {
|
||||
it('generates narratives for district sections', async () => {
|
||||
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'TP.HCM', district: 'Quận 2' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
// 5 narrative sections for district analysis
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledTimes(5);
|
||||
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic report type', () => {
|
||||
it('generates a single executive summary for unknown report types', async () => {
|
||||
const report = createReport(ReportType.PORTFOLIO, { assets: ['prop-1'] });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledOnce();
|
||||
expect(mockAINarrative.generateNarrative).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sectionKey: 'executive_summary' }),
|
||||
);
|
||||
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PDF generation failure', () => {
|
||||
it('completes report without PDF when PDF generation fails', async () => {
|
||||
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'Hà Nội', district: 'Hoàn Kiếm' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
mockPdfGenerator.generatePdf.mockRejectedValue(new Error('Puppeteer crashed'));
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
// Report should still be marked ready, but without pdfUrl
|
||||
expect(report.status).toBe(ReportStatus.READY);
|
||||
expect(report.pdfUrl).toBeNull();
|
||||
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content generation failure', () => {
|
||||
it('marks report as failed when narrative generation throws', async () => {
|
||||
const report = createReport(ReportType.INDUSTRIAL_LOCATION, { province: 'Đồng Nai' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
mockAINarrative.generateNarrative.mockRejectedValue(new Error('AI service unavailable'));
|
||||
|
||||
await expect(processor.process(makeJob('report-1'))).rejects.toThrow('AI service unavailable');
|
||||
|
||||
expect(report.status).toBe(ReportStatus.FAILED);
|
||||
expect(report.errorMsg).toBe('AI service unavailable');
|
||||
expect(mockReportRepo.update).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
it('cleans up temp PDF file after upload', async () => {
|
||||
const fs = await import('fs');
|
||||
const report = createReport(ReportType.DISTRICT_ANALYSIS, { city: 'TP.HCM', district: 'Quận 1' });
|
||||
mockReportRepo.findById.mockResolvedValue(report);
|
||||
mockPdfGenerator.generatePdf.mockResolvedValue('/tmp/goodgo-report-1.pdf');
|
||||
|
||||
await processor.process(makeJob('report-1'));
|
||||
|
||||
expect(fs.readFileSync).toHaveBeenCalledWith('/tmp/goodgo-report-1.pdf');
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith('/tmp/goodgo-report-1.pdf');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user