import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { HomeTitleSchedulerService } from '../src/scheduler.service'; import { PropertySnapshot } from '../src/types'; // All mocks inside vi.hoisted() to avoid vitest hoisting issues const mocked = vi.hoisted(() => { const mockPrisma = { subscription: { findMany: vi.fn() }, $queryRaw: vi.fn(), }; const mockProcessChangeDetection = vi.fn(); const mockDetectChanges = vi.fn(); const mockShouldTriggerAlert = vi.fn(); return { mockPrisma, mockProcessChangeDetection, mockDetectChanges, mockShouldTriggerAlert, }; }); vi.mock('@shieldai/db', () => ({ prisma: mocked.mockPrisma, })); vi.mock('../src/alert.pipeline', () => ({ homeTitleAlertPipeline: { processChangeDetection: mocked.mockProcessChangeDetection, }, HomeTitleAlertPipeline: class {}, })); vi.mock('../src/change-detector', () => ({ detectChanges: mocked.mockDetectChanges, shouldTriggerAlert: mocked.mockShouldTriggerAlert, })); vi.mock('uuid', () => ({ v4: () => 'scan-uuid-' + Date.now(), })); const mockSubscription = { id: 'sub-001', userId: 'user-001', tier: 'premium' as const, }; function mockLatestSnapshots(snapshots: PropertySnapshot[]) { mocked.mockPrisma.$queryRaw.mockResolvedValue( snapshots.map(s => ({ id: s.id, propertyId: s.propertyId, capturedAt: s.capturedAt, ownerName: s.ownerName, address: JSON.stringify(s.address), deedDate: s.deedDate ?? null, taxId: s.taxId ?? null, propertyType: s.propertyType, taxAmount: s.taxAmount ?? null, lienCount: s.lienCount ?? null, })) ); } function mockPreviousSnapshot(snapshot: PropertySnapshot | null) { if (!snapshot) { mocked.mockPrisma.$queryRaw.mockResolvedValue([]); } else { mocked.mockPrisma.$queryRaw.mockResolvedValue([ { id: snapshot.id, propertyId: snapshot.propertyId, capturedAt: snapshot.capturedAt, ownerName: snapshot.ownerName, address: JSON.stringify(snapshot.address), deedDate: snapshot.deedDate ?? null, taxId: snapshot.taxId ?? null, propertyType: snapshot.propertyType, taxAmount: snapshot.taxAmount ?? null, lienCount: snapshot.lienCount ?? null, }, ]); } } describe('HomeTitleSchedulerService', () => { let scheduler: HomeTitleSchedulerService; beforeEach(() => { vi.useFakeTimers(); vi.clearAllMocks(); mocked.mockProcessChangeDetection.mockReset(); mocked.mockDetectChanges.mockReset(); mocked.mockShouldTriggerAlert.mockReset(); scheduler = new HomeTitleSchedulerService({ scanIntervalMinutes: 60, maxPropertiesPerScan: 100, enabled: true, }); }); afterEach(() => { scheduler.stop(); vi.useRealTimers(); }); describe('constructor and config', () => { it('uses default config when none provided', () => { const defaultScheduler = new HomeTitleSchedulerService(); const config = defaultScheduler.getConfig(); expect(config.scanIntervalMinutes).toBe(60); expect(config.maxPropertiesPerScan).toBe(100); expect(config.enabled).toBe(true); defaultScheduler.stop(); }); it('accepts custom config', () => { scheduler = new HomeTitleSchedulerService({ scanIntervalMinutes: 30 }); const config = scheduler.getConfig(); expect(config.scanIntervalMinutes).toBe(30); }); it('updates config dynamically', () => { scheduler.updateConfig({ scanIntervalMinutes: 15 }); const config = scheduler.getConfig(); expect(config.scanIntervalMinutes).toBe(15); }); }); describe('start/stop', () => { it('starts the scheduler', () => { scheduler.start(); expect(scheduler.isRunning()).toBe(true); }); it('stops the scheduler', () => { scheduler.start(); scheduler.stop(); expect(scheduler.isRunning()).toBe(false); }); it('does not start when disabled', () => { scheduler = new HomeTitleSchedulerService({ enabled: false }); scheduler.start(); expect(scheduler.isRunning()).toBe(false); }); }); describe('runScan', () => { it('returns empty results when no subscriptions', async () => { mocked.mockPrisma.subscription.findMany.mockResolvedValue([]); const result = await scheduler.runScan(); expect(result.propertiesScanned).toBe(0); expect(result.changesDetected).toBe(0); expect(result.alertsCreated).toBe(0); expect(result.notificationsSent).toBe(0); expect(result.errors).toEqual([]); }); it('detects changes and creates alerts', async () => { const previousSnapshot: PropertySnapshot = { 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', }; const currentSnapshot: PropertySnapshot = { ...previousSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', }; mocked.mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]); mockLatestSnapshots([currentSnapshot]); mockPreviousSnapshot(previousSnapshot); mocked.mockDetectChanges.mockReturnValue({ propertyId: 'prop-001', changeType: 'ownership_transfer', severity: 'major', confidence: 0.95, changes: [], previousSnapshot, currentSnapshot, detectedAt: new Date().toISOString(), }); mocked.mockShouldTriggerAlert.mockReturnValue(true); mocked.mockProcessChangeDetection.mockResolvedValue({ id: 'alert-001', propertyId: 'prop-001', subscriptionId: 'sub-001', userId: 'user-001', changeType: 'ownership_transfer', severity: 'critical', title: '[MAJOR] Ownership Transfer detected', message: 'Change detected', changeDetectionResult: {} as any, channel: ['email', 'push', 'sms'], dedupKey: 'hometitle:user-001:prop-001:ownership_transfer', createdAt: new Date().toISOString(), }); const result = await scheduler.runScan(); expect(result.changesDetected).toBe(1); expect(result.alertsCreated).toBe(1); expect(result.notificationsSent).toBe(1); }); it('skips snapshots without previous', async () => { mocked.mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]); mockLatestSnapshots([{ 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', }]); mockPreviousSnapshot(null); const result = await scheduler.runScan(); expect(result.changesDetected).toBe(0); }); it('handles subscription scan errors gracefully', async () => { mocked.mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]); mockLatestSnapshots([]); mockPreviousSnapshot(null); mocked.mockDetectChanges.mockReturnValue({ propertyId: 'prop-001', changeType: 'metadata_change', severity: 'minor', confidence: 0.5, changes: [], previousSnapshot: {} as any, currentSnapshot: {} as any, detectedAt: new Date().toISOString(), }); mocked.mockShouldTriggerAlert.mockReturnValue(false); const result = await scheduler.runScan(); expect(result.errors).toEqual([]); expect(result.propertiesScanned).toBe(0); }); it('tracks scan metadata', async () => { mocked.mockPrisma.subscription.findMany.mockResolvedValue([]); const result = await scheduler.runScan(); expect(result.scanId).toBeDefined(); expect(result.startedAt).toBeDefined(); expect(result.completedAt).toBeDefined(); // completedAt should be after startedAt expect(new Date(result.completedAt).getTime()).toBeGreaterThanOrEqual( new Date(result.startedAt).getTime() ); }); it('does not send notifications for non-premium tier', async () => { const previousSnapshot: PropertySnapshot = { 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', }; const currentSnapshot: PropertySnapshot = { ...previousSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z', ownerName: 'Jane Smith', }; const nonPremiumSub = { ...mockSubscription, tier: 'plus' as const }; mocked.mockPrisma.subscription.findMany.mockResolvedValue([nonPremiumSub]); mockLatestSnapshots([currentSnapshot]); mockPreviousSnapshot(previousSnapshot); mocked.mockDetectChanges.mockReturnValue({ propertyId: 'prop-001', changeType: 'ownership_transfer', severity: 'major', confidence: 0.95, changes: [], previousSnapshot, currentSnapshot, detectedAt: new Date().toISOString(), }); mocked.mockShouldTriggerAlert.mockReturnValue(true); mocked.mockProcessChangeDetection.mockResolvedValue({ id: 'alert-002', propertyId: 'prop-001', subscriptionId: 'sub-001', userId: 'user-001', changeType: 'ownership_transfer', severity: 'critical', title: '[MAJOR] Ownership Transfer detected', message: 'Change detected', changeDetectionResult: {} as any, channel: ['email', 'push'], dedupKey: 'hometitle:user-001:prop-001:ownership_transfer', createdAt: new Date().toISOString(), }); const result = await scheduler.runScan(); expect(result.changesDetected).toBe(1); expect(result.alertsCreated).toBe(1); expect(result.notificationsSent).toBe(0); }); }); describe('getLastScanResult', () => { it('returns null before first scan', () => { expect(scheduler.getLastScanResult()).toBeNull(); }); it('returns last scan result after scan', async () => { mocked.mockPrisma.subscription.findMany.mockResolvedValue([]); scheduler.start(); await vi.advanceTimersByTimeAsync(60 * 60 * 1000); expect(scheduler.getLastScanResult()).not.toBeNull(); }); }); });