diff --git a/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts b/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts index d96a99d..226acae 100644 --- a/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts +++ b/apps/api/src/modules/analytics/infrastructure/__tests__/neighborhood-score.service.spec.ts @@ -4,11 +4,16 @@ import { PrismaNeighborhoodScoreService, } from '../services/neighborhood-score.service'; +// Helper: build the flat $queryRaw row list that countPOIs expects. +function makePoiRows(counts: Record) { + return Object.entries(counts).map(([type, n]) => ({ type, count: BigInt(n) })); +} + describe('NeighborhoodScoreServiceImpl', () => { let service: NeighborhoodScoreServiceImpl; let mockPrisma: { neighborhoodScore: { findUnique: ReturnType; upsert: ReturnType }; - pOI: { count: ReturnType }; + $queryRaw: ReturnType; }; let mockLogger: { log: ReturnType }; @@ -18,7 +23,7 @@ describe('NeighborhoodScoreServiceImpl', () => { findUnique: vi.fn(), upsert: vi.fn(), }, - pOI: { count: vi.fn() }, + $queryRaw: vi.fn(), }; mockLogger = { log: vi.fn() }; @@ -60,44 +65,45 @@ describe('NeighborhoodScoreServiceImpl', () => { }); describe('calculateAndSave', () => { - it('calculates scores from POI counts and upserts', async () => { - // Simulate POI counts: education=15 (max), healthcare=4 (50%), transport=6 (50%), - // shopping=5 (50%), greenery=3 (50%), safety=2 (50%) - const poiCountsByCategory = [15, 4, 6, 5, 3, 2]; - let callIndex = 0; - mockPrisma.pOI.count.mockImplementation(() => { - return Promise.resolve(poiCountsByCategory[callIndex++]!); - }); - - mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { - return Promise.resolve(create); - }); + it('issues exactly one DB query and calculates scores correctly', async () => { + mockPrisma.$queryRaw.mockResolvedValue( + makePoiRows({ + SCHOOL: 10, UNIVERSITY: 5, + HOSPITAL: 2, CLINIC: 2, + METRO_STATION: 3, BUS_STOP: 3, + MALL: 2, MARKET: 2, SUPERMARKET: 1, + PARK: 3, + POLICE_STATION: 1, FIRE_STATION: 1, + }), + ); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) => + Promise.resolve(create), + ); const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); - // education: 15/15 * 10 = 10 → 10 * 20/10 = 20 - // healthcare: 4/8 * 10 = 5 → 5 * 20/10 = 10 - // transport: 6/12 * 10 = 5 → 5 * 20/10 = 10 - // shopping: 5/10 * 10 = 5 → 5 * 15/10 = 7.5 - // greenery: 3/6 * 10 = 5 → 5 * 15/10 = 7.5 - // safety: 2/4 * 10 = 5 → 5 * 10/10 = 5 - // total = 20 + 10 + 10 + 7.5 + 7.5 + 5 = 60 expect(result.educationScore).toBe(10); expect(result.healthcareScore).toBe(5); expect(result.totalScore).toBe(60); + // Assert single DB round-trip for all 6 categories + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledTimes(1); }); it('caps category scores at 10', async () => { - // All categories have way more POIs than max - mockPrisma.pOI.count.mockResolvedValue(100); - mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { - return Promise.resolve(create); - }); + mockPrisma.$queryRaw.mockResolvedValue( + makePoiRows({ + SCHOOL: 100, UNIVERSITY: 100, HOSPITAL: 100, CLINIC: 100, + METRO_STATION: 100, BUS_STOP: 100, MALL: 100, MARKET: 100, + SUPERMARKET: 100, PARK: 100, POLICE_STATION: 100, FIRE_STATION: 100, + }), + ); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) => + Promise.resolve(create), + ); const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); - // All scores capped at 10 → total = sum of weights = 100 expect(result.educationScore).toBe(10); expect(result.healthcareScore).toBe(10); expect(result.transportScore).toBe(10); @@ -105,25 +111,27 @@ describe('NeighborhoodScoreServiceImpl', () => { expect(result.greeneryScore).toBe(10); expect(result.safetyScore).toBe(10); expect(result.totalScore).toBe(100); + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); }); it('returns 0 scores when no POIs exist', async () => { - mockPrisma.pOI.count.mockResolvedValue(0); - mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { - return Promise.resolve(create); - }); + mockPrisma.$queryRaw.mockResolvedValue([]); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) => + Promise.resolve(create), + ); const result = await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); expect(result.educationScore).toBe(0); expect(result.totalScore).toBe(0); + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); }); it('logs the calculated score', async () => { - mockPrisma.pOI.count.mockResolvedValue(5); - mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => { - return Promise.resolve(create); - }); + mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 5 })); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) => + Promise.resolve(create), + ); await service.calculateAndSave('Quận 1', 'Hồ Chí Minh'); @@ -140,7 +148,7 @@ describe('HttpNeighborhoodScoreService', () => { let prismaFallback: PrismaNeighborhoodScoreService; let mockPrisma: { neighborhoodScore: { findUnique: ReturnType; upsert: ReturnType }; - pOI: { count: ReturnType }; + $queryRaw: ReturnType; }; let mockLogger: { log: ReturnType; warn: ReturnType }; let mockAiClient: { scoreNeighborhood: ReturnType }; @@ -148,7 +156,7 @@ describe('HttpNeighborhoodScoreService', () => { beforeEach(() => { mockPrisma = { neighborhoodScore: { findUnique: vi.fn(), upsert: vi.fn() }, - pOI: { count: vi.fn() }, + $queryRaw: vi.fn(), }; mockLogger = { log: vi.fn(), warn: vi.fn() }; mockAiClient = { scoreNeighborhood: vi.fn() }; @@ -165,7 +173,7 @@ describe('HttpNeighborhoodScoreService', () => { }); it('persists AI service response when scoreNeighborhood succeeds', async () => { - mockPrisma.pOI.count.mockResolvedValue(6); + mockPrisma.$queryRaw.mockResolvedValue(makePoiRows({ SCHOOL: 6 })); mockAiClient.scoreNeighborhood.mockResolvedValue({ district: 'Quận 1', city: 'Hồ Chí Minh', @@ -179,7 +187,9 @@ describe('HttpNeighborhoodScoreService', () => { poi_counts: { education: 6, healthcare: 6, transport: 6, shopping: 6, greenery: 6, safety: 6 }, algorithm_version: 'neighborhood-heuristic-v1', }); - mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create)); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) => + Promise.resolve(create), + ); const result = await httpService.calculateAndSave('Quận 1', 'Hồ Chí Minh'); @@ -187,12 +197,15 @@ describe('HttpNeighborhoodScoreService', () => { expect(result.totalScore).toBe(71.2); expect(result.educationScore).toBe(8.5); expect(mockPrisma.neighborhoodScore.upsert).toHaveBeenCalledOnce(); + expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1); }); it('falls back to prisma scoring when AI service throws', async () => { - mockPrisma.pOI.count.mockResolvedValue(0); + mockPrisma.$queryRaw.mockResolvedValue([]); mockAiClient.scoreNeighborhood.mockRejectedValue(new Error('AI service down')); - mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }) => Promise.resolve(create)); + mockPrisma.neighborhoodScore.upsert.mockImplementation(({ create }: { create: unknown }) => + Promise.resolve(create), + ); const result = await httpService.calculateAndSave('Quận 7', 'Hồ Chí Minh'); diff --git a/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts b/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts index b5a21bd..61dcf6f 100644 --- a/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts +++ b/apps/api/src/modules/analytics/infrastructure/services/neighborhood-score.service.ts @@ -143,18 +143,26 @@ async function countPOIs( district: string, city: string, ): Promise { - const entries = await Promise.all( - CATEGORY_KEYS.map(async (cat) => { - const count = await prisma.pOI.count({ - where: { - district, - city, - type: { in: CATEGORY_POI_TYPES[cat] }, - }, - }); - return [cat, count] as const; - }), - ); + // Single GROUP BY query replaces 6x individual COUNT queries. + const rows = await prisma.$queryRaw<{ type: POIType; count: bigint }[]>` + SELECT "type", COUNT(*) AS count + FROM "POI" + WHERE "district" = ${district} AND "city" = ${city} + GROUP BY "type" + `; + + const typeCountMap = new Map(); + for (const row of rows) { + typeCountMap.set(row.type, Number(row.count)); + } + + const entries = CATEGORY_KEYS.map((cat) => { + const total = CATEGORY_POI_TYPES[cat].reduce( + (sum, t) => sum + (typeCountMap.get(t) ?? 0), + 0, + ); + return [cat, total] as const; + }); return Object.fromEntries(entries) as unknown as AiNeighborhoodPOICounts; }