feat: implement project development module, transfer management features, and industrial AVM model integration

This commit is contained in:
Ho Ngoc Hai
2026-04-18 20:34:35 +07:00
parent 0f3b4d7b0d
commit 38b9def99a
66 changed files with 9051 additions and 17 deletions

View File

@@ -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('&lt;script&gt;');
});
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' },
}),
);
});
});

View File

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