- 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>
649 lines
19 KiB
TypeScript
649 lines
19 KiB
TypeScript
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');
|
|
});
|
|
});
|
|
});
|