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,281 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from './analyze-industrial-location.query';
interface ConnectivityInfo {
nearest_port?: { name: string; distanceKm: number };
nearest_airport?: { name: string; distanceKm: number };
nearest_highway?: { name: string; distanceKm: number };
nearest_railway?: { name: string; distanceKm: number };
}
interface InfrastructureInfo {
power_availability?: string;
water_supply?: string;
wastewater_treatment?: string;
telecom?: string;
}
interface LocationAnalysisResult {
overall_score: number;
connectivity: ConnectivityInfo;
infrastructure: InfrastructureInfo;
labor_market: {
worker_pool_radius_30km: number | null;
average_wage_usd: number | null;
nearby_universities: string[];
};
incentives: string[];
risks: string[];
nearby_parks: { name: string; distanceKm: number; occupancyRate: number }[];
}
@QueryHandler(AnalyzeIndustrialLocationQuery)
export class AnalyzeIndustrialLocationHandler
implements IQueryHandler<AnalyzeIndustrialLocationQuery, LocationAnalysisResult>
{
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async execute(query: AnalyzeIndustrialLocationQuery): Promise<LocationAnalysisResult> {
const { latitude, longitude, parkName, targetIndustry } = query;
// Find nearest parks within 50km using PostGIS
const nearbyParks = await this.prisma.$queryRaw<
Array<{
id: string;
name: string;
province: string;
region: string;
distanceKm: number;
occupancyRate: number;
landRentUsdM2Year: number | null;
infrastructure: Record<string, unknown> | null;
connectivity: Record<string, unknown> | null;
incentives: Record<string, unknown> | null;
targetIndustries: string[];
}>
>`
SELECT
id, name, province, region,
"occupancyRate",
"landRentUsdM2Year",
infrastructure::jsonb as infrastructure,
connectivity::jsonb as connectivity,
incentives::jsonb as incentives,
"targetIndustries",
ST_Distance(
location::geography,
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)::geography
) / 1000.0 AS "distanceKm"
FROM "IndustrialPark"
WHERE ST_DWithin(
location::geography,
ST_SetSRID(ST_MakePoint(${longitude}, ${latitude}), 4326)::geography,
50000
)
ORDER BY "distanceKm" ASC
LIMIT 10
`;
// If parkName specified, find that specific park
let targetPark = nearbyParks[0] ?? null;
if (parkName) {
const matched = nearbyParks.find(
(p) => p.name.toLowerCase().includes(parkName.toLowerCase()),
);
if (matched) targetPark = matched;
}
// Build connectivity from nearest park data
const connectivity = this.buildConnectivity(targetPark?.connectivity);
// Build infrastructure from nearest park data
const infrastructure = this.buildInfrastructure(targetPark?.infrastructure);
// Compute labor market estimates based on province/region
const laborMarket = this.estimateLaborMarket(targetPark?.province ?? null, targetPark?.region ?? null);
// Gather incentives
const incentives = this.gatherIncentives(targetPark?.incentives);
// Assess risks
const risks = this.assessRisks(nearbyParks, targetIndustry);
// Calculate overall score (0-100)
const overallScore = this.calculateScore(
connectivity,
infrastructure,
nearbyParks,
targetIndustry,
targetPark,
);
return {
overall_score: overallScore,
connectivity,
infrastructure,
labor_market: laborMarket,
incentives,
risks,
nearby_parks: nearbyParks.slice(0, 5).map((p) => ({
name: p.name,
distanceKm: Math.round(p.distanceKm * 10) / 10,
occupancyRate: p.occupancyRate,
})),
};
}
private buildConnectivity(raw: Record<string, unknown> | null | undefined): ConnectivityInfo {
if (!raw) return {};
return {
nearest_port: this.extractFacility(raw, 'nearestPort', 'seaport'),
nearest_airport: this.extractFacility(raw, 'airport', 'nearestAirport'),
nearest_highway: this.extractFacility(raw, 'highway', 'nearestHighway'),
nearest_railway: this.extractFacility(raw, 'railway', 'nearestRailway'),
};
}
private extractFacility(
raw: Record<string, unknown>,
...keys: string[]
): { name: string; distanceKm: number } | undefined {
for (const key of keys) {
const val = raw[key] as Record<string, unknown> | string | undefined;
if (val && typeof val === 'object' && 'name' in val) {
return { name: String(val['name']), distanceKm: Number(val['distanceKm'] ?? val['distance'] ?? 0) };
}
if (typeof val === 'string') {
return { name: val, distanceKm: 0 };
}
}
return undefined;
}
private buildInfrastructure(raw: Record<string, unknown> | null | undefined): InfrastructureInfo {
if (!raw) return {};
return {
power_availability: raw['electricity'] ? String(raw['electricity']) : undefined,
water_supply: raw['water'] ? String(raw['water']) : undefined,
wastewater_treatment: raw['wastewater'] ? String(raw['wastewater']) : undefined,
telecom: raw['telecom'] ? String(raw['telecom']) : undefined,
};
}
private estimateLaborMarket(province: string | null, region: string | null) {
// Regional labor market estimates for Vietnam industrial zones
const regionData: Record<string, { workers: number; wage: number; unis: string[] }> = {
SOUTH: {
workers: 500_000,
wage: 350,
unis: ['ĐH Bách Khoa TP.HCM', 'ĐH Công nghiệp TP.HCM', 'ĐH Tôn Đức Thắng'],
},
NORTH: {
workers: 400_000,
wage: 300,
unis: ['ĐH Bách Khoa Hà Nội', 'ĐH Công nghiệp Hà Nội'],
},
CENTRAL: {
workers: 200_000,
wage: 280,
unis: ['ĐH Bách Khoa Đà Nẵng', 'ĐH Kinh tế Đà Nẵng'],
},
};
const data = regionData[region ?? 'SOUTH'] ?? regionData['SOUTH']!;
return {
worker_pool_radius_30km: data!.workers,
average_wage_usd: data!.wage,
nearby_universities: data!.unis,
};
}
private gatherIncentives(raw: Record<string, unknown> | null | undefined): string[] {
if (!raw) return [];
const result: string[] = [];
if (raw['taxHoliday']) result.push(`Tax holiday: ${raw['taxHoliday']}`);
if (raw['importDuty']) result.push(`Import duty exemption: ${raw['importDuty']}`);
if (raw['landRentReduction']) result.push(`Land rent reduction: ${raw['landRentReduction']}`);
if (raw['specialZone']) result.push(`Special economic zone: ${raw['specialZone']}`);
return result;
}
private assessRisks(
nearbyParks: Array<{ occupancyRate: number; province: string }>,
targetIndustry?: string | null,
): string[] {
const risks: string[] = [];
if (nearbyParks.length === 0) {
risks.push('No industrial parks within 50km — limited industrial ecosystem');
}
const avgOccupancy =
nearbyParks.length > 0
? nearbyParks.reduce((sum, p) => sum + p.occupancyRate, 0) / nearbyParks.length
: 0;
if (avgOccupancy > 90) {
risks.push('High area occupancy (>90%) — limited expansion options');
}
if (targetIndustry) {
// Check if any nearby park targets this industry — simplified check
const hasMatchingPark = nearbyParks.some(
(p) => (p as unknown as { targetIndustries?: string[] }).targetIndustries?.some(
(t) => t.toLowerCase().includes(targetIndustry.toLowerCase()),
),
);
if (!hasMatchingPark) {
risks.push(`No nearby parks specialize in "${targetIndustry}" — may lack ecosystem support`);
}
}
return risks;
}
private calculateScore(
connectivity: ConnectivityInfo,
infrastructure: InfrastructureInfo,
nearbyParks: Array<{ occupancyRate: number; distanceKm: number }>,
targetIndustry?: string | null,
targetPark?: { targetIndustries?: string[]; occupancyRate?: number } | null,
): number {
let score = 50; // Base score
// Connectivity bonus (up to +20)
let connectivityPoints = 0;
if (connectivity.nearest_port) connectivityPoints += 5;
if (connectivity.nearest_airport) connectivityPoints += 5;
if (connectivity.nearest_highway) connectivityPoints += 5;
if (connectivity.nearest_railway) connectivityPoints += 5;
score += connectivityPoints;
// Infrastructure bonus (up to +15)
let infraPoints = 0;
if (infrastructure.power_availability) infraPoints += 4;
if (infrastructure.water_supply) infraPoints += 4;
if (infrastructure.wastewater_treatment) infraPoints += 4;
if (infrastructure.telecom) infraPoints += 3;
score += infraPoints;
// Nearby parks density (up to +10)
if (nearbyParks.length >= 5) score += 10;
else if (nearbyParks.length >= 3) score += 7;
else if (nearbyParks.length >= 1) score += 4;
// Occupancy rate penalty (parks too full = -5)
if (targetPark && targetPark.occupancyRate && targetPark.occupancyRate > 95) {
score -= 5;
}
// Industry match bonus (+5)
if (targetIndustry && targetPark?.targetIndustries?.some(
(t) => t.toLowerCase().includes(targetIndustry.toLowerCase()),
)) {
score += 5;
}
return Math.max(0, Math.min(100, Math.round(score)));
}
}

