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>;
|
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)');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
Reference in New Issue
Block a user