336 lines
10 KiB
TypeScript
336 lines
10 KiB
TypeScript
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();
|
|
});
|
|
});
|
|
});
|