View File

@@ -0,0 +1,8 @@
export class AnalyzeIndustrialLocationQuery {
constructor(
public readonly latitude: number,
public readonly longitude: number,
public readonly parkName?: string | null,
public readonly targetIndustry?: string | null,
) {}
}

View File

@@ -0,0 +1,185 @@
import { Inject } from '@nestjs/common';
import { type IQueryHandler, QueryHandler } from '@nestjs/cqrs';
import { PrismaService } from '@modules/shared';
import { EstimateIndustrialRentQuery } from './estimate-industrial-rent.query';
interface RentEstimateResult {
estimated_rent_usd_m2: number;
pricing_unit: string;
total_monthly_usd: number;
total_lease_usd: number;
management_fee_usd_m2: number | null;
deposit_months: number;
market_comparison: {
province_low: number | null;
province_high: number | null;
province_avg: number | null;
};
breakdown: { item: string; amount: number }[];
}
@QueryHandler(EstimateIndustrialRentQuery)
export class EstimateIndustrialRentHandler
implements IQueryHandler<EstimateIndustrialRentQuery, RentEstimateResult>
{
constructor(@Inject(PrismaService) private readonly prisma: PrismaService) {}
async execute(query: EstimateIndustrialRentQuery): Promise<RentEstimateResult> {
const { province, propertyType, areaM2, leaseDurationYears, parkName, requiresCrane, requiredPowerKva, requiresWastewater } = query;
// Get market data for the province
const provinceParks = await this.prisma.industrialPark.findMany({
where: { province: { contains: province, mode: 'insensitive' } },
select: {
name: true,
landRentUsdM2Year: true,
rbfRentUsdM2Month: true,
rbwRentUsdM2Month: true,
managementFeeUsd: true,
occupancyRate: true,
},
});
// If specific park requested, try to find it
let specificPark = parkName
? provinceParks.find((p) => p.name.toLowerCase().includes(parkName.toLowerCase()))
: null;
// Calculate base rent based on property type
const rentField = this.getRentField(propertyType);
const rents = provinceParks
.map((p) => p[rentField] as number | null)
.filter((r): r is number => r != null);
const provinceLow = rents.length > 0 ? Math.min(...rents) : null;
const provinceHigh = rents.length > 0 ? Math.max(...rents) : null;
const provinceAvg = rents.length > 0 ? rents.reduce((a, b) => a + b, 0) / rents.length : null;
// Determine base rent
let baseRentUsdM2: number;
if (specificPark && specificPark[rentField] != null) {
baseRentUsdM2 = specificPark[rentField] as number;
} else if (provinceAvg != null) {
baseRentUsdM2 = provinceAvg;
} else {
// Fallback to national averages by property type
baseRentUsdM2 = this.getNationalAvgRent(propertyType);
}
// Apply adjustments
const breakdown: { item: string; amount: number }[] = [];
let adjustedRent = baseRentUsdM2;
breakdown.push({ item: `Base ${this.getPropertyTypeLabel(propertyType)} rent`, amount: baseRentUsdM2 });
// Crane surcharge
if (requiresCrane) {
const craneSurcharge = baseRentUsdM2 * 0.08;
adjustedRent += craneSurcharge;
breakdown.push({ item: 'Overhead crane surcharge (+8%)', amount: craneSurcharge });
}
// High power requirement surcharge
if (requiredPowerKva && requiredPowerKva > 500) {
const powerSurcharge = baseRentUsdM2 * 0.05;
adjustedRent += powerSurcharge;
breakdown.push({ item: 'High power capacity surcharge (+5%)', amount: powerSurcharge });
}
// Wastewater treatment surcharge
if (requiresWastewater) {
const wastewaterSurcharge = baseRentUsdM2 * 0.03;
adjustedRent += wastewaterSurcharge;
breakdown.push({ item: 'Wastewater treatment surcharge (+3%)', amount: wastewaterSurcharge });
}
// Long lease discount
if (leaseDurationYears >= 20) {
const discount = adjustedRent * 0.10;
adjustedRent -= discount;
breakdown.push({ item: 'Long-term lease discount (≥20yr, -10%)', amount: -discount });
} else if (leaseDurationYears >= 10) {
const discount = adjustedRent * 0.05;
adjustedRent -= discount;
breakdown.push({ item: 'Long-term lease discount (≥10yr, -5%)', amount: -discount });
}
// Large area discount
if (areaM2 >= 10_000) {
const discount = adjustedRent * 0.07;
adjustedRent -= discount;
breakdown.push({ item: 'Large area discount (≥10,000m², -7%)', amount: -discount });
} else if (areaM2 >= 5_000) {
const discount = adjustedRent * 0.03;
adjustedRent -= discount;
breakdown.push({ item: 'Large area discount (≥5,000m², -3%)', amount: -discount });
}
adjustedRent = Math.round(adjustedRent * 100) / 100;
// Determine pricing unit and compute totals
const isMonthlyType = propertyType !== 'industrial_land';
const pricingUnit = isMonthlyType ? 'USD/m²/month' : 'USD/m²/year';
const totalMonthlyUsd = isMonthlyType
? Math.round(adjustedRent * areaM2 * 100) / 100
: Math.round((adjustedRent * areaM2 / 12) * 100) / 100;
const totalLeaseUsd = Math.round(totalMonthlyUsd * 12 * leaseDurationYears * 100) / 100;
// Management fee
const managementFeeUsdM2 = specificPark?.managementFeeUsd ?? (provinceParks.length > 0
? provinceParks.reduce((sum, p) => sum + (p.managementFeeUsd ?? 0), 0) / provinceParks.length || null
: null);
return {
estimated_rent_usd_m2: adjustedRent,
pricing_unit: pricingUnit,
total_monthly_usd: totalMonthlyUsd,
total_lease_usd: totalLeaseUsd,
management_fee_usd_m2: managementFeeUsdM2 ? Math.round(managementFeeUsdM2 * 100) / 100 : null,
deposit_months: leaseDurationYears >= 10 ? 6 : 3,
market_comparison: {
province_low: provinceLow ? Math.round(provinceLow * 100) / 100 : null,
province_high: provinceHigh ? Math.round(provinceHigh * 100) / 100 : null,
province_avg: provinceAvg ? Math.round(provinceAvg * 100) / 100 : null,
},
breakdown: breakdown.map((b) => ({ item: b.item, amount: Math.round(b.amount * 100) / 100 })),
};
}
private getRentField(propertyType: string): 'landRentUsdM2Year' | 'rbfRentUsdM2Month' | 'rbwRentUsdM2Month' {
switch (propertyType) {
case 'ready_built_factory':
return 'rbfRentUsdM2Month';
case 'ready_built_warehouse':
case 'logistics_center':
return 'rbwRentUsdM2Month';
default:
return 'landRentUsdM2Year';
}
}
private getPropertyTypeLabel(propertyType: string): string {
const labels: Record<string, string> = {
industrial_land: 'Industrial land',
ready_built_factory: 'Ready-built factory',
ready_built_warehouse: 'Ready-built warehouse',
logistics_center: 'Logistics center',
office_in_park: 'Office in park',
data_center: 'Data center',
};
return labels[propertyType] ?? propertyType;
}
private getNationalAvgRent(propertyType: string): number {
// Vietnamese national average industrial rents (2024-2025 market data)
const averages: Record<string, number> = {
industrial_land: 120, // USD/m²/year
ready_built_factory: 5.5, // USD/m²/month
ready_built_warehouse: 4.8, // USD/m²/month
logistics_center: 5.0, // USD/m²/month
office_in_park: 8.0, // USD/m²/month
data_center: 12.0, // USD/m²/month
};
return averages[propertyType] ?? 5.0;
}
}

