fix: extract actual lat/lng from PostGIS instead of hardcoded (0,0)

Property toDomain() was hardcoding GeoPoint.create(0, 0) because Prisma
returns PostGIS geometry(Point, 4326) as an opaque Unsupported type.
Changed findById to use raw SQL with ST_Y/ST_X extraction, ensuring
actual coordinates round-trip correctly through save → query.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-15 09:41:10 +07:00
parent 92e708f17f
commit b809fabd41
2 changed files with 130 additions and 39 deletions

View File

@@ -16,6 +16,7 @@ describe('PrismaPropertyRepository', () => {
count: ReturnType<typeof vi.fn>; count: ReturnType<typeof vi.fn>;
}; };
$executeRaw: ReturnType<typeof vi.fn>; $executeRaw: ReturnType<typeof vi.fn>;
$queryRaw: ReturnType<typeof vi.fn>;
}; };
beforeEach(() => { beforeEach(() => {
@@ -30,51 +31,53 @@ describe('PrismaPropertyRepository', () => {
count: vi.fn().mockResolvedValue(0), count: vi.fn().mockResolvedValue(0),
}, },
$executeRaw: vi.fn().mockResolvedValue(1), $executeRaw: vi.fn().mockResolvedValue(1),
$queryRaw: vi.fn().mockResolvedValue([]),
}; };
repository = new PrismaPropertyRepository(mockPrisma as any); repository = new PrismaPropertyRepository(mockPrisma as any);
}); });
describe('findById', () => { describe('findById', () => {
it('should return null when property not found', async () => { it('should return null when property not found', async () => {
mockPrisma.property.findUnique.mockResolvedValue(null); mockPrisma.$queryRaw.mockResolvedValue([]);
const result = await repository.findById('non-existent'); const result = await repository.findById('non-existent');
expect(result).toBeNull(); expect(result).toBeNull();
expect(mockPrisma.property.findUnique).toHaveBeenCalledWith({
where: { id: 'non-existent' },
});
}); });
it('should return a PropertyEntity when property is found', async () => { it('should return a PropertyEntity with actual lat/lng from PostGIS', async () => {
const now = new Date(); const now = new Date();
mockPrisma.property.findUnique.mockResolvedValue({ // ST_Y/ST_X extract actual coordinates — no more hardcoded (0,0)
id: 'prop-1', mockPrisma.$queryRaw.mockResolvedValue([
propertyType: 'APARTMENT', {
title: 'Căn hộ đẹp', id: 'prop-1',
description: 'Mô tả chi tiết', propertyType: 'APARTMENT',
address: '123 Nguyễn Huệ', title: 'Căn hộ đẹp',
ward: 'Bến Nghé', description: 'Mô tả chi tiết',
district: 'Quận 1', address: '123 Nguyễn Huệ',
city: 'Hồ Chí Minh', ward: 'Bến Nghé',
location: null, // PostGIS geometry placeholder district: 'Quận 1',
areaM2: 80, city: 'Hồ Chí Minh',
usableAreaM2: 70, latitude: 10.7769,
bedrooms: 2, longitude: 106.7009,
bathrooms: 2, areaM2: 80,
floors: null, usableAreaM2: 70,
floor: 10, bedrooms: 2,
totalFloors: 25, bathrooms: 2,
direction: 'EAST', floors: null,
yearBuilt: 2022, floor: 10,
legalStatus: 'Sổ hồng', totalFloors: 25,
amenities: null, direction: 'EAST',
nearbyPOIs: null, yearBuilt: 2022,
metroDistanceM: 300, legalStatus: 'Sổ hồng',
projectName: 'Vinhomes', amenities: null,
createdAt: now, nearbyPOIs: null,
updatedAt: now, metroDistanceM: 300,
}); projectName: 'Vinhomes',
createdAt: now,
updatedAt: now,
},
]);
const result = await repository.findById('prop-1'); const result = await repository.findById('prop-1');
@@ -82,6 +85,51 @@ describe('PrismaPropertyRepository', () => {
expect(result!.id).toBe('prop-1'); expect(result!.id).toBe('prop-1');
expect(result!.title).toBe('Căn hộ đẹp'); expect(result!.title).toBe('Căn hộ đẹp');
expect(result!.propertyType).toBe('APARTMENT'); expect(result!.propertyType).toBe('APARTMENT');
// Verify actual lat/lng are returned — not hardcoded (0, 0)
expect(result!.location.latitude).toBe(10.7769);
expect(result!.location.longitude).toBe(106.7009);
});
it('should correctly map Ho Chi Minh City coordinates', async () => {
const now = new Date();
mockPrisma.$queryRaw.mockResolvedValue([
{
id: 'prop-hcm',
propertyType: 'TOWNHOUSE',
title: 'Nhà phố Thủ Đức',
description: 'Nhà phố đẹp',
address: '456 Võ Văn Ngân',
ward: 'Linh Chiểu',
district: 'Thủ Đức',
city: 'Hồ Chí Minh',
latitude: 10.8514,
longitude: 106.7583,
areaM2: 120,
usableAreaM2: 100,
bedrooms: 3,
bathrooms: 2,
floors: 3,
floor: null,
totalFloors: null,
direction: 'SOUTH',
yearBuilt: 2021,
legalStatus: 'Sổ hồng',
amenities: { parking: true },
nearbyPOIs: [],
metroDistanceM: 500,
projectName: null,
createdAt: now,
updatedAt: now,
},
]);
const result = await repository.findById('prop-hcm');
expect(result).toBeInstanceOf(PropertyEntity);
expect(result!.location.latitude).toBe(10.8514);
expect(result!.location.longitude).toBe(106.7583);
// Verify WKT generation works with the correct coordinates
expect(result!.location.toWKT()).toBe('POINT(106.7583 10.8514)');
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Prisma, Property as PrismaProperty, PropertyMedia as PrismaMedia } from '@prisma/client'; import { Prisma, PropertyMedia as PrismaMedia, PropertyType, Direction } from '@prisma/client';
import { PrismaService } from '@modules/shared'; import { PrismaService } from '@modules/shared';
import { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.entity'; import { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.entity';
import { PropertyEntity, PropertyProps } from '../../domain/entities/property.entity'; import { PropertyEntity, PropertyProps } from '../../domain/entities/property.entity';
@@ -7,13 +7,58 @@ import { IPropertyRepository } from '../../domain/repositories/property.reposito
import { Address } from '../../domain/value-objects/address.vo'; import { Address } from '../../domain/value-objects/address.vo';
import { GeoPoint } from '../../domain/value-objects/geo-point.vo'; import { GeoPoint } from '../../domain/value-objects/geo-point.vo';
/** Shape returned by findById raw SQL query with ST_X/ST_Y extracted coordinates */
interface PropertyWithGeo {
id: string;
propertyType: PropertyType;
title: string;
description: string;
address: string;
ward: string;
district: string;
city: string;
latitude: number;
longitude: number;
areaM2: number;
usableAreaM2: number | null;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
floor: number | null;
totalFloors: number | null;
direction: Direction | null;
yearBuilt: number | null;
legalStatus: string | null;
amenities: unknown;
nearbyPOIs: unknown;
metroDistanceM: number | null;
projectName: string | null;
createdAt: Date;
updatedAt: Date;
}
@Injectable() @Injectable()
export class PrismaPropertyRepository implements IPropertyRepository { export class PrismaPropertyRepository implements IPropertyRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<PropertyEntity | null> { async findById(id: string): Promise<PropertyEntity | null> {
const property = await this.prisma.property.findUnique({ where: { id } }); // Use raw SQL to extract lat/lng from PostGIS geometry(Point, 4326)
return property ? this.toDomain(property) : null; const rows = await this.prisma.$queryRaw<PropertyWithGeo[]>`
SELECT
"id", "propertyType", "title", "description",
"address", "ward", "district", "city",
ST_Y("location"::geometry) AS latitude,
ST_X("location"::geometry) AS longitude,
"areaM2", "usableAreaM2", "bedrooms", "bathrooms",
"floors", "floor", "totalFloors", "direction",
"yearBuilt", "legalStatus", "amenities", "nearbyPOIs",
"metroDistanceM", "projectName", "createdAt", "updatedAt"
FROM "Property"
WHERE "id" = ${id}
LIMIT 1
`;
const row = rows[0];
return row ? this.toDomainWithGeo(row) : null;
} }
async save(entity: PropertyEntity): Promise<void> { async save(entity: PropertyEntity): Promise<void> {
@@ -91,10 +136,8 @@ export class PrismaPropertyRepository implements IPropertyRepository {
return this.prisma.propertyMedia.count({ where: { propertyId } }); return this.prisma.propertyMedia.count({ where: { propertyId } });
} }
private toDomain(raw: PrismaProperty): PropertyEntity { private toDomainWithGeo(raw: PropertyWithGeo): PropertyEntity {
// PostGIS geometry is returned as a raw object — extract lat/lng const geoPoint = GeoPoint.create(raw.latitude, raw.longitude).unwrap();
// For raw SQL results we'd get WKB, but Prisma returns Unsupported as-is
const geoPoint = GeoPoint.create(0, 0).unwrap(); // placeholder — location read via raw SQL
const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap(); const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap();
const props: PropertyProps = { const props: PropertyProps = {