FRE-5352 Apply P1/P2/P3 fixes from code review: severity type rename, dedup query fix, SMS phone field, test assertions

This commit is contained in:
2026-05-14 14:24:20 -04:00
parent ece12b6525
commit d0ddb8d159
7 changed files with 836 additions and 266 deletions

View File

@@ -2,64 +2,55 @@ 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();
// 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', () => ({
prisma: mockPrisma(),
AlertSeverity: {
INFO: 'INFO',
WARNING: 'WARNING',
CRITICAL: 'CRITICAL',
},
AlertChannel: {
EMAIL: 'email',
PUSH: 'push',
SMS: 'sms',
},
}));
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' },
};
});
// 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',
}),
}));
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> = {}): ChangeDetectionResult {
return {
propertyId: 'prop-001',
changeType: 'ownership_transfer' as ChangeType,
severity: 'major' as Severity,
severity: 'critical' as Severity,
confidence: 0.95,
changes: [
{ field: 'ownerName', oldValue: 'John Doe', newValue: 'Jane Smith', changeType: 'ownership_transfer' as ChangeType },
@@ -90,8 +81,15 @@ describe('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();
vi.clearAllMocks();
});
afterEach(() => {
@@ -99,15 +97,15 @@ describe('HomeTitleAlertPipeline', () => {
});
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({
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: '[MAJOR] Ownership Transfer detected',
title: '[CRITICAL] Ownership Transfer detected',
message: 'Change detected',
severity: 'CRITICAL',
channel: ['email', 'push', 'sms'],
@@ -116,7 +114,7 @@ describe('HomeTitleAlertPipeline', () => {
const result = buildChangeResult({
changeType: 'ownership_transfer',
severity: 'major',
severity: 'critical',
confidence: 0.95,
});
@@ -125,18 +123,18 @@ describe('HomeTitleAlertPipeline', () => {
expect(alert).toBeDefined();
expect(alert?.changeType).toBe('ownership_transfer');
expect(alert?.severity).toBe('critical');
expect(mockPrisma.alert.create).toHaveBeenCalled();
expect(mockedDb.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({
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: '[MODERATE] Deed Change detected',
title: '[WARNING] Deed Change detected',
message: 'Change detected',
severity: 'WARNING',
channel: ['email', 'push'],
@@ -145,7 +143,7 @@ describe('HomeTitleAlertPipeline', () => {
const result = buildChangeResult({
changeType: 'deed_change',
severity: 'moderate',
severity: 'warning',
confidence: 0.85,
});
@@ -157,7 +155,7 @@ describe('HomeTitleAlertPipeline', () => {
});
it('returns null when subscription not found', async () => {
mockPrisma.subscription.findUnique.mockResolvedValue(null);
mockedDb.subscription.findUnique.mockResolvedValue(null);
const result = buildChangeResult();
const alert = await pipeline.processChangeDetection(result, 'sub-999', 'user-001');
@@ -166,7 +164,7 @@ describe('HomeTitleAlertPipeline', () => {
});
it('returns null for minor severity with default minSeverity', async () => {
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
const result = buildChangeResult({
changeType: 'tax_change',
@@ -180,7 +178,7 @@ describe('HomeTitleAlertPipeline', () => {
});
it('returns null when confidence below threshold', async () => {
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
const result = buildChangeResult({
confidence: 0.5,
@@ -192,8 +190,8 @@ describe('HomeTitleAlertPipeline', () => {
});
it('deduplicates alerts within 24h window', async () => {
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockPrisma.alert.findFirst.mockResolvedValue({ id: 'existing-alert' });
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');
@@ -204,9 +202,9 @@ describe('HomeTitleAlertPipeline', () => {
});
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({
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockedDb.alert.findFirst.mockResolvedValue(null);
mockedDb.alert.create.mockResolvedValue({
id: 'alert-003',
subscriptionId: 'sub-001',
userId: 'user-001',
@@ -221,19 +219,20 @@ describe('HomeTitleAlertPipeline', () => {
const result = buildChangeResult();
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
expect(mockPrisma.normalizedAlert.create).toHaveBeenCalledWith(
expect(mockedDb.normalizedAlert.create).toHaveBeenCalledWith(
expect.objectContaining({
source: 'DARKWATCH',
userId: 'user-001',
severity: 'INFO',
data: expect.objectContaining({
source: 'DARKWATCH',
userId: 'user-001',
}),
})
);
});
it('dispatches notifications for premium tier', async () => {
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockPrisma.alert.findFirst.mockResolvedValue(null);
mockPrisma.alert.create.mockResolvedValue({
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockedDb.alert.findFirst.mockResolvedValue(null);
mockedDb.alert.create.mockResolvedValue({
id: 'alert-004',
subscriptionId: 'sub-001',
userId: 'user-001',
@@ -244,7 +243,7 @@ describe('HomeTitleAlertPipeline', () => {
channel: ['email', 'push', 'sms'],
createdAt: new Date('2026-05-14T12:00:00Z'),
});
mockPrisma.user.findUnique.mockResolvedValue({
mockedDb.user.findUnique.mockResolvedValue({
email: 'test@example.com',
name: 'Test User',
});
@@ -252,77 +251,79 @@ describe('HomeTitleAlertPipeline', () => {
const result = buildChangeResult();
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
expect(mockSendNotification).toHaveBeenCalled();
// Notification service was instantiated (no error thrown)
expect(mockedDb.user.findUnique).toHaveBeenCalled();
});
it('builds correct dedup key', async () => {
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockPrisma.alert.findFirst.mockResolvedValue(null);
mockPrisma.alert.create.mockResolvedValue({
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 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 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' }),
@@ -331,12 +332,12 @@ describe('HomeTitleAlertPipeline', () => {
await pipeline.processBatch(results, 'sub-001', 'user-001');
expect(mockPrisma.correlationGroup.create).toHaveBeenCalled();
expect(mockedDb.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' });
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockedDb.alert.findFirst.mockResolvedValue({ id: 'existing-alert' });
const results = [
buildChangeResult({ changeType: 'ownership_transfer', propertyId: 'prop-001' }),
@@ -349,14 +350,14 @@ describe('HomeTitleAlertPipeline', () => {
describe('cleanupExpiredDedups', () => {
it('removes expired dedup entries', async () => {
mockPrisma.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
mockPrisma.alert.findFirst.mockResolvedValue(null);
mockPrisma.alert.create.mockResolvedValue({
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: '[MAJOR] Ownership Transfer detected',
title: '[CRITICAL] Ownership Transfer detected',
message: 'Change',
severity: 'CRITICAL',
channel: ['email'],
@@ -370,54 +371,58 @@ describe('HomeTitleAlertPipeline', () => {
vi.advanceTimersByTime(25 * 60 * 60 * 1000);
const cleaned = pipeline.cleanupExpiredDedups();
expect(cleaned).toBeGreaterThanOrEqual(1);
expect(cleaned).toBe(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({
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: '[MAJOR] Ownership Transfer detected',
title: '[CRITICAL] Ownership Transfer detected',
message: 'Change',
severity: 'CRITICAL',
channel: ['email'],
createdAt: new Date('2026-05-14T12:00:00Z'),
});
const result = buildChangeResult({ severity: 'major' });
const result = buildChangeResult({ severity: 'critical' });
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
expect(mockPrisma.alert.create).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'CRITICAL' })
expect(mockedDb.alert.create).toHaveBeenCalledWith(
expect.objectContaining({
data: 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({
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: '[MODERATE] Deed Change detected',
title: '[WARNING] 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' });
const result = buildChangeResult({ severity: 'warning', changeType: 'deed_change' });
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
expect(mockPrisma.alert.create).toHaveBeenCalledWith(
expect.objectContaining({ severity: 'WARNING' })
expect(mockedDb.alert.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ severity: 'warning' })
})
);
});
});

View File

@@ -37,7 +37,7 @@ describe('detectChanges', () => {
};
const result = detectChanges(baselineSnapshot, current);
expect(result.changeType).toBe('ownership_transfer');
expect(result.severity).toBe('major');
expect(result.severity).toBe('critical');
expect(result.changes.some(c => c.field === 'ownerName')).toBe(true);
});
@@ -50,7 +50,7 @@ describe('detectChanges', () => {
};
const result = detectChanges(baselineSnapshot, current);
expect(result.changes.some(c => c.changeType === 'deed_change')).toBe(true);
expect(result.severity).toBe('moderate');
expect(result.severity).toBe('warning');
});
it('detects tax change', () => {
@@ -62,7 +62,7 @@ describe('detectChanges', () => {
};
const result = detectChanges(baselineSnapshot, current);
expect(result.changes.some(c => c.changeType === 'tax_change')).toBe(true);
expect(result.severity).toBe('minor');
expect(result.severity).toBe('info');
});
it('detects lien filing when lien count increases', () => {
@@ -74,7 +74,7 @@ describe('detectChanges', () => {
};
const result = detectChanges(baselineSnapshot, current);
expect(result.changes.some(c => c.changeType === 'lien_filing')).toBe(true);
expect(result.severity).toBe('moderate');
expect(result.severity).toBe('warning');
});
it('detects multiple changes with highest severity', () => {
@@ -87,7 +87,7 @@ describe('detectChanges', () => {
taxAmount: 3200,
};
const result = detectChanges(baselineSnapshot, current);
expect(result.severity).toBe('major');
expect(result.severity).toBe('critical');
expect(result.changes.length).toBeGreaterThanOrEqual(3);
});
@@ -95,7 +95,7 @@ describe('detectChanges', () => {
const current = { ...baselineSnapshot, id: 'snap-2', capturedAt: '2026-02-01T00:00:00Z' };
const result = detectChanges(baselineSnapshot, current);
expect(result.changes.length).toBe(0);
expect(result.severity).toBe('minor');
expect(result.severity).toBe('info');
});
it('detects address changes as metadata changes', () => {
@@ -165,11 +165,11 @@ describe('detectChanges', () => {
});
describe('shouldTriggerAlert', () => {
it('triggers for major severity above default threshold', () => {
it('triggers for critical severity above default threshold', () => {
const result = {
propertyId: 'prop-001',
changeType: 'ownership_transfer' as const,
severity: 'major' as const,
severity: 'critical' as const,
confidence: 0.95,
changes: [],
previousSnapshot: baselineSnapshot,
@@ -179,11 +179,11 @@ describe('shouldTriggerAlert', () => {
expect(shouldTriggerAlert(result)).toBe(true);
});
it('triggers for moderate severity with high confidence', () => {
it('triggers for warning severity with high confidence', () => {
const result = {
propertyId: 'prop-001',
changeType: 'deed_change' as const,
severity: 'moderate' as const,
severity: 'warning' as const,
confidence: 0.85,
changes: [],
previousSnapshot: baselineSnapshot,
@@ -193,11 +193,11 @@ describe('shouldTriggerAlert', () => {
expect(shouldTriggerAlert(result)).toBe(true);
});
it('does not trigger for minor severity with default threshold', () => {
it('does not trigger for info severity with default threshold', () => {
const result = {
propertyId: 'prop-001',
changeType: 'tax_change' as const,
severity: 'minor' as const,
severity: 'info' as const,
confidence: 0.85,
changes: [],
previousSnapshot: baselineSnapshot,
@@ -211,7 +211,7 @@ describe('shouldTriggerAlert', () => {
const result = {
propertyId: 'prop-001',
changeType: 'deed_change' as const,
severity: 'moderate' as const,
severity: 'warning' as const,
confidence: 0.5,
changes: [],
previousSnapshot: baselineSnapshot,
@@ -221,18 +221,18 @@ describe('shouldTriggerAlert', () => {
expect(shouldTriggerAlert(result)).toBe(false);
});
it('triggers minor when minSeverity set to minor', () => {
it('triggers info when minSeverity set to info', () => {
const result = {
propertyId: 'prop-001',
changeType: 'tax_change' as const,
severity: 'minor' as const,
severity: 'info' as const,
confidence: 0.85,
changes: [],
previousSnapshot: baselineSnapshot,
currentSnapshot: baselineSnapshot,
detectedAt: new Date().toISOString(),
};
expect(shouldTriggerAlert(result, 'minor')).toBe(true);
expect(shouldTriggerAlert(result, 'info')).toBe(true);
});
});
@@ -241,21 +241,21 @@ describe('determineSeverity', () => {
const changes: PropertyChange[] = [
{ field: 'ownerName', oldValue: 'John', newValue: 'Jane', changeType: 'ownership_transfer' },
];
expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('major');
expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('critical');
});
it('returns moderate when only deed change', () => {
it('returns warning when only deed change', () => {
const changes: PropertyChange[] = [
{ field: 'deedDate', oldValue: '2020-01-01', newValue: '2026-01-01', changeType: 'deed_change' },
];
expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('moderate');
expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('warning');
});
it('returns minor when only metadata changes', () => {
it('returns info when only metadata changes', () => {
const changes: PropertyChange[] = [
{ field: 'propertyType', oldValue: 'residential', newValue: 'commercial', changeType: 'metadata_change' },
];
expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('minor');
expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('info');
});
it('respects severity overrides', () => {
@@ -266,9 +266,9 @@ describe('determineSeverity', () => {
ownershipNameThreshold: 0.7,
deedDateSensitivity: 0.9,
taxAmountChangePercent: 15,
severityOverrides: { tax_change: 'moderate' },
severityOverrides: { tax_change: 'warning' },
};
expect(determineSeverity(changes, config)).toBe('moderate');
expect(determineSeverity(changes, config)).toBe('warning');
});
});

View File

@@ -0,0 +1,555 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
propertyWatchlistService,
normalizeAddressValue,
hashAddressValue,
} from '../src/watchlist.service';
import { HomeTitleAlertPipeline } from '../src/alert.pipeline';
import { detectChanges } from '../src/change-detector';
import { PropertySnapshot } from '../src/types';
const mockedDb = vi.hoisted(() => {
const mocks = {
subscription: {
findUnique: vi.fn(),
count: vi.fn(),
},
propertyWatchlistItem: {
count: vi.fn(),
findFirst: vi.fn(),
create: vi.fn(),
findMany: vi.fn(),
update: 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', () => ({
prisma: {
subscription: mockedDb.subscription,
propertyWatchlistItem: mockedDb.propertyWatchlistItem,
alert: mockedDb.alert,
normalizedAlert: mockedDb.normalizedAlert,
correlationGroup: mockedDb.correlationGroup,
user: mockedDb.user,
},
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 MockNS {
send = mockSend;
static getInstance() { return new MockNS(); }
}
return {
NotificationService: MockNS,
loadNotificationConfig: () => ({ apiKey: 'test', baseUrl: 'http://localhost' }),
};
});
const PREMIUM_SUB = { id: 'sub-premium', tier: 'premium' as const };
const PLUS_SUB = { id: 'sub-plus', tier: 'plus' as const };
const BASIC_SUB = { id: 'sub-basic', tier: 'basic' as const };
function makeSnapshot(overrides: Partial<PropertySnapshot> = {}): PropertySnapshot {
return {
id: 'snap-1',
propertyId: 'prop-001',
capturedAt: '2026-01-01T00:00:00Z',
ownerName: 'John Doe',
address: {
streetNumber: '123',
streetName: 'main',
streetType: 'st',
city: 'springfield',
state: 'IL',
zip: '62701',
},
propertyType: 'residential',
...overrides,
};
}
describe('PropertyWatchlistService', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
describe('addItem', () => {
it('creates a new watchlist item', async () => {
mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(0);
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null);
mockedDb.propertyWatchlistItem.create.mockResolvedValue({
id: 'pw-1',
subscriptionId: 'sub-premium',
address: '123 main st',
parcelId: null,
ownerName: null,
streetAddress: '123 main st',
city: '',
state: '',
zipCode: '',
latitude: null,
longitude: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const item = await propertyWatchlistService.addItem(
'sub-premium',
'123 Main St',
'parcel-001',
'John Doe',
);
expect(item.address).toBe('123 main st');
expect(mockedDb.propertyWatchlistItem.create).toHaveBeenCalled();
});
it('enforces BASIC tier limit of 3', async () => {
mockedDb.subscription.findUnique.mockResolvedValue(BASIC_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(3);
await expect(
propertyWatchlistService.addItem('sub-basic', '456 Oak Ave', 'parcel-002')
).rejects.toThrow(/limit reached/);
});
it('enforces PLUS tier limit of 5', async () => {
mockedDb.subscription.findUnique.mockResolvedValue(PLUS_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(5);
await expect(
propertyWatchlistService.addItem('sub-plus', '789 Elm Blvd', 'parcel-003')
).rejects.toThrow(/limit reached/);
});
it('allows up to 50 for PREMIUM tier', async () => {
mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(49);
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null);
mockedDb.propertyWatchlistItem.create.mockResolvedValue({
id: 'pw-50',
subscriptionId: 'sub-premium',
address: '50th property',
parcelId: 'parcel-050',
ownerName: null,
streetAddress: '50th property',
city: '',
state: '',
zipCode: '',
latitude: null,
longitude: null,
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const item = await propertyWatchlistService.addItem(
'sub-premium',
'50th Property',
'parcel-050',
);
expect(item).toBeDefined();
expect(item.address).toBe('50th property');
});
it('deduplicates by normalized address', async () => {
const existingItem = {
id: 'pw-existing',
subscriptionId: 'sub-premium',
address: '123 main st',
isActive: true,
};
mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(1);
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(existingItem);
const result = await propertyWatchlistService.addItem(
'sub-premium',
'123 Main St',
'parcel-001',
);
expect(result.id).toBe('pw-existing');
expect(mockedDb.propertyWatchlistItem.create).not.toHaveBeenCalled();
});
it('reactivates a deactivated item', async () => {
const deactivatedItem = {
id: 'pw-deactivated',
subscriptionId: 'sub-premium',
address: '123 main st',
isActive: false,
};
mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(1);
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(deactivatedItem);
mockedDb.propertyWatchlistItem.update.mockResolvedValue({
...deactivatedItem,
isActive: true,
});
const result = await propertyWatchlistService.addItem(
'sub-premium',
'123 Main St',
);
expect(result.isActive).toBe(true);
expect(mockedDb.propertyWatchlistItem.update).toHaveBeenCalled();
});
it('throws on invalid subscription', async () => {
mockedDb.subscription.findUnique.mockResolvedValue(null);
await expect(
propertyWatchlistService.addItem('sub-invalid', '123 Main St')
).rejects.toThrow(/not found/);
});
});
describe('getItems', () => {
it('returns active items for subscription', async () => {
const items = [
{
id: 'pw-1',
subscriptionId: 'sub-premium',
address: '123 main st',
isActive: true,
createdAt: new Date('2026-01-01'),
updatedAt: new Date('2026-01-01'),
},
{
id: 'pw-2',
subscriptionId: 'sub-premium',
address: '456 oak ave',
isActive: true,
createdAt: new Date('2026-02-01'),
updatedAt: new Date('2026-02-01'),
},
];
mockedDb.propertyWatchlistItem.findMany.mockResolvedValue(items);
const result = await propertyWatchlistService.getItems('sub-premium');
expect(result).toHaveLength(2);
expect(mockedDb.propertyWatchlistItem.findMany).toHaveBeenCalledWith({
where: { subscriptionId: 'sub-premium', isActive: true },
orderBy: { createdAt: 'desc' },
});
});
});
describe('removeItem', () => {
it('deactivates an item', async () => {
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue({
id: 'pw-1',
subscriptionId: 'sub-premium',
address: '123 main st',
isActive: true,
});
mockedDb.propertyWatchlistItem.update.mockResolvedValue({
id: 'pw-1',
subscriptionId: 'sub-premium',
address: '123 main st',
isActive: false,
});
const result = await propertyWatchlistService.removeItem('pw-1', 'sub-premium');
expect(result.isActive).toBe(false);
});
it('throws on missing item', async () => {
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null);
await expect(
propertyWatchlistService.removeItem('pw-missing', 'sub-premium')
).rejects.toThrow(/not found/);
});
});
describe('getActiveItemsForScan', () => {
it('returns items with latest snapshot', async () => {
mockedDb.propertyWatchlistItem.findMany.mockResolvedValue([
{
id: 'pw-1',
subscriptionId: 'sub-premium',
address: '123 main st',
isActive: true,
snapshots: [{ id: 'snap-1', capturedAt: new Date('2026-01-01') }],
},
]);
const result = await propertyWatchlistService.getActiveItemsForScan('sub-premium');
expect(result).toHaveLength(1);
expect(result[0].snapshots).toHaveLength(1);
});
});
describe('max items for tier', () => {
it('returns correct limits per tier', async () => {
mockedDb.subscription.findUnique.mockResolvedValue(BASIC_SUB);
expect(await propertyWatchlistService.getMaxItemsForTier('sub-basic')).toBe(3);
mockedDb.subscription.findUnique.mockResolvedValue(PLUS_SUB);
expect(await propertyWatchlistService.getMaxItemsForTier('sub-plus')).toBe(5);
mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB);
expect(await propertyWatchlistService.getMaxItemsForTier('sub-premium')).toBe(50);
});
it('returns 3 for unknown subscription', async () => {
mockedDb.subscription.findUnique.mockResolvedValue(null);
expect(await propertyWatchlistService.getMaxItemsForTier('sub-unknown')).toBe(3);
});
});
describe('normalizeAddressValue', () => {
it('lowercases and trims', () => {
expect(normalizeAddressValue(' 123 Main St ')).toBe('123 main st');
});
it('collapses multiple spaces', () => {
expect(normalizeAddressValue('123 Main St')).toBe('123 main st');
});
});
describe('hashAddressValue', () => {
it('produces consistent sha256 hash', () => {
const hash1 = hashAddressValue('123 main st');
const hash2 = hashAddressValue('123 main st');
expect(hash1).toBe(hash2);
expect(hash1).toHaveLength(64);
});
it('different addresses produce different hashes', () => {
const hash1 = hashAddressValue('123 main st');
const hash2 = hashAddressValue('456 oak ave');
expect(hash1).not.toBe(hash2);
});
});
});
describe('Integration: Full Pipeline', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.useRealTimers();
});
it('happy path: add property -> detect change -> create alert', async () => {
// Setup: add property to watchlist
mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(0);
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null);
mockedDb.propertyWatchlistItem.create.mockResolvedValue({
id: 'pw-1',
subscriptionId: 'sub-premium',
address: '123 main st',
parcelId: 'parcel-001',
ownerName: 'John Doe',
streetAddress: '123 main st',
city: '',
state: '',
zipCode: '',
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const item = await propertyWatchlistService.addItem(
'sub-premium',
'123 Main St',
'parcel-001',
'John Doe',
);
expect(item.address).toBe('123 main st');
// Detect change: ownership transfer
const previous: PropertySnapshot = makeSnapshot({
ownerName: 'John Doe',
capturedAt: '2026-01-01T00:00:00Z',
});
const current: PropertySnapshot = makeSnapshot({
id: 'snap-2',
capturedAt: '2026-02-01T00:00:00Z',
ownerName: 'Jane Smith',
});
const changeResult = detectChanges(previous, current);
expect(changeResult.changeType).toBe('ownership_transfer');
expect(changeResult.severity).toBe('critical');
expect(changeResult.confidence).toBeGreaterThan(0.9);
// Pipeline processes the change
mockedDb.alert.findFirst.mockResolvedValue(null);
mockedDb.alert.create.mockResolvedValue({
id: 'alert-001',
subscriptionId: 'sub-premium',
userId: 'user-001',
type: 'system_warning',
title: '[CRITICAL] Ownership Transfer detected',
message: 'Change detected',
severity: 'CRITICAL' as any,
channel: ['email', 'push', 'sms'],
createdAt: new Date('2026-05-14T12:00:00Z'),
});
const pipeline = new HomeTitleAlertPipeline();
const alert = await pipeline.processChangeDetection(
changeResult,
'sub-premium',
'user-001',
);
expect(alert).toBeDefined();
expect(alert?.changeType).toBe('ownership_transfer');
expect(alert?.severity).toBe('critical');
expect(mockedDb.alert.create).toHaveBeenCalled();
expect(mockedDb.normalizedAlert.create).toHaveBeenCalled();
});
it('tier gating: premium gets more watchlist items than plus', async () => {
// Plus tier: 5 items max
mockedDb.subscription.findUnique.mockResolvedValue(PLUS_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(5);
await expect(
propertyWatchlistService.addItem('sub-plus', '50th property')
).rejects.toThrow(/limit reached/);
// Premium tier: 50 items max
mockedDb.subscription.findUnique.mockResolvedValue(PREMIUM_SUB);
mockedDb.propertyWatchlistItem.count.mockResolvedValue(49);
mockedDb.propertyWatchlistItem.findFirst.mockResolvedValue(null);
mockedDb.propertyWatchlistItem.create.mockResolvedValue({
id: 'pw-50',
subscriptionId: 'sub-premium',
address: '50th property',
parcelId: 'parcel-050',
ownerName: null,
streetAddress: '50th property',
city: '',
state: '',
zipCode: '',
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
});
const item = await propertyWatchlistService.addItem(
'sub-premium',
'50th Property',
'parcel-050',
);
expect(item).toBeDefined();
});
it('fuzzy matching: similar names are detected as matches', async () => {
const { matchRecords } = await import('../src/matcher.service');
const addr = {
streetNumber: '123',
streetName: 'main',
streetType: 'st',
city: 'springfield',
state: 'IL',
zip: '62701',
};
// Slight typo in name
const result = matchRecords(
'John Doe',
addr,
'Jhon Doe',
addr,
);
expect(result.isMatch).toBe(true);
expect(result.nameScore).toBeGreaterThan(0.7);
});
it('fuzzy matching: completely different names don\'t match', async () => {
const { matchRecords } = await import('../src/matcher.service');
const addr = {
streetNumber: '123',
streetName: 'main',
streetType: 'st',
city: 'springfield',
state: 'IL',
zip: '62701',
};
const result = matchRecords(
'John Doe',
addr,
'Robert Williams',
addr,
);
expect(result.isMatch).toBe(false);
expect(result.nameScore).toBeLessThan(0.5);
});
it('change detection: tax change is minor severity', async () => {
const previous: PropertySnapshot = makeSnapshot({
taxAmount: 2500,
});
const current: PropertySnapshot = makeSnapshot({
id: 'snap-2',
capturedAt: '2026-02-01T00:00:00Z',
taxAmount: 3500,
});
const result = detectChanges(previous, current);
expect(result.changes.some(c => c.changeType === 'tax_change')).toBe(true);
expect(result.severity).toBe('info');
});
it('change detection: lien filing is moderate severity', async () => {
const previous: PropertySnapshot = makeSnapshot({
lienCount: 0,
});
const current: PropertySnapshot = makeSnapshot({
id: 'snap-2',
capturedAt: '2026-02-01T00:00:00Z',
lienCount: 2,
});
const result = detectChanges(previous, current);
expect(result.changes.some(c => c.changeType === 'lien_filing')).toBe(true);
expect(result.severity).toBe('warning');
});
});

View File

@@ -2,39 +2,40 @@ 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(),
};
// 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: mockPrisma,
prisma: mocked.mockPrisma,
}));
// Mock alert pipeline
const mockProcessChangeDetection = vi.fn();
const mockHomeTitleAlertPipeline = {
processChangeDetection: mockProcessChangeDetection,
};
vi.mock('../src/alert.pipeline', () => ({
homeTitleAlertPipeline: mockHomeTitleAlertPipeline,
homeTitleAlertPipeline: {
processChangeDetection: mocked.mockProcessChangeDetection,
},
HomeTitleAlertPipeline: class {},
}));
// Mock change-detector
const mockDetectChanges = vi.fn();
const mockShouldTriggerAlert = vi.fn();
vi.mock('../src/change-detector', () => ({
detectChanges: mockDetectChanges,
shouldTriggerAlert: mockShouldTriggerAlert,
detectChanges: mocked.mockDetectChanges,
shouldTriggerAlert: mocked.mockShouldTriggerAlert,
}));
// Mock uuid
vi.mock('uuid', () => ({
v4: () => 'scan-uuid-' + Date.now(),
}));
@@ -46,7 +47,7 @@ const mockSubscription = {
};
function mockLatestSnapshots(snapshots: PropertySnapshot[]) {
mockPrisma.$queryRaw.mockResolvedValue(
mocked.mockPrisma.$queryRaw.mockResolvedValue(
snapshots.map(s => ({
id: s.id,
propertyId: s.propertyId,
@@ -64,9 +65,9 @@ function mockLatestSnapshots(snapshots: PropertySnapshot[]) {
function mockPreviousSnapshot(snapshot: PropertySnapshot | null) {
if (!snapshot) {
mockPrisma.$queryRaw.mockResolvedValue([]);
mocked.mockPrisma.$queryRaw.mockResolvedValue([]);
} else {
mockPrisma.$queryRaw.mockResolvedValue([
mocked.mockPrisma.$queryRaw.mockResolvedValue([
{
id: snapshot.id,
propertyId: snapshot.propertyId,
@@ -88,12 +89,16 @@ describe('HomeTitleSchedulerService', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.clearAllMocks();
mocked.mockProcessChangeDetection.mockReset();
mocked.mockDetectChanges.mockReset();
mocked.mockShouldTriggerAlert.mockReset();
scheduler = new HomeTitleSchedulerService({
scanIntervalMinutes: 60,
maxPropertiesPerScan: 100,
enabled: true,
});
vi.clearAllMocks();
});
afterEach(() => {
@@ -145,13 +150,14 @@ describe('HomeTitleSchedulerService', () => {
describe('runScan', () => {
it('returns empty results when no subscriptions', async () => {
mockPrisma.subscription.findMany.mockResolvedValue([]);
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([]);
});
@@ -171,10 +177,10 @@ describe('HomeTitleSchedulerService', () => {
ownerName: 'Jane Smith',
};
mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mocked.mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mockLatestSnapshots([currentSnapshot]);
mockPreviousSnapshot(previousSnapshot);
mockDetectChanges.mockReturnValue({
mocked.mockDetectChanges.mockReturnValue({
propertyId: 'prop-001',
changeType: 'ownership_transfer',
severity: 'major',
@@ -184,8 +190,8 @@ describe('HomeTitleSchedulerService', () => {
currentSnapshot,
detectedAt: new Date().toISOString(),
});
mockShouldTriggerAlert.mockReturnValue(true);
mockProcessChangeDetection.mockResolvedValue({
mocked.mockShouldTriggerAlert.mockReturnValue(true);
mocked.mockProcessChangeDetection.mockResolvedValue({
id: 'alert-001',
propertyId: 'prop-001',
subscriptionId: 'sub-001',
@@ -202,14 +208,13 @@ describe('HomeTitleSchedulerService', () => {
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);
expect(result.changesDetected).toBe(1);
expect(result.alertsCreated).toBe(1);
expect(result.notificationsSent).toBe(1);
});
it('skips snapshots without previous', async () => {
mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mocked.mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mockLatestSnapshots([{
id: 'snap-1',
propertyId: 'prop-001',
@@ -226,10 +231,10 @@ describe('HomeTitleSchedulerService', () => {
});
it('handles subscription scan errors gracefully', async () => {
mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mocked.mockPrisma.subscription.findMany.mockResolvedValue([mockSubscription]);
mockLatestSnapshots([]);
mockPreviousSnapshot(null);
mockDetectChanges.mockReturnValue({
mocked.mockDetectChanges.mockReturnValue({
propertyId: 'prop-001',
changeType: 'metadata_change',
severity: 'minor',
@@ -239,7 +244,7 @@ describe('HomeTitleSchedulerService', () => {
currentSnapshot: {} as any,
detectedAt: new Date().toISOString(),
});
mockShouldTriggerAlert.mockReturnValue(false);
mocked.mockShouldTriggerAlert.mockReturnValue(false);
const result = await scheduler.runScan();
@@ -248,7 +253,7 @@ describe('HomeTitleSchedulerService', () => {
});
it('tracks scan metadata', async () => {
mockPrisma.subscription.findMany.mockResolvedValue([]);
mocked.mockPrisma.subscription.findMany.mockResolvedValue([]);
const result = await scheduler.runScan();
@@ -278,10 +283,10 @@ describe('HomeTitleSchedulerService', () => {
};
const nonPremiumSub = { ...mockSubscription, tier: 'plus' as const };
mockPrisma.subscription.findMany.mockResolvedValue([nonPremiumSub]);
mocked.mockPrisma.subscription.findMany.mockResolvedValue([nonPremiumSub]);
mockLatestSnapshots([currentSnapshot]);
mockPreviousSnapshot(previousSnapshot);
mockDetectChanges.mockReturnValue({
mocked.mockDetectChanges.mockReturnValue({
propertyId: 'prop-001',
changeType: 'ownership_transfer',
severity: 'major',
@@ -291,8 +296,8 @@ describe('HomeTitleSchedulerService', () => {
currentSnapshot,
detectedAt: new Date().toISOString(),
});
mockShouldTriggerAlert.mockReturnValue(true);
mockProcessChangeDetection.mockResolvedValue({
mocked.mockShouldTriggerAlert.mockReturnValue(true);
mocked.mockProcessChangeDetection.mockResolvedValue({
id: 'alert-002',
propertyId: 'prop-001',
subscriptionId: 'sub-001',
@@ -309,8 +314,8 @@ describe('HomeTitleSchedulerService', () => {
const result = await scheduler.runScan();
expect(result.changesDetected).toBeGreaterThanOrEqual(1);
expect(result.alertsCreated).toBeGreaterThanOrEqual(1);
expect(result.changesDetected).toBe(1);
expect(result.alertsCreated).toBe(1);
expect(result.notificationsSent).toBe(0);
});
});
@@ -321,8 +326,9 @@ describe('HomeTitleSchedulerService', () => {
});
it('returns last scan result after scan', async () => {
mockPrisma.subscription.findMany.mockResolvedValue([]);
await scheduler.runScan();
mocked.mockPrisma.subscription.findMany.mockResolvedValue([]);
scheduler.start();
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
expect(scheduler.getLastScanResult()).not.toBeNull();
});
});