View File

@@ -0,0 +1,12 @@
export class EstimateIndustrialRentQuery {
constructor(
public readonly province: string,
public readonly propertyType: string,
public readonly areaM2: number,
public readonly leaseDurationYears: number,
public readonly parkName?: string | null,
public readonly requiresCrane?: boolean,
public readonly requiredPowerKva?: number | null,
public readonly requiresWastewater?: boolean,
) {}
}

View File

@@ -6,7 +6,9 @@ import { CreateIndustrialParkHandler } from './application/commands/create-indus
import { DeleteIndustrialListingHandler } from './application/commands/delete-industrial-listing/delete-industrial-listing.handler';
import { UpdateIndustrialListingHandler } from './application/commands/update-industrial-listing/update-industrial-listing.handler';
import { UpdateIndustrialParkHandler } from './application/commands/update-industrial-park/update-industrial-park.handler';
import { AnalyzeIndustrialLocationHandler } from './application/queries/analyze-industrial-location/analyze-industrial-location.handler';
import { CompareIndustrialParksHandler } from './application/queries/compare-industrial-parks/compare-industrial-parks.handler';
import { EstimateIndustrialRentHandler } from './application/queries/estimate-industrial-rent/estimate-industrial-rent.handler';
import { GetIndustrialListingHandler } from './application/queries/get-industrial-listing/get-industrial-listing.handler';
import { GetIndustrialParkHandler } from './application/queries/get-industrial-park/get-industrial-park.handler';
import { IndustrialMarketHandler } from './application/queries/industrial-market/industrial-market.handler';
@@ -30,6 +32,8 @@ const CommandHandlers = [
];
const QueryHandlers = [
AnalyzeIndustrialLocationHandler,
EstimateIndustrialRentHandler,
GetIndustrialParkHandler,
ListIndustrialParksHandler,
CompareIndustrialParksHandler,

View File

@@ -4,15 +4,19 @@ import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagg
import { UserRole } from '@prisma/client';
import { JwtAuthGuard, Roles, RolesGuard } from '@modules/auth';
import { NotFoundException } from '@modules/shared';
import { AnalyzeIndustrialLocationQuery } from '../../application/queries/analyze-industrial-location/analyze-industrial-location.query';
import { CreateIndustrialParkCommand } from '../../application/commands/create-industrial-park/create-industrial-park.command';
import { EstimateIndustrialRentQuery } from '../../application/queries/estimate-industrial-rent/estimate-industrial-rent.query';
import { UpdateIndustrialParkCommand } from '../../application/commands/update-industrial-park/update-industrial-park.command';
import { CompareIndustrialParksQuery } from '../../application/queries/compare-industrial-parks/compare-industrial-parks.query';
import { GetIndustrialParkQuery } from '../../application/queries/get-industrial-park/get-industrial-park.query';
import { IndustrialMarketQuery } from '../../application/queries/industrial-market/industrial-market.query';
import { IndustrialParkStatsQuery } from '../../application/queries/industrial-park-stats/industrial-park-stats.query';
import { ListIndustrialParksQuery } from '../../application/queries/list-industrial-parks/list-industrial-parks.query';
import { type AnalyzeIndustrialLocationDto } from '../dto/analyze-industrial-location.dto';
import { type CompareIndustrialParksDto } from '../dto/compare-industrial-parks.dto';
import { type CreateIndustrialParkDto } from '../dto/create-industrial-park.dto';
import { type EstimateIndustrialRentDto } from '../dto/estimate-industrial-rent.dto';
import { type SearchIndustrialParksDto } from '../dto/search-industrial-parks.dto';
import { type UpdateIndustrialParkDto } from '../dto/update-industrial-park.dto';
@@ -78,6 +82,38 @@ export class IndustrialParksController {
return this.queryBus.execute(new IndustrialMarketQuery());
}
@ApiOperation({ summary: 'Phân tích vị trí KCN', description: 'Đánh giá vị trí dựa trên hạ tầng, kết nối, lao động' })
@ApiResponse({ status: 200, description: 'Kết quả phân tích vị trí' })
@Post('analyze-location')
async analyzeLocation(@Body() dto: AnalyzeIndustrialLocationDto) {
return this.queryBus.execute(
new AnalyzeIndustrialLocationQuery(
dto.latitude,
dto.longitude,
dto.park_name ?? null,
dto.target_industry ?? null,
),
);
}
@ApiOperation({ summary: 'Ước tính giá thuê KCN', description: 'Tính giá thuê BĐS công nghiệp theo tỉnh, loại, diện tích' })
@ApiResponse({ status: 200, description: 'Kết quả ước tính giá thuê' })
@Post('estimate-rent')
async estimateRent(@Body() dto: EstimateIndustrialRentDto) {
return this.queryBus.execute(
new EstimateIndustrialRentQuery(
dto.province,
dto.property_type,
dto.area_m2,
dto.lease_duration_years,
dto.park_name ?? null,
dto.requires_crane ?? false,
dto.required_power_kva ?? null,
dto.requires_wastewater ?? false,
),
);
}
// ── Admin endpoints ───────────────────────────────────────────────
@ApiOperation({ summary: 'Tạo KCN (admin)', description: 'Tạo mới khu công nghiệp' })

View File

@@ -0,0 +1,29 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
export class AnalyzeIndustrialLocationDto {
@ApiProperty({ example: 10.9, description: 'Vĩ độ' })
@IsNumber()
@Type(() => Number)
@Min(-90)
@Max(90)
latitude!: number;
@ApiProperty({ example: 106.8, description: 'Kinh độ' })
@IsNumber()
@Type(() => Number)
@Min(-180)
@Max(180)
longitude!: number;
@ApiPropertyOptional({ example: 'VSIP Bình Dương', description: 'Tên KCN cần phân tích' })
@IsOptional()
@IsString()
park_name?: string;
@ApiPropertyOptional({ example: 'electronics', description: 'Ngành mục tiêu' })
@IsOptional()
@IsString()
target_industry?: string;
}

View File

@@ -0,0 +1,62 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
import { Type } from 'class-transformer';
const INDUSTRIAL_PROPERTY_TYPES = [
'industrial_land',
'ready_built_factory',
'ready_built_warehouse',
'logistics_center',
'office_in_park',
'data_center',
] as const;
export class EstimateIndustrialRentDto {
@ApiProperty({ example: 'Bình Dương', description: 'Tỉnh/thành phố' })
@IsString()
province!: string;
@ApiProperty({
example: 'ready_built_factory',
enum: INDUSTRIAL_PROPERTY_TYPES,
description: 'Loại BĐS công nghiệp',
})
@IsEnum(INDUSTRIAL_PROPERTY_TYPES)
property_type!: (typeof INDUSTRIAL_PROPERTY_TYPES)[number];
@ApiProperty({ example: 5000, description: 'Diện tích yêu cầu (m²)' })
@IsNumber()
@Type(() => Number)
@Min(1)
area_m2!: number;
@ApiProperty({ example: 10, description: 'Thời hạn thuê (năm)' })
@IsNumber()
@Type(() => Number)
@Min(1)
@Max(70)
lease_duration_years!: number;
@ApiPropertyOptional({ example: 'VSIP Bình Dương', description: 'Tên KCN cụ thể' })
@IsOptional()
@IsString()
park_name?: string;
@ApiPropertyOptional({ example: false, description: 'Yêu cầu cầu trục' })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
requires_crane?: boolean;
@ApiPropertyOptional({ example: 500, description: 'Công suất điện yêu cầu (KVA)' })
@IsOptional()
@IsNumber()
@Type(() => Number)
required_power_kva?: number;
@ApiPropertyOptional({ example: false, description: 'Yêu cầu xử lý nước thải' })
@IsOptional()
@IsBoolean()
@Type(() => Boolean)
requires_wastewater?: boolean;
}

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