Files
ShieldAI/services/hometitle/test/scheduler.service.test.ts

330 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { HomeTitleSchedulerService } from '../src/scheduler.service';
import { PropertySnapshot } from '../src/types';
// Mock @shieldai/db
const mockPrisma = {
subscription: {
findMany: vi.fn(),
},
$queryRaw: vi.fn(),
};
vi.mock('@shieldai/db', () => ({
prisma: mockPrisma,
}));
// Mock alert pipeline
const mockProcessChangeDetection = vi.fn();
const mockHomeTitleAlertPipeline = {
processChangeDetection: mockProcessChangeDetection,
};
vi.mock('../src/alert.pipeline', () => ({
homeTitleAlertPipeline: mockHomeTitleAlertPipeline,
HomeTitleAlertPipeline: class {},
}));
// Mock change-detector
const mockDetectChanges = vi.fn();
const mockShouldTriggerAlert = vi.fn();
vi.mock('../src/change-detector', () => ({
detectChanges: mockDetectChanges,
shouldTriggerAlert: mockShouldTriggerAlert,
}));
// Mock uuid
vi.mock('uuid', () => ({
v4: () => 'scan-uuid-' + Date.now(),
}));
const mockSubscription = {
id: 'sub-001',
userId: 'user-001',
tier: 'premium' as const,
};
function mockLatestSnapshots(snapshots: PropertySnapshot[]) {
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) {
mockPrisma.$queryRaw.mockResolvedValue([]);
} else {
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();
scheduler = new HomeTitleSchedulerService({
scanIntervalMinutes: 60,
maxPropertiesPerScan: 100,
enabled: true,
});
vi.clearAllMocks();
});
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 () => {
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.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',
};
mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mockLatestSnapshots([currentSnapshot]);
mockPreviousSnapshot(previousSnapshot);
mockDetectChanges.mockReturnValue({
propertyId: 'prop-001',
changeType: 'ownership_transfer',
severity: 'major',
confidence: 0.95,
changes: [],
previousSnapshot,
currentSnapshot,
detectedAt: new Date().toISOString(),
});
mockShouldTriggerAlert.mockReturnValue(true);
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.propertiesScanned).toBeGreaterThanOrEqual(0);
expect(result.changesDetected).toBeGreaterThanOrEqual(1);
expect(result.alertsCreated).toBeGreaterThanOrEqual(1);
expect(result.notificationsSent).toBeGreaterThanOrEqual(1);
});
it('skips snapshots without previous', async () => {
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 () => {
mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mockLatestSnapshots([]);
mockPreviousSnapshot(null);
mockDetectChanges.mockReturnValue({
propertyId: 'prop-001',
changeType: 'metadata_change',
severity: 'minor',
confidence: 0.5,
changes: [],
previousSnapshot: {} as any,
currentSnapshot: {} as any,
detectedAt: new Date().toISOString(),
});
mockShouldTriggerAlert.mockReturnValue(false);
const result = await scheduler.runScan();
expect(result.errors).toEqual([]);
expect(result.propertiesScanned).toBe(0);
});
it('tracks scan metadata', async () => {
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 };
mockPrisma.subscription.findMany.mockResolvedValue([nonPremiumSub]);
mockLatestSnapshots([currentSnapshot]);
mockPreviousSnapshot(previousSnapshot);
mockDetectChanges.mockReturnValue({
propertyId: 'prop-001',
changeType: 'ownership_transfer',
severity: 'major',
confidence: 0.95,
changes: [],
previousSnapshot,
currentSnapshot,
detectedAt: new Date().toISOString(),
});
mockShouldTriggerAlert.mockReturnValue(true);
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).toBeGreaterThanOrEqual(1);
expect(result.alertsCreated).toBeGreaterThanOrEqual(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 () => {
mockPrisma.subscription.findMany.mockResolvedValue([]);
await scheduler.runScan();
expect(scheduler.getLastScanResult()).not.toBeNull();
});
});
});