docs(env): canonicalize FIELD_ENCRYPTION_KEY in .env.example (GOO-238)
Replace KYC_ENCRYPTION_KEY/KYC_ENCRYPTION_KEY_VERSION in .env.example with the canonical FIELD_ENCRYPTION_KEY/FIELD_ENCRYPTION_KEY_VERSION used by env-validation.ts and the rotation runbook. Update bootstrap.sh sed line to substitute the canonical name. Runtime still reads the legacy KYC_* vars as a deprecated fallback for existing operators. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -29,12 +29,15 @@ describe('PrismaAVMService', () => {
|
||||
});
|
||||
|
||||
it('returns zero confidence when fewer than 3 comparables', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
]);
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
]);
|
||||
// First $queryRaw call: property location lookup
|
||||
// Second $queryRaw call: findComparables (parameterized after refactor in 6774914)
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||
|
||||
@@ -44,14 +47,15 @@ describe('PrismaAVMService', () => {
|
||||
});
|
||||
|
||||
it('calculates weighted valuation with sufficient comparables', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
]);
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||
]);
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await service.estimateValue({ propertyId: 'prop-1' });
|
||||
|
||||
@@ -63,7 +67,8 @@ describe('PrismaAVMService', () => {
|
||||
});
|
||||
|
||||
it('uses coordinates directly when no propertyId', async () => {
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
// coords-only path: no property lookup, $queryRaw used for comparables directly
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 100, published_at: new Date() },
|
||||
{ property_id: 'p2', address: '2 Test', district: 'Q1', price_vnd: 5200000000n, price_per_m2: 72000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 300, published_at: new Date() },
|
||||
{ property_id: 'p3', address: '3 Test', district: 'Q1', price_vnd: 5500000000n, price_per_m2: 75000000, area_m2: 73, property_type: 'APARTMENT', distance_meters: 500, published_at: new Date() },
|
||||
@@ -78,18 +83,20 @@ describe('PrismaAVMService', () => {
|
||||
|
||||
expect(result.confidence).toBeGreaterThan(0);
|
||||
expect(Number(result.estimatedPrice)).toBeGreaterThan(0);
|
||||
expect(mockPrisma.$queryRaw).not.toHaveBeenCalled();
|
||||
// coords-only path: $queryRaw is called for comparables, $queryRawUnsafe is not
|
||||
expect(mockPrisma.$queryRawUnsafe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getComparables', () => {
|
||||
it('returns comparables for a property', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
]);
|
||||
mockPrisma.$queryRawUnsafe.mockResolvedValue([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
||||
]);
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([
|
||||
{ latitude: 10.762, longitude: 106.66, areaM2: 80, propertyType: 'APARTMENT', yearBuilt: 2020, floor: 5, totalFloors: 20 },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ property_id: 'p1', address: '1 Test', district: 'Q1', price_vnd: 5000000000n, price_per_m2: 70000000, area_m2: 72, property_type: 'APARTMENT', distance_meters: 200, published_at: new Date() },
|
||||
]);
|
||||
|
||||
const result = await service.getComparables('prop-1', 3000);
|
||||
|
||||
|
||||
@@ -144,4 +144,45 @@ describe('SearchPropertiesHandler', () => {
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
expect(searchCall.filterBy).not.toContain('isFeatured');
|
||||
});
|
||||
|
||||
it('emits ward clause when ward is provided', async () => {
|
||||
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||
|
||||
const query = new SearchPropertiesQuery(
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, 'Tan Phong',
|
||||
);
|
||||
await handler.execute(query);
|
||||
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
expect(searchCall.filterBy).toContain('ward:=Tan Phong');
|
||||
});
|
||||
|
||||
it('omits ward clause when ward is not provided', async () => {
|
||||
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||
|
||||
await handler.execute(new SearchPropertiesQuery('anything'));
|
||||
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
expect(searchCall.filterBy).not.toContain('ward:=');
|
||||
});
|
||||
|
||||
it('round-trips ward through parseFilterBy', async () => {
|
||||
const { parseFilterBy } = await import(
|
||||
'../../infrastructure/services/search-filter-parser'
|
||||
);
|
||||
mockSearchRepo.search.mockResolvedValue(createMockSearchResult());
|
||||
|
||||
const query = new SearchPropertiesQuery(
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, 'Tan Phong',
|
||||
);
|
||||
await handler.execute(query);
|
||||
|
||||
const searchCall = mockSearchRepo.search.mock.calls[0]![0];
|
||||
const parsed = parseFilterBy(searchCall.filterBy);
|
||||
expect(parsed.ward).toBe('Tan Phong');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,9 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
||||
if (query.district) {
|
||||
filters.push(`district:=${query.district}`);
|
||||
}
|
||||
if (query.ward) {
|
||||
filters.push(`ward:=${query.ward}`);
|
||||
}
|
||||
if (query.city) {
|
||||
filters.push(`city:=${query.city}`);
|
||||
}
|
||||
@@ -70,6 +73,7 @@ export class SearchPropertiesHandler implements IQueryHandler<SearchPropertiesQu
|
||||
query.transactionType,
|
||||
query.district,
|
||||
query.city,
|
||||
query.ward,
|
||||
query.page,
|
||||
query.perPage,
|
||||
query.priceMin,
|
||||
|
||||
@@ -14,5 +14,6 @@ export class SearchPropertiesQuery {
|
||||
public readonly page?: number,
|
||||
public readonly perPage?: number,
|
||||
public readonly featured?: boolean,
|
||||
public readonly ward?: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SearchFilterProps {
|
||||
areaMax?: number;
|
||||
bedrooms?: number;
|
||||
district?: string;
|
||||
ward?: string;
|
||||
city?: string;
|
||||
sortBy?: 'price_asc' | 'price_desc' | 'date_desc' | 'relevance' | 'distance';
|
||||
page?: number;
|
||||
@@ -53,6 +54,10 @@ export class SearchFilter extends ValueObject<SearchFilterProps> {
|
||||
return this.props.district;
|
||||
}
|
||||
|
||||
get ward(): string | undefined {
|
||||
return this.props.ward;
|
||||
}
|
||||
|
||||
get city(): string | undefined {
|
||||
return this.props.city;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface ParsedFilters {
|
||||
areaMax?: number;
|
||||
bedrooms?: number;
|
||||
district?: string;
|
||||
ward?: string;
|
||||
city?: string;
|
||||
}
|
||||
|
||||
@@ -49,6 +50,7 @@ export function parseFilterBy(filterStr: string): ParsedFilters {
|
||||
if (field === 'propertyType') result.propertyType = val;
|
||||
else if (field === 'transactionType') result.transactionType = val;
|
||||
else if (field === 'district') result.district = val;
|
||||
else if (field === 'ward') result.ward = val;
|
||||
else if (field === 'city') result.city = val;
|
||||
else if (field === 'status') { /* handled separately */ }
|
||||
continue;
|
||||
|
||||
@@ -52,6 +52,7 @@ export class SearchController {
|
||||
dto.page,
|
||||
dto.perPage,
|
||||
dto.featured,
|
||||
dto.ward,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -74,6 +74,11 @@ export class SearchPropertiesDto {
|
||||
@IsString()
|
||||
district?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Ward name', example: 'Tan Phong' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ward?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'City name', example: 'Ho Chi Minh' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||
import { InquiryDetailDialog } from '../inquiry-detail-dialog';
|
||||
import type { InquiryReadDto } from '@/lib/inquiries-api';
|
||||
|
||||
// Mock the hook
|
||||
const mockMarkReadMutate = vi.fn();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { LeadReadDto } from '@/lib/leads-api';
|
||||
import { LeadDetailDialog } from '../lead-detail-dialog';
|
||||
import type { LeadReadDto } from '@/lib/leads-api';
|
||||
|
||||
// Mock hooks
|
||||
const mockUpdateMutate = vi.fn();
|
||||
|
||||
@@ -5,6 +5,7 @@ import { defineConfig } from 'vitest/config';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
root: path.resolve(__dirname, '.'),
|
||||
include: ['**/__tests__/**/*.spec.ts', '**/__tests__/**/*.test.ts', '**/__tests__/**/*.spec.tsx', '**/__tests__/**/*.test.tsx'],
|
||||
exclude: ['**/node_modules/**'],
|
||||
environment: 'jsdom',
|
||||
|
||||
Reference in New Issue
Block a user