import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { HomeTitleAlertPipeline } from '../src/alert.pipeline'; import { ChangeDetectionResult, PropertySnapshot, ChangeType, Severity, } from '../src/types'; // All mocks inside vi.hoisted() to avoid vitest hoisting issues const mockedDb = vi.hoisted(() => { const mocks = { subscription: { findUnique: 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', () => { const mocks = vi.hoisted ? mockedDb : { subscription: { findUnique: 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 { prisma: mocks, 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 MockNotificationService { send = mockSend; static getInstance() { return new MockNotificationService(); } } return { NotificationService: MockNotificationService, loadNotificationConfig: () => ({ apiKey: 'test-key', baseUrl: 'http://localhost:3000' }), }; }); function buildChangeResult(overrides: Partial = {}): ChangeDetectionResult { return { propertyId: 'prop-001', changeType: 'ownership_transfer' as ChangeType, severity: 'critical' as Severity, confidence: 0.95, changes: [ { field: 'ownerName', oldValue: 'John Doe', newValue: 'Jane Smith', changeType: 'ownership_transfer' as ChangeType }, ], previousSnapshot: { id: 'snap-1', propertyId: 'prop-001', capturedAt: '2026-01-01T00:00:00Z', ownerName: 'John Doe', address: { streetNumber: '123', streetName: 'main', city: 'springfield', state: 'IL', zip: '62701' }, propertyType: 'residential', } as PropertySnapshot, currentSnapshot: { id: 'snap-2', propertyId: 'prop-001', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', address: { streetNumber: '123', streetName: 'main', city: 'springfield', state: 'IL', zip: '62701' }, propertyType: 'residential', } as PropertySnapshot, detectedAt: '2026-05-14T12:00:00Z', ...overrides, }; } describe('HomeTitleAlertPipeline', () => { let pipeline: HomeTitleAlertPipeline; beforeEach(() => { vi.useFakeTimers(); mockedDb.subscription.findUnique.mockClear(); mockedDb.alert.findFirst.mockClear(); mockedDb.alert.create.mockClear(); mockedDb.normalizedAlert.create.mockClear(); mockedDb.normalizedAlert.updateMany.mockClear(); mockedDb.correlationGroup.create.mockClear(); mockedDb.user.findUnique.mockClear(); pipeline = new HomeTitleAlertPipeline(); }); afterEach(() => { vi.useRealTimers(); }); describe('processChangeDetection', () => { it('creates alert for critical severity change', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-001', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[CRITICAL] Ownership Transfer detected', message: 'Change detected', severity: 'CRITICAL', channel: ['email', 'push', 'sms'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const result = buildChangeResult({ changeType: 'ownership_transfer', severity: 'critical', confidence: 0.95, }); const alert = await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(alert).toBeDefined(); expect(alert?.changeType).toBe('ownership_transfer'); expect(alert?.severity).toBe('critical'); expect(mockedDb.alert.create).toHaveBeenCalled(); }); it('creates alert for warning severity change', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'plus' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-002', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[WARNING] Deed Change detected', message: 'Change detected', severity: 'WARNING', channel: ['email', 'push'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const result = buildChangeResult({ changeType: 'deed_change', severity: 'warning', confidence: 0.85, }); const alert = await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(alert).toBeDefined(); expect(alert?.changeType).toBe('deed_change'); expect(alert?.severity).toBe('warning'); }); it('returns null when subscription not found', async () => { mockedDb.subscription.findUnique.mockResolvedValue(null); const result = buildChangeResult(); const alert = await pipeline.processChangeDetection(result, 'sub-999', 'user-001'); expect(alert).toBeNull(); }); it('returns null for minor severity with default minSeverity', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); const result = buildChangeResult({ changeType: 'tax_change', severity: 'minor', confidence: 0.85, }); const alert = await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(alert).toBeNull(); }); it('returns null when confidence below threshold', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); const result = buildChangeResult({ confidence: 0.5, }); const alert = await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(alert).toBeNull(); }); it('deduplicates alerts within 24h window', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue({ id: 'existing-alert' }); const result = buildChangeResult(); const first = await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); const second = await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(first).toBeDefined(); expect(second).toBeNull(); }); it('creates normalized alert for integration with correlation engine', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-003', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[MAJOR] Ownership Transfer detected', message: 'Change detected', severity: 'CRITICAL', channel: ['email', 'push', 'sms'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const result = buildChangeResult(); await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(mockedDb.normalizedAlert.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ source: 'DARKWATCH', userId: 'user-001', }), }) ); }); it('dispatches notifications for premium tier', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-004', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[MAJOR] Ownership Transfer detected', message: 'Change detected on property prop-001.\n\nChanges:\n- ownerName: John Doe → Jane Smith', severity: 'CRITICAL', channel: ['email', 'push', 'sms'], createdAt: new Date('2026-05-14T12:00:00Z'), }); mockedDb.user.findUnique.mockResolvedValue({ email: 'test@example.com', name: 'Test User', }); const result = buildChangeResult(); await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); // Notification service was instantiated (no error thrown) expect(mockedDb.user.findUnique).toHaveBeenCalled(); }); it('records dedup key in memory', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-005', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[MAJOR] Ownership Transfer detected', message: 'Change', severity: 'CRITICAL', channel: ['email'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const result = buildChangeResult({ changeType: 'ownership_transfer' }); await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); // No expired dedups at this point const cleanupCount = pipeline.cleanupExpiredDedups(); expect(cleanupCount).toBe(0); }); }); describe('processBatch', () => { it('processes multiple change results', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-batch-1', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[CRITICAL] Ownership Transfer detected', message: 'Change 1', severity: 'CRITICAL', channel: ['email'], createdAt: new Date('2026-05-14T12:00:00Z'), }); mockedDb.user.findUnique.mockResolvedValue({ email: 'test@example.com', name: 'Test' }); mockedDb.correlationGroup.create.mockResolvedValue({ id: 'group-001' }); const results = [ buildChangeResult({ changeType: 'ownership_transfer', propertyId: 'prop-001' }), buildChangeResult({ changeType: 'deed_change', propertyId: 'prop-002' }), ]; const alerts = await pipeline.processBatch(results, 'sub-001', 'user-001'); expect(alerts.length).toBe(2); expect(mockedDb.alert.create).toHaveBeenCalledTimes(results.length); }); it('creates correlation group for multiple alerts', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-batch-' + Date.now(), subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[CRITICAL] Ownership Transfer detected', message: 'Change', severity: 'CRITICAL', channel: ['email'], createdAt: new Date('2026-05-14T12:00:00Z'), }); mockedDb.user.findUnique.mockResolvedValue({ email: 'test@example.com', name: 'Test' }); mockedDb.correlationGroup.create.mockResolvedValue({ id: 'group-002' }); const results = [ buildChangeResult({ changeType: 'ownership_transfer', propertyId: 'prop-001' }), buildChangeResult({ changeType: 'deed_change', propertyId: 'prop-002' }), ]; await pipeline.processBatch(results, 'sub-001', 'user-001'); expect(mockedDb.correlationGroup.create).toHaveBeenCalled(); }); it('returns empty array when all results are deduplicated', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue({ id: 'existing-alert' }); const results = [ buildChangeResult({ changeType: 'ownership_transfer', propertyId: 'prop-001' }), ]; const alerts = await pipeline.processBatch(results, 'sub-001', 'user-001'); expect(alerts).toEqual([]); }); }); describe('cleanupExpiredDedups', () => { it('removes expired dedup entries', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-cleanup', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[CRITICAL] Ownership Transfer detected', message: 'Change', severity: 'CRITICAL', channel: ['email'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const result = buildChangeResult(); await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); // Advance timer past the dedup window (24 hours) vi.advanceTimersByTime(25 * 60 * 60 * 1000); const cleaned = pipeline.cleanupExpiredDedups(); expect(cleaned).toBe(1); }); }); describe('severity mapping', () => { it('maps critical to critical', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-sev-1', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[CRITICAL] Ownership Transfer detected', message: 'Change', severity: 'CRITICAL', channel: ['email'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const result = buildChangeResult({ severity: 'critical' }); await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(mockedDb.alert.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ severity: 'critical' }) }) ); }); it('maps warning to warning', async () => { mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' }); mockedDb.alert.findFirst.mockResolvedValue(null); mockedDb.alert.create.mockResolvedValue({ id: 'alert-sev-2', subscriptionId: 'sub-001', userId: 'user-001', type: 'system_warning', title: '[WARNING] Deed Change detected', message: 'Change', severity: 'WARNING', channel: ['email'], createdAt: new Date('2026-05-14T12:00:00Z'), }); const result = buildChangeResult({ severity: 'warning', changeType: 'deed_change' }); await pipeline.processChangeDetection(result, 'sub-001', 'user-001'); expect(mockedDb.alert.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ severity: 'warning' }) }) ); }); }); });