430 lines
15 KiB
TypeScript
430 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
|
|
import { HomeTitleAlertPipeline } from '../src/alert.pipeline';
|
|
import {
|
|
ChangeDetectionResult,
|
|
PropertySnapshot,
|
|
ChangeType,
|
|
Severity,
|
|
} from '../src/types';
|
|
|
|
// 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', () => {
|
|
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' },
|
|
};
|
|
});
|
|
|
|
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: 'critical' 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();
|
|
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();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
describe('processChangeDetection', () => {
|
|
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: '[CRITICAL] 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: 'critical',
|
|
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(mockedDb.alert.create).toHaveBeenCalled();
|
|
});
|
|
|
|
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: '[WARNING] 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: 'warning',
|
|
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 () => {
|
|
mockedDb.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 () => {
|
|
mockedDb.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 () => {
|
|
mockedDb.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 () => {
|
|
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');
|
|
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 () => {
|
|
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
|
mockedDb.alert.findFirst.mockResolvedValue(null);
|
|
mockedDb.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(mockedDb.normalizedAlert.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({
|
|
source: 'DARKWATCH',
|
|
userId: 'user-001',
|
|
}),
|
|
})
|
|
);
|
|
});
|
|
|
|
it('dispatches notifications for premium tier', async () => {
|
|
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
|
mockedDb.alert.findFirst.mockResolvedValue(null);
|
|
mockedDb.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'),
|
|
});
|
|
mockedDb.user.findUnique.mockResolvedValue({
|
|
email: 'test@example.com',
|
|
name: 'Test User',
|
|
});
|
|
|
|
const result = buildChangeResult();
|
|
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
|
|
|
|
// Notification service was instantiated (no error thrown)
|
|
expect(mockedDb.user.findUnique).toHaveBeenCalled();
|
|
});
|
|
|
|
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',
|
|
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');
|
|
|
|
// 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' }),
|
|
buildChangeResult({ changeType: 'deed_change', propertyId: 'prop-002' }),
|
|
];
|
|
|
|
await pipeline.processBatch(results, 'sub-001', 'user-001');
|
|
|
|
expect(mockedDb.correlationGroup.create).toHaveBeenCalled();
|
|
});
|
|
|
|
it('returns empty array when all results are deduplicated', async () => {
|
|
mockedDb.subscription.findUnique.mockResolvedValue({ tier: 'premium' });
|
|
mockedDb.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 () => {
|
|
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: '[CRITICAL] 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).toBe(1);
|
|
});
|
|
});
|
|
|
|
describe('severity mapping', () => {
|
|
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: '[CRITICAL] Ownership Transfer detected',
|
|
message: 'Change',
|
|
severity: 'CRITICAL',
|
|
channel: ['email'],
|
|
createdAt: new Date('2026-05-14T12:00:00Z'),
|
|
});
|
|
|
|
const result = buildChangeResult({ severity: 'critical' });
|
|
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
|
|
|
|
expect(mockedDb.alert.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ severity: 'critical' })
|
|
})
|
|
);
|
|
});
|
|
|
|
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: '[WARNING] Deed Change detected',
|
|
message: 'Change',
|
|
severity: 'WARNING',
|
|
channel: ['email'],
|
|
createdAt: new Date('2026-05-14T12:00:00Z'),
|
|
});
|
|
|
|
const result = buildChangeResult({ severity: 'warning', changeType: 'deed_change' });
|
|
await pipeline.processChangeDetection(result, 'sub-001', 'user-001');
|
|
|
|
expect(mockedDb.alert.create).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ severity: 'warning' })
|
|
})
|
|
);
|
|
});
|
|
});
|
|
});
|