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