import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { propertyWatchlistService, normalizeAddressValue, hashAddressValue, } from '../src/watchlist.service'; import { HomeTitleAlertPipeline } from '../src/alert.pipeline'; import { detectChanges } from '../src/change-detector'; import { PropertySnapshot } from '../src/types'; const mockedDb = vi.hoisted(() => { const mocks = { subscription: { findUnique: vi.fn(), count: vi.fn(), }, propertyWatchlistItem: { count: vi.fn(), findFirst: vi.fn(), create: vi.fn(), findMany: vi.fn(), update: vi.fn(), }, alert: { create: vi.fn(), findFirst: vi.fn(), }, normalizedAlert: { create: vi.fn(), updateMany: vi.fn(), }, correlationGroup: { create: vi.fn(), }, user: { findUnique: vi.fn(), }, }; return mocks; }); vi.mock('@shieldai/db', () => ({ prisma: { subscription: mockedDb.subscription, propertyWatchlistItem: mockedDb.propertyWatchlistItem, alert: mockedDb.alert, normalizedAlert: mockedDb.normalizedAlert, correlationGroup: mockedDb.correlationGroup, user: mockedDb.user, }, AlertSeverity: { INFO: 'INFO', WARNING: 'WARNING', CRITICAL: 'CRITICAL' }, AlertChannel: { EMAIL: 'email', PUSH: 'push', SMS: 'sms' }, })); vi.mock('@shieldai/shared-notifications', () => { const mockSend = vi.fn().mockResolvedValue({ notificationId: 'mock-notif', status: 'sent' }); class MockNS { send = mockSend; static getInstance() { return new MockNS(); } } return { NotificationService: MockNS, loadNotificationConfig: () => ({ apiKey: 'test', baseUrl: 'http://localhost' }), }; }); const PREMIUM_SUB = { id: 'sub-premium', tier: 'premium' as const }; const PLUS_SUB = { id: 'sub-plus', tier: 'plus' as const }; const BASIC_SUB = { id: 'sub-basic', tier: 'basic' as const }; function makeSnapshot(overrides: Partial = {}): PropertySnapshot { return { id: 'snap-1', propertyId: 'prop-001', capturedAt: '2026-01-01T00:00:00Z', ownerName: 'John Doe', address: { streetNumber: '123', streetName: 'main', streetType: 'st', city: 'springfield', state: 'IL', zip: '62701', }, propertyType: 'residential', ...overrides, }; } describe('PropertyWatchlistService', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); describe('addItem', () => { it('creates a new watchlist item', async () => { mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(0); mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null); mockedDb.propertyWatchlistItem.create.mockResolvedValue({ id: 'pw-1', subscriptionId: 'sub-premium', address: '123 main st', parcelId: null, ownerName: null, streetAddress: '123 main st', city: '', state: '', zipCode: '', latitude: null, longitude: null, isActive: true, createdAt: new Date(), updatedAt: new Date(), }); const item = await propertyWatchlistService.addItem( 'sub-premium', '123 Main St', 'parcel-001', 'John Doe', ); expect(item.address).toBe('123 main st'); expect(mockedDb.propertyWatchlistItem.create).toHaveBeenCalled(); }); it('enforces BASIC tier limit of 3', async () => { mockedDb.subscription.findUnique.mockResolvedValue(BASIC_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(3); await expect( propertyWatchlistService.addItem('sub-basic', '456 Oak Ave', 'parcel-002') ).rejects.toThrow(/limit reached/); }); it('enforces PLUS tier limit of 5', async () => { mockedDb.subscription.findUnique.mockResolvedValue(PLUS_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(5); await expect( propertyWatchlistService.addItem('sub-plus', '789 Elm Blvd', 'parcel-003') ).rejects.toThrow(/limit reached/); }); it('allows up to 50 for PREMIUM tier', async () => { mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(49); mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null); mockedDb.propertyWatchlistItem.create.mockResolvedValue({ id: 'pw-50', subscriptionId: 'sub-premium', address: '50th property', parcelId: 'parcel-050', ownerName: null, streetAddress: '50th property', city: '', state: '', zipCode: '', latitude: null, longitude: null, isActive: true, createdAt: new Date(), updatedAt: new Date(), }); const item = await propertyWatchlistService.addItem( 'sub-premium', '50th Property', 'parcel-050', ); expect(item).toBeDefined(); expect(item.address).toBe('50th property'); }); it('deduplicates by normalized address', async () => { const existingItem = { id: 'pw-existing', subscriptionId: 'sub-premium', address: '123 main st', isActive: true, }; mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(1); mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(existingItem); const result = await propertyWatchlistService.addItem( 'sub-premium', '123 Main St', 'parcel-001', ); expect(result.id).toBe('pw-existing'); expect(mockedDb.propertyWatchlistItem.create).not.toHaveBeenCalled(); }); it('reactivates a deactivated item', async () => { const deactivatedItem = { id: 'pw-deactivated', subscriptionId: 'sub-premium', address: '123 main st', isActive: false, }; mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(1); mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(deactivatedItem); mockedDb.propertyWatchlistItem.update.mockResolvedValue({ ...deactivatedItem, isActive: true, }); const result = await propertyWatchlistService.addItem( 'sub-premium', '123 Main St', ); expect(result.isActive).toBe(true); expect(mockedDb.propertyWatchlistItem.update).toHaveBeenCalled(); }); it('throws on invalid subscription', async () => { mockedDb.subscription.findUnique.mockResolvedValue(null); await expect( propertyWatchlistService.addItem('sub-invalid', '123 Main St') ).rejects.toThrow(/not found/); }); }); describe('getItems', () => { it('returns active items for subscription', async () => { const items = [ { id: 'pw-1', subscriptionId: 'sub-premium', address: '123 main st', isActive: true, createdAt: new Date('2026-01-01'), updatedAt: new Date('2026-01-01'), }, { id: 'pw-2', subscriptionId: 'sub-premium', address: '456 oak ave', isActive: true, createdAt: new Date('2026-02-01'), updatedAt: new Date('2026-02-01'), }, ]; mockedDb.propertyWatchlistItem.findMany.mockResolvedValue(items); const result = await propertyWatchlistService.getItems('sub-premium'); expect(result).toHaveLength(2); expect(mockedDb.propertyWatchlistItem.findMany).toHaveBeenCalledWith({ where: { subscriptionId: 'sub-premium', isActive: true }, orderBy: { createdAt: 'desc' }, }); }); }); describe('removeItem', () => { it('deactivates an item', async () => { mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue({ id: 'pw-1', subscriptionId: 'sub-premium', address: '123 main st', isActive: true, }); mockedDb.propertyWatchlistItem.update.mockResolvedValue({ id: 'pw-1', subscriptionId: 'sub-premium', address: '123 main st', isActive: false, }); const result = await propertyWatchlistService.removeItem('pw-1', 'sub-premium'); expect(result.isActive).toBe(false); }); it('throws on missing item', async () => { mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null); await expect( propertyWatchlistService.removeItem('pw-missing', 'sub-premium') ).rejects.toThrow(/not found/); }); }); describe('getActiveItemsForScan', () => { it('returns items with latest snapshot', async () => { mockedDb.propertyWatchlistItem.findMany.mockResolvedValue([ { id: 'pw-1', subscriptionId: 'sub-premium', address: '123 main st', isActive: true, snapshots: [{ id: 'snap-1', capturedAt: new Date('2026-01-01') }], }, ]); const result = await propertyWatchlistService.getActiveItemsForScan('sub-premium'); expect(result).toHaveLength(1); expect(result[0].snapshots).toHaveLength(1); }); }); describe('max items for tier', () => { it('returns correct limits per tier', async () => { mockedDb.subscription.findUnique.mockResolvedValue(BASIC_SUB); expect(await propertyWatchlistService.getMaxItemsForTier('sub-basic')).toBe(3); mockedDb.subscription.findUnique.mockResolvedValue(PLUS_SUB); expect(await propertyWatchlistService.getMaxItemsForTier('sub-plus')).toBe(5); mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB); expect(await propertyWatchlistService.getMaxItemsForTier('sub-premium')).toBe(50); }); it('returns 3 for unknown subscription', async () => { mockedDb.subscription.findUnique.mockResolvedValue(null); expect(await propertyWatchlistService.getMaxItemsForTier('sub-unknown')).toBe(3); }); }); describe('normalizeAddressValue', () => { it('lowercases and trims', () => { expect(normalizeAddressValue(' 123 Main St ')).toBe('123 main st'); }); it('collapses multiple spaces', () => { expect(normalizeAddressValue('123 Main St')).toBe('123 main st'); }); }); describe('hashAddressValue', () => { it('produces consistent sha256 hash', () => { const hash1 = hashAddressValue('123 main st'); const hash2 = hashAddressValue('123 main st'); expect(hash1).toBe(hash2); expect(hash1).toHaveLength(64); }); it('different addresses produce different hashes', () => { const hash1 = hashAddressValue('123 main st'); const hash2 = hashAddressValue('456 oak ave'); expect(hash1).not.toBe(hash2); }); }); }); describe('Integration: Full Pipeline', () => { beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); }); afterEach(() => { vi.useRealTimers(); }); it('happy path: add property -> detect change -> create alert', async () => { // Setup: add property to watchlist mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(0); mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null); mockedDb.propertyWatchlistItem.create.mockResolvedValue({ id: 'pw-1', subscriptionId: 'sub-premium', address: '123 main st', parcelId: 'parcel-001', ownerName: 'John Doe', streetAddress: '123 main st', city: '', state: '', zipCode: '', isActive: true, createdAt: new Date(), updatedAt: new Date(), }); const item = await propertyWatchlistService.addItem( 'sub-premium', '123 Main St', 'parcel-001', 'John Doe', ); expect(item.address).toBe('123 main st'); // Detect change: ownership transfer const previous: PropertySnapshot = makeSnapshot({ ownerName: 'John Doe', capturedAt: '2026-01-01T00:00:00Z', }); const current: PropertySnapshot = makeSnapshot({ id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', }); const changeResult = detectChanges(previous, current); expect(changeResult.changeType).toBe('ownership_transfer'); expect(changeResult.severity).toBe('critical'); expect(changeResult.confidence).toBeGreaterThan(0.9); // Pipeline processes the change mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-001', subscriptionId: 'sub-premium', userId: 'user-001', type: 'system_warning', title: '[CRITICAL] Ownership Transfer detected', message: 'Change detected', severity: 'CRITICAL' as any, channel: ['email', 'push', 'sms'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const pipeline = new HomeTitleAlertPipeline(); const alert = await pipeline.processChangeDetection( changeResult, 'sub-premium', 'user-001', ); expect(alert).toBeDefined(); expect(alert?.changeType).toBe('ownership_transfer'); expect(alert?.severity).toBe('critical'); expect(mockedDb.alert.create).toHaveBeenCalled(); expect(mockedDb.normalizedAlert.create).toHaveBeenCalled(); }); it('tier gating: premium gets more watchlist items than plus', async () => { // Plus tier: 5 items max mockedDb.subscription.findUnique.mockResolvedValue(PLUS_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(5); await expect( propertyWatchlistService.addItem('sub-plus', '50th property') ).rejects.toThrow(/limit reached/); // Premium tier: 50 items max mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB); mockedDb.propertyWatchlistItem.count.mockResolvedValue(49); mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null); mockedDb.propertyWatchlistItem.create.mockResolvedValue({ id: 'pw-50', subscriptionId: 'sub-premium', address: '50th property', parcelId: 'parcel-050', ownerName: null, streetAddress: '50th property', city: '', state: '', zipCode: '', isActive: true, createdAt: new Date(), updatedAt: new Date(), }); const item = await propertyWatchlistService.addItem( 'sub-premium', '50th Property', 'parcel-050', ); expect(item).toBeDefined(); }); it('fuzzy matching: similar names are detected as matches', async () => { const { matchRecords } = await import('../src/matcher.service'); const addr = { streetNumber: '123', streetName: 'main', streetType: 'st', city: 'springfield', state: 'IL', zip: '62701', }; // Slight typo in name const result = matchRecords( 'John Doe', addr, 'Jhon Doe', addr, ); expect(result.isMatch).toBe(true); expect(result.nameScore).toBeGreaterThan(0.7); }); it('fuzzy matching: completely different names don\'t match', async () => { const { matchRecords } = await import('../src/matcher.service'); const addr = { streetNumber: '123', streetName: 'main', streetType: 'st', city: 'springfield', state: 'IL', zip: '62701', }; const result = matchRecords( 'John Doe', addr, 'Robert Williams', addr, ); expect(result.isMatch).toBe(false); expect(result.nameScore).toBeLessThan(0.5); }); it('change detection: tax change is minor severity', async () => { const previous: PropertySnapshot = makeSnapshot({ taxAmount: 2500, }); const current: PropertySnapshot = makeSnapshot({ id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', taxAmount: 3500, }); const result = detectChanges(previous, current); expect(result.changes.some(c => c.changeType === 'tax_change')).toBe(true); expect(result.severity).toBe('info'); }); it('change detection: lien filing is moderate severity', async () => { const previous: PropertySnapshot = makeSnapshot({ lienCount: 0, }); const current: PropertySnapshot = makeSnapshot({ id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', lienCount: 2, }); const result = detectChanges(previous, current); expect(result.changes.some(c => c.changeType === 'lien_filing')).toBe(true); expect(result.severity).toBe('warning'); }); });