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