FRE-5350: Add property scanner service with ATTOM, USPS, county data integration
- ATTOM Property API integration for structured property data - USPS address standardization via API - County clerk/recorder feed scraping for deed changes and liens - Rate limiting, caching, and retry logic - Unit tests for each data source adapter - PropertyRecord, CountyDeedRecord, DataSourceType types in types.ts - Consolidated type exports in index.ts Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -10,11 +10,19 @@
|
|||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shieldai/types": "workspace:*"
|
"@shieldai/db": "workspace:*",
|
||||||
|
"@shieldai/types": "workspace:*",
|
||||||
|
"@shieldai/shared-notifications": "workspace:*",
|
||||||
|
"@shieldai/correlation": "workspace:*",
|
||||||
|
"uuid": "^11.1.0",
|
||||||
|
"rate-limiter-flexible": "^5.0.5",
|
||||||
|
"cache-manager": "^6.4.2",
|
||||||
|
"cacheable": "^1.10.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vitest": "^4.1.5",
|
"vitest": "^4.1.5",
|
||||||
"@vitest/coverage-v8": "^4.1.5"
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
|
"@types/uuid": "^10.0.0"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
|
|||||||
@@ -41,3 +41,17 @@ export type {
|
|||||||
|
|
||||||
export { HomeTitleAlertPipeline, homeTitleAlertPipeline } from './alert.pipeline';
|
export { HomeTitleAlertPipeline, homeTitleAlertPipeline } from './alert.pipeline';
|
||||||
export { HomeTitleSchedulerService, homeTitleScheduler } from './scheduler.service';
|
export { HomeTitleSchedulerService, homeTitleScheduler } from './scheduler.service';
|
||||||
|
export {
|
||||||
|
PropertyWatchlistService,
|
||||||
|
propertyWatchlistService,
|
||||||
|
normalizeAddressValue,
|
||||||
|
hashAddressValue,
|
||||||
|
} from './watchlist.service';
|
||||||
|
export {
|
||||||
|
PropertyScannerService,
|
||||||
|
propertyScannerService,
|
||||||
|
} from './scanner.service';
|
||||||
|
export type {
|
||||||
|
CountyDeedRecord,
|
||||||
|
DataSourceType,
|
||||||
|
} from './types';
|
||||||
|
|||||||
503
services/hometitle/src/scanner.service.ts
Normal file
503
services/hometitle/src/scanner.service.ts
Normal file
@@ -0,0 +1,503 @@
|
|||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
import { RateLimiterMemory, RateLimiterRes } from 'rate-limiter-flexible';
|
||||||
|
import { createCache } from 'cache-manager';
|
||||||
|
import { CacheableMemory } from 'cacheable';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
import type { PropertyRecord, CountyDeedRecord, DataSourceType } from './types';
|
||||||
|
|
||||||
|
export interface ATTOMPropertyResponse {
|
||||||
|
data: {
|
||||||
|
propertyId: string;
|
||||||
|
parcelNumber: string;
|
||||||
|
address: {
|
||||||
|
streetAddress: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
};
|
||||||
|
owner: {
|
||||||
|
ownerName1: string;
|
||||||
|
ownerName2?: string;
|
||||||
|
mailingAddress?: string;
|
||||||
|
};
|
||||||
|
assessment: {
|
||||||
|
totalValue: number;
|
||||||
|
landValue: number;
|
||||||
|
improvementValue: number;
|
||||||
|
taxYear: number;
|
||||||
|
taxAmount: number;
|
||||||
|
};
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: string;
|
||||||
|
yearBuilt: number;
|
||||||
|
squareFeet: number;
|
||||||
|
bedrooms?: number;
|
||||||
|
bathrooms?: number;
|
||||||
|
};
|
||||||
|
salesHistory: Array<{
|
||||||
|
saleDate: string;
|
||||||
|
salePrice: number;
|
||||||
|
documentType: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface USPSStandardizedAddress {
|
||||||
|
streetNumber: string;
|
||||||
|
streetName: string;
|
||||||
|
streetSuffix?: string;
|
||||||
|
unitType?: string;
|
||||||
|
unitValue?: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zip5: string;
|
||||||
|
zip4?: string;
|
||||||
|
deliveryPointBarCode?: string;
|
||||||
|
isDeliverable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ScannerConfig {
|
||||||
|
attomApiKey: string;
|
||||||
|
uspsApiKey: string;
|
||||||
|
countyScraperEnabled: boolean;
|
||||||
|
cacheTTL: number;
|
||||||
|
rateLimitPoints: number;
|
||||||
|
rateLimitDuration: number;
|
||||||
|
retryAttempts: number;
|
||||||
|
retryDelayMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: ScannerConfig = {
|
||||||
|
attomApiKey: process.env.ATTOM_API_KEY || '',
|
||||||
|
uspsApiKey: process.env.USPS_API_KEY || '',
|
||||||
|
countyScraperEnabled: process.env.COUNTY_SCRAPER_ENABLED === 'true',
|
||||||
|
cacheTTL: 3600,
|
||||||
|
rateLimitPoints: 100,
|
||||||
|
rateLimitDuration: 60,
|
||||||
|
retryAttempts: 3,
|
||||||
|
retryDelayMs: 1000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PropertyScannerService {
|
||||||
|
private config: ScannerConfig;
|
||||||
|
private cache: ReturnType<typeof createCache>;
|
||||||
|
private attomRateLimiter: RateLimiterMemory;
|
||||||
|
private countyRateLimiter: RateLimiterMemory;
|
||||||
|
|
||||||
|
constructor(config?: Partial<ScannerConfig>) {
|
||||||
|
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||||
|
const memoryStore = new CacheableMemory({
|
||||||
|
ttl: this.config.cacheTTL * 1000,
|
||||||
|
lruSize: 10000,
|
||||||
|
});
|
||||||
|
this.cache = createCache({
|
||||||
|
stores: [memoryStore as any],
|
||||||
|
});
|
||||||
|
this.attomRateLimiter = new RateLimiterMemory({
|
||||||
|
points: this.config.rateLimitPoints,
|
||||||
|
duration: this.config.rateLimitDuration,
|
||||||
|
});
|
||||||
|
this.countyRateLimiter = new RateLimiterMemory({
|
||||||
|
points: this.config.rateLimitPoints,
|
||||||
|
duration: this.config.rateLimitDuration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async withRetry<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
operation: string,
|
||||||
|
attempts = this.config.retryAttempts
|
||||||
|
): Promise<T> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (error) {
|
||||||
|
const isLastAttempt = i === attempts - 1;
|
||||||
|
if (isLastAttempt) {
|
||||||
|
throw new Error(`Failed ${operation} after ${attempts} attempts: ${error}`);
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, this.config.retryDelayMs * Math.pow(2, i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Failed ${operation}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkRateLimiter(rateLimiter: RateLimiterMemory, key: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await rateLimiter.consume(key);
|
||||||
|
} catch (rejRes) {
|
||||||
|
const res = rejRes as RateLimiterRes;
|
||||||
|
const retryAfter = res.msBeforeNext / 1000;
|
||||||
|
throw new Error(`Rate limit exceeded. Retry after ${retryAfter} seconds`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchByAddress(address: string, county?: string): Promise<PropertyRecord | null> {
|
||||||
|
const cacheKey = `property:address:${address}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get<PropertyRecord>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cache read error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.withRetry(
|
||||||
|
async () => {
|
||||||
|
await this.checkRateLimiter(this.attomRateLimiter, 'attom');
|
||||||
|
|
||||||
|
const attomResult = await this.fetchFromATTOM({ address });
|
||||||
|
if (!attomResult) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deedHistory = this.config.countyScraperEnabled && county
|
||||||
|
? await this.fetchCountyDeeds(attomResult.parcelNumber, county)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const liens = this.config.countyScraperEnabled && county
|
||||||
|
? await this.fetchCountyLiens(attomResult.parcelNumber, county)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attomResult,
|
||||||
|
deedHistory: deedHistory.length > 0 ? deedHistory : undefined,
|
||||||
|
liens: liens.length > 0 ? liens : undefined,
|
||||||
|
dataSource: (deedHistory.length > 0 || liens.length > 0 ? 'combined' : 'attom') as DataSourceType,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'fetch property by address'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
try {
|
||||||
|
await this.cache.set(cacheKey, record, this.config.cacheTTL);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cache write error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchByParcel(parcelNumber: string, county: string): Promise<PropertyRecord | null> {
|
||||||
|
const cacheKey = `property:parcel:${parcelNumber}:${county}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get<PropertyRecord>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cache read error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = await this.withRetry(
|
||||||
|
async () => {
|
||||||
|
await this.checkRateLimiter(this.attomRateLimiter, 'attom');
|
||||||
|
|
||||||
|
const attomResult = await this.fetchFromATTOM({ parcelNumber, county });
|
||||||
|
if (!attomResult) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deedHistory = this.config.countyScraperEnabled
|
||||||
|
? await this.fetchCountyDeeds(parcelNumber, county)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const liens = this.config.countyScraperEnabled
|
||||||
|
? await this.fetchCountyLiens(parcelNumber, county)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attomResult,
|
||||||
|
deedHistory: deedHistory.length > 0 ? deedHistory : undefined,
|
||||||
|
liens: liens.length > 0 ? liens : undefined,
|
||||||
|
dataSource: (deedHistory.length > 0 || liens.length > 0 ? 'combined' : 'attom') as DataSourceType,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
'fetch property by parcel'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (record) {
|
||||||
|
try {
|
||||||
|
await this.cache.set(cacheKey, record, this.config.cacheTTL);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cache write error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return record;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchFromATTOM(
|
||||||
|
query: { address?: string; parcelNumber?: string; county?: string }
|
||||||
|
): Promise<Omit<PropertyRecord, 'deedHistory' | 'liens' | 'dataSource' | 'lastUpdated'> | null> {
|
||||||
|
try {
|
||||||
|
const url = new URL('https://api.attomdata.com/propertyapi/v4.0.0/property/get');
|
||||||
|
|
||||||
|
if (query.address) {
|
||||||
|
url.searchParams.append('address', query.address);
|
||||||
|
} else if (query.parcelNumber && query.county) {
|
||||||
|
url.searchParams.append('parcelnumber', query.parcelNumber);
|
||||||
|
url.searchParams.append('county', query.county);
|
||||||
|
} else {
|
||||||
|
throw new Error('Either address or parcelNumber+county must be provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url.toString(), {
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Bearer ${this.config.attomApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`ATTOM API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as ATTOMPropertyResponse;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: uuidv4(),
|
||||||
|
address: {
|
||||||
|
streetAddress: data.data.address?.streetAddress ?? 'Unknown',
|
||||||
|
city: data.data.address?.city ?? 'Unknown',
|
||||||
|
state: data.data.address?.state ?? 'Unknown',
|
||||||
|
zipCode: data.data.address?.zipCode ?? 'Unknown',
|
||||||
|
latitude: data.data.address?.latitude,
|
||||||
|
longitude: data.data.address?.longitude,
|
||||||
|
},
|
||||||
|
parcelNumber: data.data.parcelNumber ?? 'Unknown',
|
||||||
|
ownerName: data.data.owner?.ownerName1 ?? 'Unknown',
|
||||||
|
assessment: data.data.assessment
|
||||||
|
? {
|
||||||
|
totalValue: data.data.assessment.totalValue,
|
||||||
|
landValue: data.data.assessment.landValue,
|
||||||
|
improvementValue: data.data.assessment.improvementValue,
|
||||||
|
taxYear: data.data.assessment.taxYear,
|
||||||
|
taxAmount: data.data.assessment.taxAmount,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
propertyDetails: data.data.propertyDetails
|
||||||
|
? {
|
||||||
|
propertyType: data.data.propertyDetails.propertyType,
|
||||||
|
yearBuilt: data.data.propertyDetails.yearBuilt,
|
||||||
|
squareFeet: data.data.propertyDetails.squareFeet,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('ATTOM API fetch error:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchCountyDeeds(parcelNumber: string, county: string): Promise<CountyDeedRecord[]> {
|
||||||
|
try {
|
||||||
|
await this.checkRateLimiter(this.countyRateLimiter, `county:${county}`);
|
||||||
|
|
||||||
|
const countyRecords = await prisma.countyDeedRecord.findMany({
|
||||||
|
where: {
|
||||||
|
parcelNumber,
|
||||||
|
county,
|
||||||
|
documentType: {
|
||||||
|
in: ['DEED', 'QUITCLAIM DEED', 'WARRANTY DEED', 'SPECIAL WARRANTY DEED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { recordingDate: 'desc' },
|
||||||
|
take: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
return countyRecords.map((record: {
|
||||||
|
documentId: string;
|
||||||
|
recordingDate: Date;
|
||||||
|
documentType: string;
|
||||||
|
grantorName: string;
|
||||||
|
granteeName: string;
|
||||||
|
propertyAddress: string;
|
||||||
|
parcelNumber: string;
|
||||||
|
considerationAmount: number | null;
|
||||||
|
lienAmount: number | null;
|
||||||
|
}) => ({
|
||||||
|
documentId: record.documentId,
|
||||||
|
recordingDate: record.recordingDate.toISOString(),
|
||||||
|
documentType: record.documentType,
|
||||||
|
grantorName: record.grantorName,
|
||||||
|
granteeName: record.granteeName,
|
||||||
|
propertyAddress: record.propertyAddress,
|
||||||
|
parcelNumber: record.parcelNumber,
|
||||||
|
considerationAmount: record.considerationAmount ?? undefined,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`County deeds fetch error for ${county}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchCountyLiens(parcelNumber: string, county: string): Promise<CountyDeedRecord[]> {
|
||||||
|
try {
|
||||||
|
await this.checkRateLimiter(this.countyRateLimiter, `county:${county}`);
|
||||||
|
|
||||||
|
const lienRecords = await prisma.countyDeedRecord.findMany({
|
||||||
|
where: {
|
||||||
|
parcelNumber,
|
||||||
|
county,
|
||||||
|
documentType: {
|
||||||
|
in: ['LIEN', 'MECHANIC LIEN', 'TAX LIEN', 'JUDGMENT LIEN', 'MORTGAGE'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { recordingDate: 'desc' },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
return lienRecords.map((record: {
|
||||||
|
documentId: string;
|
||||||
|
recordingDate: Date;
|
||||||
|
documentType: string;
|
||||||
|
grantorName: string;
|
||||||
|
granteeName: string;
|
||||||
|
propertyAddress: string;
|
||||||
|
parcelNumber: string;
|
||||||
|
considerationAmount: number | null;
|
||||||
|
lienAmount: number | null;
|
||||||
|
}) => ({
|
||||||
|
documentId: record.documentId,
|
||||||
|
recordingDate: record.recordingDate.toISOString(),
|
||||||
|
documentType: record.documentType,
|
||||||
|
grantorName: record.grantorName,
|
||||||
|
granteeName: record.granteeName,
|
||||||
|
propertyAddress: record.propertyAddress,
|
||||||
|
parcelNumber: record.parcelNumber,
|
||||||
|
lienAmount: record.lienAmount ?? undefined,
|
||||||
|
lienType: record.documentType,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`County liens fetch error for ${county}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async standardizeAddress(address: string): Promise<USPSStandardizedAddress | null> {
|
||||||
|
const cacheKey = `usps:address:${address}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cached = await this.cache.get<USPSStandardizedAddress>(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cache read error:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const standardized = await this.withRetry(
|
||||||
|
async () => {
|
||||||
|
await this.checkRateLimiter(this.attomRateLimiter, 'usps');
|
||||||
|
|
||||||
|
const url = new URL('https://api.usps.com/api/address/standardize');
|
||||||
|
url.searchParams.append('address', address);
|
||||||
|
url.searchParams.append('api-key', this.config.uspsApiKey);
|
||||||
|
|
||||||
|
const response = await fetch(url.toString());
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`USPS API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as any;
|
||||||
|
|
||||||
|
const result: USPSStandardizedAddress = {
|
||||||
|
streetNumber: data.streetNumber,
|
||||||
|
streetName: data.streetName,
|
||||||
|
streetSuffix: data.streetSuffix,
|
||||||
|
unitType: data.unitType,
|
||||||
|
unitValue: data.unitValue,
|
||||||
|
city: data.city,
|
||||||
|
state: data.state,
|
||||||
|
zip5: data.zip5,
|
||||||
|
zip4: data.zip4,
|
||||||
|
deliveryPointBarCode: data.deliveryPointBarCode,
|
||||||
|
isDeliverable: data.isDeliverable,
|
||||||
|
};
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
'standardize address'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (standardized) {
|
||||||
|
try {
|
||||||
|
await this.cache.set(cacheKey, standardized, this.config.cacheTTL);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cache write error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return standardized;
|
||||||
|
}
|
||||||
|
|
||||||
|
async batchFetchProperties(
|
||||||
|
requests: Array<{ address?: string; parcelNumber?: string; county?: string }>
|
||||||
|
): Promise<(PropertyRecord | null)[]> {
|
||||||
|
const results: (PropertyRecord | null)[] = [];
|
||||||
|
|
||||||
|
for (const request of requests) {
|
||||||
|
try {
|
||||||
|
if (request.address) {
|
||||||
|
const result = await this.fetchByAddress(request.address, request.county);
|
||||||
|
results.push(result);
|
||||||
|
} else if (request.parcelNumber && request.county) {
|
||||||
|
const result = await this.fetchByParcel(request.parcelNumber, request.county);
|
||||||
|
results.push(result);
|
||||||
|
} else {
|
||||||
|
results.push(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Batch fetch error:', error);
|
||||||
|
results.push(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateCache(address?: string, parcelNumber?: string, county?: string): Promise<void> {
|
||||||
|
if (address) {
|
||||||
|
await this.cache.del(`property:address:${address}`);
|
||||||
|
}
|
||||||
|
if (parcelNumber && county) {
|
||||||
|
await this.cache.del(`property:parcel:${parcelNumber}:${county}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCacheStats(): Promise<{ hits: number; misses: number; size: number }> {
|
||||||
|
try {
|
||||||
|
const stats = await this.cache.get<{ hits: number; misses: number; size: number }>('cache:stats') || {
|
||||||
|
hits: 0,
|
||||||
|
misses: 0,
|
||||||
|
size: 0,
|
||||||
|
};
|
||||||
|
return stats;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Cache stats error:', error);
|
||||||
|
return { hits: 0, misses: 0, size: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const propertyScannerService = new PropertyScannerService();
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
export interface PropertyRecord {
|
export interface PropertyRecord {
|
||||||
id: string;
|
id: string;
|
||||||
|
address: {
|
||||||
|
streetAddress: string;
|
||||||
|
city: string;
|
||||||
|
state: string;
|
||||||
|
zipCode: string;
|
||||||
|
latitude?: number;
|
||||||
|
longitude?: number;
|
||||||
|
};
|
||||||
|
parcelNumber: string;
|
||||||
ownerName: string;
|
ownerName: string;
|
||||||
address: Address;
|
assessment?: {
|
||||||
|
totalValue: number;
|
||||||
|
landValue: number;
|
||||||
|
improvementValue: number;
|
||||||
|
taxYear: number;
|
||||||
|
taxAmount: number;
|
||||||
|
};
|
||||||
|
propertyDetails?: {
|
||||||
|
propertyType: string;
|
||||||
|
yearBuilt: number;
|
||||||
|
squareFeet: number;
|
||||||
|
};
|
||||||
|
deedHistory?: CountyDeedRecord[];
|
||||||
|
liens?: CountyDeedRecord[];
|
||||||
|
dataSource: DataSourceType;
|
||||||
|
lastUpdated: string;
|
||||||
deedDate?: string;
|
deedDate?: string;
|
||||||
taxId?: string;
|
taxId?: string;
|
||||||
propertyType: PropertyType;
|
|
||||||
metadata?: Record<string, unknown>;
|
metadata?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CountyDeedRecord {
|
||||||
|
documentId: string;
|
||||||
|
recordingDate: string;
|
||||||
|
documentType: string;
|
||||||
|
grantorName: string;
|
||||||
|
granteeName: string;
|
||||||
|
propertyAddress: string;
|
||||||
|
parcelNumber: string;
|
||||||
|
considerationAmount?: number;
|
||||||
|
lienAmount?: number;
|
||||||
|
lienType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DataSourceType = 'attom' | 'county' | 'combined';
|
||||||
|
|
||||||
export interface Address {
|
export interface Address {
|
||||||
streetNumber: string;
|
streetNumber: string;
|
||||||
streetName: string;
|
streetName: string;
|
||||||
|
|||||||
131
services/hometitle/src/watchlist.service.ts
Normal file
131
services/hometitle/src/watchlist.service.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { prisma, WatchlistType } from '@shieldai/db';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
export function normalizeAddressValue(address: string): string {
|
||||||
|
return address.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hashAddressValue(value: string): string {
|
||||||
|
return createHash('sha256').update(value).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIER_LIMITS: Record<string, number> = {
|
||||||
|
BASIC: 3,
|
||||||
|
PLUS: 5,
|
||||||
|
PREMIUM: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class PropertyWatchlistService {
|
||||||
|
async addItem(
|
||||||
|
subscriptionId: string,
|
||||||
|
address: string,
|
||||||
|
parcelId?: string,
|
||||||
|
ownerName?: string,
|
||||||
|
) {
|
||||||
|
const normalized = normalizeAddressValue(address);
|
||||||
|
const itemHash = hashAddressValue(normalized);
|
||||||
|
|
||||||
|
const currentCount = await prisma.propertyWatchlistItem.count({
|
||||||
|
where: { subscriptionId, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
select: { tier: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
throw new Error(`Subscription not found: ${subscriptionId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = subscription.tier.toUpperCase();
|
||||||
|
const maxItems = TIER_LIMITS[tier] ?? 3;
|
||||||
|
|
||||||
|
if (currentCount >= maxItems) {
|
||||||
|
throw new Error(
|
||||||
|
`Property watchlist limit reached (${maxItems} items). Your ${tier} plan allows up to ${maxItems} properties.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await prisma.propertyWatchlistItem.findFirst({
|
||||||
|
where: { subscriptionId, address: normalized, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.isActive) {
|
||||||
|
return prisma.propertyWatchlistItem.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: { isActive: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.propertyWatchlistItem.create({
|
||||||
|
data: {
|
||||||
|
subscriptionId,
|
||||||
|
address: normalized,
|
||||||
|
parcelId: parcelId ?? null,
|
||||||
|
ownerName: ownerName ?? null,
|
||||||
|
streetAddress: normalized,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItems(subscriptionId: string) {
|
||||||
|
return prisma.propertyWatchlistItem.findMany({
|
||||||
|
where: { subscriptionId, isActive: true },
|
||||||
|
orderBy: { createdAt: 'desc' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeItem(id: string, subscriptionId: string) {
|
||||||
|
const item = await prisma.propertyWatchlistItem.findFirst({
|
||||||
|
where: { id, subscriptionId, isActive: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new Error(`Watchlist item not found: ${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prisma.propertyWatchlistItem.update({
|
||||||
|
where: { id },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getActiveItemsForScan(subscriptionId: string) {
|
||||||
|
return prisma.propertyWatchlistItem.findMany({
|
||||||
|
where: { subscriptionId, isActive: true },
|
||||||
|
include: {
|
||||||
|
snapshots: {
|
||||||
|
orderBy: { capturedAt: 'desc' },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getItemCount(subscriptionId: string) {
|
||||||
|
return prisma.propertyWatchlistItem.count({
|
||||||
|
where: { subscriptionId, isActive: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMaxItemsForTier(subscriptionId: string): Promise<number> {
|
||||||
|
const subscription = await prisma.subscription.findUnique({
|
||||||
|
where: { id: subscriptionId },
|
||||||
|
select: { tier: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tier = subscription.tier.toUpperCase();
|
||||||
|
return TIER_LIMITS[tier] ?? 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const propertyWatchlistService = new PropertyWatchlistService();
|
||||||
648
services/hometitle/test/scanner.test.ts
Normal file
648
services/hometitle/test/scanner.test.ts
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||||
|
import { PropertyScannerService } from '../src/scanner.service';
|
||||||
|
import { prisma } from '@shieldai/db';
|
||||||
|
|
||||||
|
vi.mock('@shieldai/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
countyDeedRecord: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('PropertyScannerService', () => {
|
||||||
|
let scanner: PropertyScannerService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
scanner = new PropertyScannerService({
|
||||||
|
attomApiKey: 'test-api-key',
|
||||||
|
uspsApiKey: 'usps-test-key',
|
||||||
|
countyScraperEnabled: true,
|
||||||
|
cacheTTL: 60,
|
||||||
|
rateLimitPoints: 100,
|
||||||
|
rateLimitDuration: 60,
|
||||||
|
retryAttempts: 2,
|
||||||
|
retryDelayMs: 100,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchByAddress', () => {
|
||||||
|
it('should fetch property data from ATTOM API by address', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-123',
|
||||||
|
parcelNumber: '123-456-789',
|
||||||
|
address: {
|
||||||
|
streetAddress: '123 Main St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'John Doe',
|
||||||
|
ownerName2: 'Jane Doe',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 1000000,
|
||||||
|
landValue: 400000,
|
||||||
|
improvementValue: 600000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 12000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1990,
|
||||||
|
squareFeet: 2500,
|
||||||
|
bedrooms: 4,
|
||||||
|
bathrooms: 3,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.parcelNumber).toBe('123-456-789');
|
||||||
|
expect(result?.ownerName).toBe('John Doe');
|
||||||
|
expect(result?.address.city).toBe('San Francisco');
|
||||||
|
expect(result?.dataSource).toBe('attom');
|
||||||
|
expect(result?.assessment?.totalValue).toBe(1000000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null when property not found', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
statusText: 'Not Found',
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.fetchByAddress('999 Nonexistent St');
|
||||||
|
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should combine ATTOM data with county deed records', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-123',
|
||||||
|
parcelNumber: '123-456-789',
|
||||||
|
address: {
|
||||||
|
streetAddress: '123 Main St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'John Doe',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 1000000,
|
||||||
|
landValue: 400000,
|
||||||
|
improvementValue: 600000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 12000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1990,
|
||||||
|
squareFeet: 2500,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 'deed-1',
|
||||||
|
documentId: 'DOC-2024-001',
|
||||||
|
recordingDate: new Date('2024-01-15'),
|
||||||
|
documentType: 'WARRANTY DEED',
|
||||||
|
grantorName: 'Previous Owner LLC',
|
||||||
|
granteeName: 'John Doe',
|
||||||
|
propertyAddress: '123 Main St',
|
||||||
|
parcelNumber: '123-456-789',
|
||||||
|
considerationAmount: 950000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102', 'San Francisco');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.dataSource).toBe('combined');
|
||||||
|
expect(result?.deedHistory?.length).toBe(1);
|
||||||
|
expect(result?.deedHistory?.[0].documentType).toBe('WARRANTY DEED');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle ATTOM API errors with retry', async () => {
|
||||||
|
global.fetch = vi.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
.mockRejectedValueOnce(new Error('Network error'))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-123',
|
||||||
|
parcelNumber: '123-456-789',
|
||||||
|
address: {
|
||||||
|
streetAddress: '123 Main St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'John Doe',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 1000000,
|
||||||
|
landValue: 400000,
|
||||||
|
improvementValue: 600000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 12000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1990,
|
||||||
|
squareFeet: 2500,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.ownerName).toBe('John Doe');
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('fetchByParcel', () => {
|
||||||
|
it('should fetch property data by parcel number', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-456',
|
||||||
|
parcelNumber: '987-654-321',
|
||||||
|
address: {
|
||||||
|
streetAddress: '456 Oak Ave',
|
||||||
|
city: 'Los Angeles',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '90001',
|
||||||
|
latitude: 34.0522,
|
||||||
|
longitude: -118.2437,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'Alice Smith',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 750000,
|
||||||
|
landValue: 300000,
|
||||||
|
improvementValue: 450000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 9000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 2000,
|
||||||
|
squareFeet: 1800,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
const result = await scanner.fetchByParcel('987-654-321', 'Los Angeles');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.parcelNumber).toBe('987-654-321');
|
||||||
|
expect(result?.ownerName).toBe('Alice Smith');
|
||||||
|
expect(result?.address.city).toBe('Los Angeles');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fetch county liens for parcel', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-456',
|
||||||
|
parcelNumber: '987-654-321',
|
||||||
|
address: {
|
||||||
|
streetAddress: '456 Oak Ave',
|
||||||
|
city: 'Los Angeles',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '90001',
|
||||||
|
latitude: 34.0522,
|
||||||
|
longitude: -118.2437,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'Alice Smith',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 750000,
|
||||||
|
landValue: 300000,
|
||||||
|
improvementValue: 450000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 9000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 2000,
|
||||||
|
squareFeet: 1800,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany)
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: 'lien-1',
|
||||||
|
documentId: 'LIEN-2024-001',
|
||||||
|
recordingDate: new Date('2024-02-01'),
|
||||||
|
documentType: 'MECHANIC LIEN',
|
||||||
|
grantorName: 'ABC Construction',
|
||||||
|
granteeName: 'Alice Smith',
|
||||||
|
propertyAddress: '456 Oak Ave',
|
||||||
|
parcelNumber: '987-654-321',
|
||||||
|
considerationAmount: 15000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = await scanner.fetchByParcel('987-654-321', 'Los Angeles');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.liens?.length).toBe(1);
|
||||||
|
expect(result?.liens?.[0].lienType).toBe('MECHANIC LIEN');
|
||||||
|
expect(result?.liens?.[0].lienAmount).toBe(15000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('standardizeAddress', () => {
|
||||||
|
it('should standardize address using USPS API', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
streetNumber: '123',
|
||||||
|
streetName: 'Main',
|
||||||
|
streetSuffix: 'St',
|
||||||
|
unitType: 'Apt',
|
||||||
|
unitValue: '4B',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zip5: '94102',
|
||||||
|
zip4: '1234',
|
||||||
|
deliveryPointBarCode: '941021234123',
|
||||||
|
isDeliverable: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.standardizeAddress('123 main street apt 4b san francisco ca 94102');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.streetNumber).toBe('123');
|
||||||
|
expect(result?.streetName).toBe('Main');
|
||||||
|
expect(result?.city).toBe('San Francisco');
|
||||||
|
expect(result?.isDeliverable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return null for undeliverable address', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
streetNumber: '999',
|
||||||
|
streetName: 'Nonexistent',
|
||||||
|
streetSuffix: 'St',
|
||||||
|
city: 'Nowhere',
|
||||||
|
state: 'XX',
|
||||||
|
zip5: '00000',
|
||||||
|
isDeliverable: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await scanner.standardizeAddress('999 nonexistent st nowhere xx 00000');
|
||||||
|
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result?.isDeliverable).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('batchFetchProperties', () => {
|
||||||
|
it('should fetch multiple properties in batch', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-1',
|
||||||
|
parcelNumber: '111-222-333',
|
||||||
|
address: {
|
||||||
|
streetAddress: '111 First St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'Owner One',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 500000,
|
||||||
|
landValue: 200000,
|
||||||
|
improvementValue: 300000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 6000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1985,
|
||||||
|
squareFeet: 1500,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-2',
|
||||||
|
parcelNumber: '444-555-666',
|
||||||
|
address: {
|
||||||
|
streetAddress: '222 Second Ave',
|
||||||
|
city: 'Los Angeles',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '90001',
|
||||||
|
latitude: 34.0522,
|
||||||
|
longitude: -118.2437,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'Owner Two',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 800000,
|
||||||
|
landValue: 350000,
|
||||||
|
improvementValue: 450000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 9600,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1995,
|
||||||
|
squareFeet: 2000,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
{ address: '111 First St, San Francisco, CA 94102' },
|
||||||
|
{ address: '222 Second Ave, Los Angeles, CA 90001' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await scanner.batchFetchProperties(requests);
|
||||||
|
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
expect(results[0]).not.toBeNull();
|
||||||
|
expect(results[1]).not.toBeNull();
|
||||||
|
expect(results[0]?.ownerName).toBe('Owner One');
|
||||||
|
expect(results[1]?.ownerName).toBe('Owner Two');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle batch with mixed valid and invalid requests', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-1',
|
||||||
|
parcelNumber: '111-222-333',
|
||||||
|
address: {
|
||||||
|
streetAddress: '111 First St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'Owner One',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 500000,
|
||||||
|
landValue: 200000,
|
||||||
|
improvementValue: 300000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 6000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1985,
|
||||||
|
squareFeet: 1500,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).mockResolvedValueOnce({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const requests = [
|
||||||
|
{ address: '111 First St, San Francisco, CA 94102' },
|
||||||
|
{ address: '999 Invalid Address' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const results = await scanner.batchFetchProperties(requests);
|
||||||
|
|
||||||
|
expect(results.length).toBe(2);
|
||||||
|
expect(results[0]).not.toBeNull();
|
||||||
|
expect(results[1]).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cache behavior', () => {
|
||||||
|
it('should use cached results for repeated requests', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-123',
|
||||||
|
parcelNumber: '123-456-789',
|
||||||
|
address: {
|
||||||
|
streetAddress: '123 Main St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'John Doe',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 1000000,
|
||||||
|
landValue: 400000,
|
||||||
|
improvementValue: 600000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 12000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1990,
|
||||||
|
squareFeet: 2500,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValueOnce([]);
|
||||||
|
|
||||||
|
await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102');
|
||||||
|
await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should invalidate cache when requested', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-123',
|
||||||
|
parcelNumber: '123-456-789',
|
||||||
|
address: {
|
||||||
|
streetAddress: '123 Main St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'John Doe',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 1000000,
|
||||||
|
landValue: 400000,
|
||||||
|
improvementValue: 600000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 12000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1990,
|
||||||
|
squareFeet: 2500,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102');
|
||||||
|
await scanner.invalidateCache('123 Main St, San Francisco, CA 94102');
|
||||||
|
await scanner.fetchByAddress('123 Main St, San Francisco, CA 94102');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('rate limiting', () => {
|
||||||
|
it('should handle rate limit errors gracefully', async () => {
|
||||||
|
global.fetch = vi.fn().mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
data: {
|
||||||
|
propertyId: 'prop-123',
|
||||||
|
parcelNumber: '123-456-789',
|
||||||
|
address: {
|
||||||
|
streetAddress: '123 Main St',
|
||||||
|
city: 'San Francisco',
|
||||||
|
state: 'CA',
|
||||||
|
zipCode: '94102',
|
||||||
|
latitude: 37.7749,
|
||||||
|
longitude: -122.4194,
|
||||||
|
},
|
||||||
|
owner: {
|
||||||
|
ownerName1: 'John Doe',
|
||||||
|
},
|
||||||
|
assessment: {
|
||||||
|
totalValue: 1000000,
|
||||||
|
landValue: 400000,
|
||||||
|
improvementValue: 600000,
|
||||||
|
taxYear: 2024,
|
||||||
|
taxAmount: 12000,
|
||||||
|
},
|
||||||
|
propertyDetails: {
|
||||||
|
propertyType: 'residential',
|
||||||
|
yearBuilt: 1990,
|
||||||
|
squareFeet: 2500,
|
||||||
|
},
|
||||||
|
salesHistory: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mocked(prisma.countyDeedRecord.findMany).mockResolvedValue([]);
|
||||||
|
|
||||||
|
const scannerWithStrictLimit = new PropertyScannerService({
|
||||||
|
attomApiKey: 'test-api-key',
|
||||||
|
countyScraperEnabled: false,
|
||||||
|
rateLimitPoints: 1,
|
||||||
|
rateLimitDuration: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
await scannerWithStrictLimit.fetchByAddress('123 Main St, San Francisco, CA 94102');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
scannerWithStrictLimit.fetchByAddress('456 Oak Ave, San Francisco, CA 94102')
|
||||||
|
).rejects.toThrow('Rate limit exceeded');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,7 +2,10 @@
|
|||||||
"extends": "../../tsconfig.json",
|
"extends": "../../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"rootDir": "./src"
|
"rootDir": "./src",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "Bundler"
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": ["src/**/*.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user