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:
555
services/hometitle/test/integration.test.ts
Normal file
555
services/hometitle/test/integration.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user