/** * Engine integration test - drives `runAlertEngineForPorts` against * seeded conditions and asserts: (1) correct alerts upsert, (2) running * twice doesn't duplicate, (3) mutating state auto-resolves stale alerts. * * Socket emissions are stubbed via vi.mock so the test stays offline. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { and, eq, isNull } from 'drizzle-orm'; vi.mock('@/lib/socket/server', () => ({ emitToRoom: vi.fn(), })); import { db } from '@/lib/db'; import { alerts } from '@/lib/db/schema/insights'; import { interests } from '@/lib/db/schema/interests'; import { berthTenancies } from '@/lib/db/schema/tenancies'; import { documents } from '@/lib/db/schema/documents'; import { interestContactLog } from '@/lib/db/schema/operations'; import { migrationSourceLinks } from '@/lib/db/schema/migration'; import { runAlertEngineForPorts } from '@/lib/services/alert-engine'; import { makePort, makeClient, makeBerth, makeYacht } from '../helpers/factories'; async function clearAlerts(portId: string) { await db.delete(alerts).where(eq(alerts.portId, portId)); } async function listOpenAlerts(portId: string, ruleId: string) { return db .select() .from(alerts) .where(and(eq(alerts.portId, portId), eq(alerts.ruleId, ruleId), isNull(alerts.resolvedAt))); } /** Mark an interest as bulk-imported via the migration ledger. */ async function markImported(interestId: string) { await db.insert(migrationSourceLinks).values({ sourceSystem: 'nocodb_interests', sourceId: `legacy-${interestId}`, targetEntityType: 'interest', targetEntityId: interestId, appliedId: 'test-apply', }); } /** A genuine in-system follow-up: a logged contact at `occurredAt`. */ async function logContact(portId: string, interestId: string, occurredAt: Date) { await db.insert(interestContactLog).values({ portId, interestId, occurredAt, channel: 'phone', direction: 'outbound', summary: 'Test follow-up', createdBy: 'seed', }); } describe('alert engine', () => { beforeEach(() => { vi.clearAllMocks(); }); it('reservation.no_agreement fires for active reservation older than 3 days without agreement', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const berth = await makeBerth({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, overrides: { name: 'M/Y Test' }, }); const fourDaysAgo = new Date(Date.now() - 4 * 86_400_000); const [resv] = await db .insert(berthTenancies) .values({ portId: port.id, berthId: berth.id, clientId: client.id, yachtId: yacht.id, status: 'active', startDate: new Date(), createdBy: 'seed', createdAt: fourDaysAgo, }) .returning(); expect(resv).toBeDefined(); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); const open = await listOpenAlerts(port.id, 'reservation.no_agreement'); expect(open).toHaveLength(1); expect(open[0]!.entityId).toBe(resv!.id); expect(open[0]!.severity).toBe('warning'); }); it('does not duplicate on a second sweep', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const berth = await makeBerth({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const stale = new Date(Date.now() - 10 * 86_400_000); await db.insert(berthTenancies).values({ portId: port.id, berthId: berth.id, clientId: client.id, yachtId: yacht.id, status: 'active', startDate: new Date(), createdBy: 'seed', createdAt: stale, }); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); await runAlertEngineForPorts([port.id]); const open = await listOpenAlerts(port.id, 'reservation.no_agreement'); expect(open).toHaveLength(1); }); it('auto-resolves an open alert when the underlying condition clears', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const berth = await makeBerth({ portId: port.id }); const yacht = await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const tenDaysAgo = new Date(Date.now() - 10 * 86_400_000); const [resv] = await db .insert(berthTenancies) .values({ portId: port.id, berthId: berth.id, clientId: client.id, yachtId: yacht.id, status: 'active', startDate: new Date(), createdBy: 'seed', createdAt: tenDaysAgo, }) .returning(); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); expect(await listOpenAlerts(port.id, 'reservation.no_agreement')).toHaveLength(1); // Add an agreement document - condition no longer fires. await db.insert(documents).values({ portId: port.id, tenancyId: resv!.id, documentType: 'reservation_agreement', title: 'Reservation Agreement', status: 'sent', createdBy: 'seed', }); await runAlertEngineForPorts([port.id]); expect(await listOpenAlerts(port.id, 'reservation.no_agreement')).toHaveLength(0); const allRows = await db .select() .from(alerts) .where(and(eq(alerts.portId, port.id), eq(alerts.ruleId, 'reservation.no_agreement'))); expect(allRows).toHaveLength(1); expect(allRows[0]!.resolvedAt).not.toBeNull(); }); it('interest.stale fires for worked leads gone quiet >14d', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const stale = new Date(Date.now() - 30 * 86_400_000); const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, pipelineStage: 'qualified', dateLastContact: stale, createdAt: stale, updatedAt: stale, }) .returning(); // A real in-system follow-up 30 days ago → this is a worked-then-quiet lead, // not an untouched import. await logContact(port.id, interest!.id, stale); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); const open = await listOpenAlerts(port.id, 'interest.stale'); expect(open).toHaveLength(1); expect(open[0]!.entityId).toBe(interest!.id); expect(open[0]!.severity).toBe('info'); // A worked lead must not also fire the new-untouched rule. expect(await listOpenAlerts(port.id, 'interest.no_activity')).toHaveLength(0); }); it('interest.stale does NOT fire for imported, never-touched interests', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const migrationTime = new Date(Date.now() - 20 * 86_400_000); const legacyDate = new Date(Date.now() - 3 * 365 * 86_400_000); // ~3yr back-dated const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, pipelineStage: 'qualified', dateLastContact: legacyDate, // back-dated by the migration createdAt: migrationTime, updatedAt: migrationTime, }) .returning(); await markImported(interest!.id); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); // Imported + never touched in-system → neither interest rule should fire. expect(await listOpenAlerts(port.id, 'interest.stale')).toHaveLength(0); expect(await listOpenAlerts(port.id, 'interest.no_activity')).toHaveLength(0); }); it('interest.no_activity fires for new, non-imported, untouched interests >14d old', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const created = new Date(Date.now() - 20 * 86_400_000); const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, pipelineStage: 'enquiry', dateLastContact: null, createdAt: created, updatedAt: created, }) .returning(); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); const open = await listOpenAlerts(port.id, 'interest.no_activity'); expect(open).toHaveLength(1); expect(open[0]!.entityId).toBe(interest!.id); expect(open[0]!.severity).toBe('info'); expect(await listOpenAlerts(port.id, 'interest.stale')).toHaveLength(0); }); it('interest.high_value_silent fires for hot leads silent >7d', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const stale = new Date(Date.now() - 10 * 86_400_000); await db.insert(interests).values({ portId: port.id, clientId: client.id, pipelineStage: 'qualified', leadCategory: 'hot_lead', dateLastContact: stale, updatedAt: stale, }); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); const open = await listOpenAlerts(port.id, 'interest.high_value_silent'); expect(open).toHaveLength(1); expect(open[0]!.severity).toBe('critical'); }); it('interest.high_value_silent skips imported, never-touched hot leads', async () => { const port = await makePort(); const client = await makeClient({ portId: port.id }); const migrationTime = new Date(Date.now() - 20 * 86_400_000); const legacyDate = new Date(Date.now() - 3 * 365 * 86_400_000); const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, pipelineStage: 'qualified', leadCategory: 'hot_lead', dateLastContact: legacyDate, createdAt: migrationTime, updatedAt: migrationTime, }) .returning(); await markImported(interest!.id); await clearAlerts(port.id); await runAlertEngineForPorts([port.id]); expect(await listOpenAlerts(port.id, 'interest.high_value_silent')).toHaveLength(0); }); it('engine reports rule errors without crashing the sweep', async () => { const port = await makePort(); const summary = await runAlertEngineForPorts([port.id]); expect(summary.portsScanned).toBe(1); expect(summary.rulesEvaluated).toBeGreaterThan(0); // No conditions seeded - no rules should fail. expect(summary.errors).toHaveLength(0); }); });