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/"
|
||||
},
|
||||
"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": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@types/uuid": "^10.0.0"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
@@ -41,3 +41,17 @@ export type {
|
||||
|
||||
export { HomeTitleAlertPipeline, homeTitleAlertPipeline } from './alert.pipeline';
|
||||
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 {
|
||||
id: string;
|
||||
address: {
|
||||
streetAddress: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zipCode: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
};
|
||||
parcelNumber: 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;
|
||||
taxId?: string;
|
||||
propertyType: PropertyType;
|
||||
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 {
|
||||
streetNumber: 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",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
"rootDir": "./src",
|
||||
"skipLibCheck": true,
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "Bundler"
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user