FRE-5351 CTO review: finalize hometitle exports and types for alert pipeline + scheduler
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
424
services/hometitle/test/alert.pipeline.test.ts
Normal file
424
services/hometitle/test/alert.pipeline.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
||||
import { HomeTitleAlertPipeline } from '../src/alert.pipeline';
|
||||
import {
|
||||
ChangeDetectionResult,
|
||||
PropertyAlert,
|
||||
PropertySnapshot,
|
||||
ChangeType,
|
||||
Severity,
|
||||
PropertyChange,
|
||||
} from '../src/types';
|
||||
|
||||
// Mock @shieldai/db
|
||||
const mockPrisma = vi.fn();
|
||||
|
||||
vi.mock('@shieldai/db', () => ({
|
||||
prisma: mockPrisma(),
|
||||
AlertSeverity: {
|
||||
INFO: 'INFO',
|
||||
WARNING: 'WARNING',
|
||||
CRITICAL: 'CRITICAL',
|
||||
},
|
||||
AlertChannel: {
|
||||
EMAIL: 'email',
|
||||
PUSH: 'push',
|
||||
SMS: 'sms',
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock @shieldai/shared-notifications
|
||||
let mockSendNotification = vi.fn();
|
||||
|
||||
vi.mock('@shieldai/shared-notifications', () => ({
|
||||
NotificationService: class {
|
||||
constructor() {
|
||||
this.send = mockSendNotification;
|
||||
}
|
||||
},
|
||||
loadNotificationConfig: () => ({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock @shieldai/shared-notifications
|
||||
const mockSendNotification = vi.fn();
|
||||
vi.mock('@shieldai/shared-notifications', () => ({
|
||||
NotificationService: class {
|
||||
constructor() {
|
||||
this.send = mockSendNotification;
|
||||
}
|
||||
},
|
||||
loadNotificationConfig: () => ({
|
||||
apiKey: 'test-key',
|
||||
baseUrl: 'http://localhost:3000',
|
||||
}),
|
||||
}));
|
||||
|
||||
function buildChangeResult(overrides: Partial<ChangeDetectionResult> = {}): ChangeDetectionResult {
|
||||
return {
|
||||
propertyId: 'prop-001',
|
||||
changeType: 'ownership_transfer' as ChangeType,
|
||||
severity: 'major' 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();
|
||||
pipeline = new HomeTitleAlertPipeline();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('processChangeDetection', () => {
|
||||
it('creates alert for major severity change', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: 'alert-001',
|
||||
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({
|
||||
changeType: 'ownership_transfer',
|
||||
severity: 'major',
|
||||
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(mockPrisma.alert.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates alert for moderate severity change', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'plus' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: 'alert-002',
|
||||
subscriptionId: 'sub-001',
|
||||
userId: 'user-001',
|
||||
type: 'system_warning',
|
||||
title: '[MODERATE] 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: 'moderate',
|
||||
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 () => {
|
||||
mockPrisma.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 () => {
|
||||
mockPrisma.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 () => {
|
||||
mockPrisma.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 () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.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 () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.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(mockPrisma.normalizedAlert.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'DARKWATCH',
|
||||
userId: 'user-001',
|
||||
severity: 'INFO',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches notifications for premium tier', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.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'),
|
||||
});
|
||||
mockPrisma.user.findUnique.mockResolvedValue({
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
});
|
||||
|
||||
const result = buildChangeResult();
|
||||
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
|
||||
|
||||
expect(mockSendNotification).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds correct dedup key', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: 'alert-005',
|
||||
subscriptionId: 'sub-001',
|
||||
userId: 'user-001',
|
||||
type: 'system_warning',
|
||||
title: '[MAJOR] Ownership Transfer detected',
|
||||
message: 'Change detected',
|
||||
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');
|
||||
|
||||
// Verify in-memory dedup was recorded
|
||||
const cleanupCount = pipeline.cleanupExpiredDedups();
|
||||
// No expired dedups at this point
|
||||
expect(cleanupCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processBatch', () => {
|
||||
it('processes multiple change results', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: 'alert-batch-1',
|
||||
subscriptionId: 'sub-001',
|
||||
userId: 'user-001',
|
||||
type: 'system_warning',
|
||||
title: '[MAJOR] Ownership Transfer detected',
|
||||
message: 'Change 1',
|
||||
severity: 'CRITICAL',
|
||||
channel: ['email'],
|
||||
createdAt: new Date('2026-05-14T12:00:00Z'),
|
||||
});
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ email: 'test@example.com', name: 'Test' });
|
||||
|
||||
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).toBeGreaterThanOrEqual(1);
|
||||
expect(mockPrisma.alert.create).toHaveBeenCalledTimes(results.length);
|
||||
});
|
||||
|
||||
it('creates correlation group for multiple alerts', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: `alert-batch-${Date.now()}`,
|
||||
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'),
|
||||
});
|
||||
mockPrisma.user.findUnique.mockResolvedValue({ email: 'test@example.com', name: 'Test' });
|
||||
|
||||
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(mockPrisma.correlationGroup.create).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty array when all results are deduplicated', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.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 () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: 'alert-cleanup',
|
||||
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();
|
||||
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).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('severity mapping', () => {
|
||||
it('maps major to critical', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: 'alert-sev-1',
|
||||
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({ severity: 'major' });
|
||||
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
|
||||
|
||||
expect(mockPrisma.alert.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'CRITICAL' })
|
||||
);
|
||||
});
|
||||
|
||||
it('maps moderate to warning', async () => {
|
||||
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
||||
mockPrisma.alert.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.alert.create.mockResolvedValue({
|
||||
id: 'alert-sev-2',
|
||||
subscriptionId: 'sub-001',
|
||||
userId: 'user-001',
|
||||
type: 'system_warning',
|
||||
title: '[MODERATE] Deed Change detected',
|
||||
message: 'Change',
|
||||
severity: 'WARNING',
|
||||
channel: ['email'],
|
||||
createdAt: new Date('2026-05-14T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = buildChangeResult({ severity: 'moderate', changeType: 'deed_change' });
|
||||
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
|
||||
|
||||
expect(mockPrisma.alert.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ severity: 'WARNING' })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
329
services/hometitle/test/scheduler.service.test.ts
Normal file
329
services/hometitle/test/scheduler.service.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user