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