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:
2026-05-16 09:50:21 -04:00
parent 24c31f1b1b
commit d6f574ff8e
7 changed files with 1350 additions and 5 deletions

View File

@@ -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"

View File

@@ -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';

View 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();

View File

@@ -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;

View 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();

View 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');
});
});
});

View File

@@ -2,7 +2,10 @@
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
"rootDir": "./src",
"skipLibCheck": true,
"module": "ES2022",
"moduleResolution": "Bundler"
},
"include": ["src/**/*.ts"]
}