306 lines
10 KiB
TypeScript
306 lines
10 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
detectChanges,
|
|
shouldTriggerAlert,
|
|
determineSeverity,
|
|
computeChangeConfidence,
|
|
} from '../src/change-detector';
|
|
import { PropertySnapshot, PropertyChange, DetectionConfig } from '../src/types';
|
|
|
|
const baselineSnapshot: PropertySnapshot = {
|
|
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',
|
|
},
|
|
deedDate: '2020-03-15',
|
|
taxId: 'tax-123',
|
|
propertyType: 'residential',
|
|
taxAmount: 2500,
|
|
lienCount: 0,
|
|
};
|
|
|
|
describe('detectChanges', () => {
|
|
it('detects ownership transfer via name change', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
ownerName: 'Jane Smith',
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.changeType).toBe('ownership_transfer');
|
|
expect(result.severity).toBe('critical');
|
|
expect(result.changes.some(c => c.field === 'ownerName')).toBe(true);
|
|
});
|
|
|
|
it('detects deed change via deed date update', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
deedDate: '2026-01-15',
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.changes.some(c => c.changeType === 'deed_change')).toBe(true);
|
|
expect(result.severity).toBe('warning');
|
|
});
|
|
|
|
it('detects tax change', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
taxAmount: 3200,
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.changes.some(c => c.changeType === 'tax_change')).toBe(true);
|
|
expect(result.severity).toBe('info');
|
|
});
|
|
|
|
it('detects lien filing when lien count increases', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
lienCount: 1,
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.changes.some(c => c.changeType === 'lien_filing')).toBe(true);
|
|
expect(result.severity).toBe('warning');
|
|
});
|
|
|
|
it('detects multiple changes with highest severity', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
ownerName: 'Jane Smith',
|
|
deedDate: '2026-01-15',
|
|
taxAmount: 3200,
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.severity).toBe('critical');
|
|
expect(result.changes.length).toBeGreaterThanOrEqual(3);
|
|
});
|
|
|
|
it('returns no changes for identical snapshots', () => {
|
|
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('info');
|
|
});
|
|
|
|
it('detects address changes as metadata changes', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
address: {
|
|
...baselineSnapshot.address,
|
|
streetNumber: '125',
|
|
},
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.changes.some(c => c.field === 'address.streetNumber')).toBe(true);
|
|
});
|
|
|
|
it('detects tax ID change as deed change', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
taxId: 'tax-456',
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.changes.some(c => c.changeType === 'deed_change')).toBe(true);
|
|
});
|
|
|
|
it('respects configurable ownership threshold', () => {
|
|
const config: DetectionConfig = {
|
|
ownershipNameThreshold: 0.5,
|
|
deedDateSensitivity: 0.9,
|
|
taxAmountChangePercent: 15,
|
|
};
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
ownerName: 'Jon Doe',
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current, config);
|
|
expect(result.changes.some(c => c.field === 'ownerName')).toBe(true);
|
|
});
|
|
|
|
it('populates previous and current snapshots in result', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
ownerName: 'Jane Smith',
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.previousSnapshot).toBe(baselineSnapshot);
|
|
expect(result.currentSnapshot).toBe(current);
|
|
});
|
|
|
|
it('includes detectedAt timestamp', () => {
|
|
const current = {
|
|
...baselineSnapshot,
|
|
id: 'snap-2',
|
|
capturedAt: '2026-02-01T00:00:00Z',
|
|
ownerName: 'Jane Smith',
|
|
};
|
|
const result = detectChanges(baselineSnapshot, current);
|
|
expect(result.detectedAt).toBeDefined();
|
|
expect(new Date(result.detectedAt).getTime()).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe('shouldTriggerAlert', () => {
|
|
it('triggers for critical severity above default threshold', () => {
|
|
const result = {
|
|
propertyId: 'prop-001',
|
|
changeType: 'ownership_transfer' as const,
|
|
severity: 'critical' as const,
|
|
confidence: 0.95,
|
|
changes: [],
|
|
previousSnapshot: baselineSnapshot,
|
|
currentSnapshot: baselineSnapshot,
|
|
detectedAt: new Date().toISOString(),
|
|
};
|
|
expect(shouldTriggerAlert(result)).toBe(true);
|
|
});
|
|
|
|
it('triggers for warning severity with high confidence', () => {
|
|
const result = {
|
|
propertyId: 'prop-001',
|
|
changeType: 'deed_change' as const,
|
|
severity: 'warning' as const,
|
|
confidence: 0.85,
|
|
changes: [],
|
|
previousSnapshot: baselineSnapshot,
|
|
currentSnapshot: baselineSnapshot,
|
|
detectedAt: new Date().toISOString(),
|
|
};
|
|
expect(shouldTriggerAlert(result)).toBe(true);
|
|
});
|
|
|
|
it('does not trigger for info severity with default threshold', () => {
|
|
const result = {
|
|
propertyId: 'prop-001',
|
|
changeType: 'tax_change' as const,
|
|
severity: 'info' as const,
|
|
confidence: 0.85,
|
|
changes: [],
|
|
previousSnapshot: baselineSnapshot,
|
|
currentSnapshot: baselineSnapshot,
|
|
detectedAt: new Date().toISOString(),
|
|
};
|
|
expect(shouldTriggerAlert(result)).toBe(false);
|
|
});
|
|
|
|
it('does not trigger when confidence below 0.7', () => {
|
|
const result = {
|
|
propertyId: 'prop-001',
|
|
changeType: 'deed_change' as const,
|
|
severity: 'warning' as const,
|
|
confidence: 0.5,
|
|
changes: [],
|
|
previousSnapshot: baselineSnapshot,
|
|
currentSnapshot: baselineSnapshot,
|
|
detectedAt: new Date().toISOString(),
|
|
};
|
|
expect(shouldTriggerAlert(result)).toBe(false);
|
|
});
|
|
|
|
it('triggers info when minSeverity set to info', () => {
|
|
const result = {
|
|
propertyId: 'prop-001',
|
|
changeType: 'tax_change' as const,
|
|
severity: 'info' as const,
|
|
confidence: 0.85,
|
|
changes: [],
|
|
previousSnapshot: baselineSnapshot,
|
|
currentSnapshot: baselineSnapshot,
|
|
detectedAt: new Date().toISOString(),
|
|
};
|
|
expect(shouldTriggerAlert(result, 'info')).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('determineSeverity', () => {
|
|
it('returns major when ownership transfer present', () => {
|
|
const changes: PropertyChange[] = [
|
|
{ field: 'ownerName', oldValue: 'John', newValue: 'Jane', changeType: 'ownership_transfer' },
|
|
];
|
|
expect(determineSeverity(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe('critical');
|
|
});
|
|
|
|
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('warning');
|
|
});
|
|
|
|
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('info');
|
|
});
|
|
|
|
it('respects severity overrides', () => {
|
|
const changes: PropertyChange[] = [
|
|
{ field: 'taxAmount', oldValue: 1000, newValue: 2000, changeType: 'tax_change' },
|
|
];
|
|
const config: DetectionConfig = {
|
|
ownershipNameThreshold: 0.7,
|
|
deedDateSensitivity: 0.9,
|
|
taxAmountChangePercent: 15,
|
|
severityOverrides: { tax_change: 'warning' },
|
|
};
|
|
expect(determineSeverity(changes, config)).toBe('warning');
|
|
});
|
|
});
|
|
|
|
describe('computeChangeConfidence', () => {
|
|
it('returns 0 for empty changes', () => {
|
|
expect(computeChangeConfidence([], { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 })).toBe(0);
|
|
});
|
|
|
|
it('returns high confidence for ownership transfer', () => {
|
|
const changes: PropertyChange[] = [
|
|
{ field: 'ownerName', oldValue: 'John', newValue: 'Jane', changeType: 'ownership_transfer' },
|
|
];
|
|
const conf = computeChangeConfidence(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 });
|
|
expect(conf).toBeCloseTo(0.95, 2);
|
|
});
|
|
|
|
it('returns high confidence for lien filing', () => {
|
|
const changes: PropertyChange[] = [
|
|
{ field: 'lienCount', oldValue: 0, newValue: 1, changeType: 'lien_filing' },
|
|
];
|
|
const conf = computeChangeConfidence(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 });
|
|
expect(conf).toBeCloseTo(0.9, 2);
|
|
});
|
|
|
|
it('averages confidence across multiple changes', () => {
|
|
const changes: PropertyChange[] = [
|
|
{ field: 'ownerName', oldValue: 'John', newValue: 'Jane', changeType: 'ownership_transfer' },
|
|
{ field: 'taxAmount', oldValue: 1000, newValue: 2000, changeType: 'tax_change' },
|
|
];
|
|
const conf = computeChangeConfidence(changes, { ownershipNameThreshold: 0.7, deedDateSensitivity: 0.9, taxAmountChangePercent: 15 });
|
|
expect(conf).toBeGreaterThan(0.7);
|
|
expect(conf).toBeLessThan(1.0);
|
|
});
|
|
});
|