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:
@@ -16,6 +16,7 @@ describe('PrismaPropertyRepository', () => {
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
$executeRaw: ReturnType<typeof vi.fn>;
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -30,51 +31,53 @@ describe('PrismaPropertyRepository', () => {
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
},
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
$queryRaw: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
repository = new PrismaPropertyRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should return null when property not found', async () => {
|
||||
mockPrisma.property.findUnique.mockResolvedValue(null);
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
const result = await repository.findById('non-existent');
|
||||
|
||||
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();
|
||||
mockPrisma.property.findUnique.mockResolvedValue({
|
||||
id: 'prop-1',
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Căn hộ đẹp',
|
||||
description: 'Mô tả chi tiết',
|
||||
address: '123 Nguyễn Huệ',
|
||||
ward: 'Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
location: null, // PostGIS geometry placeholder
|
||||
areaM2: 80,
|
||||
usableAreaM2: 70,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: null,
|
||||
floor: 10,
|
||||
totalFloors: 25,
|
||||
direction: 'EAST',
|
||||
yearBuilt: 2022,
|
||||
legalStatus: 'Sổ hồng',
|
||||
amenities: null,
|
||||
nearbyPOIs: null,
|
||||
metroDistanceM: 300,
|
||||
projectName: 'Vinhomes',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
// ST_Y/ST_X extract actual coordinates — no more hardcoded (0,0)
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{
|
||||
id: 'prop-1',
|
||||
propertyType: 'APARTMENT',
|
||||
title: 'Căn hộ đẹp',
|
||||
description: 'Mô tả chi tiết',
|
||||
address: '123 Nguyễn Huệ',
|
||||
ward: 'Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
latitude: 10.7769,
|
||||
longitude: 106.7009,
|
||||
areaM2: 80,
|
||||
usableAreaM2: 70,
|
||||
bedrooms: 2,
|
||||
bathrooms: 2,
|
||||
floors: null,
|
||||
floor: 10,
|
||||
totalFloors: 25,
|
||||
direction: 'EAST',
|
||||
yearBuilt: 2022,
|
||||
legalStatus: 'Sổ hồng',
|
||||
amenities: null,
|
||||
nearbyPOIs: null,
|
||||
metroDistanceM: 300,
|
||||
projectName: 'Vinhomes',
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await repository.findById('prop-1');
|
||||
|
||||
@@ -82,6 +85,51 @@ describe('PrismaPropertyRepository', () => {
|
||||
expect(result!.id).toBe('prop-1');
|
||||
expect(result!.title).toBe('Căn hộ đẹp');
|
||||
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)');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { PropertyMediaEntity, PropertyMediaProps } from '../../domain/entities/property-media.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 { 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()
|
||||
export class PrismaPropertyRepository implements IPropertyRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async findById(id: string): Promise<PropertyEntity | null> {
|
||||
const property = await this.prisma.property.findUnique({ where: { id } });
|
||||
return property ? this.toDomain(property) : null;
|
||||
// Use raw SQL to extract lat/lng from PostGIS geometry(Point, 4326)
|
||||
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> {
|
||||
@@ -91,10 +136,8 @@ export class PrismaPropertyRepository implements IPropertyRepository {
|
||||
return this.prisma.propertyMedia.count({ where: { propertyId } });
|
||||
}
|
||||
|
||||
private toDomain(raw: PrismaProperty): PropertyEntity {
|
||||
// PostGIS geometry is returned as a raw object — extract lat/lng
|
||||
// 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
|
||||
private toDomainWithGeo(raw: PropertyWithGeo): PropertyEntity {
|
||||
const geoPoint = GeoPoint.create(raw.latitude, raw.longitude).unwrap();
|
||||
const address = Address.create(raw.address, raw.ward, raw.district, raw.city).unwrap();
|
||||
|
||||
const props: PropertyProps = {
|
||||
|
||||
Reference in New Issue
Block a user