/** * Tests for interest scoring pure helper functions. * The exported `calculateInterestScore` hits the database, so we test the * scoring logic via the module-private helpers by re-implementing them inline * here (they are not exported from the module). Alternatively we test the * boundary conditions via vi.mock of the db/redis dependencies and exercising * the main function. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // ─── Mock heavy dependencies before importing the service ──────────────────── vi.mock('@/lib/db', () => ({ db: { query: { interests: { findFirst: vi.fn() }, }, select: vi.fn(), }, })); vi.mock('@/lib/redis', () => ({ redis: { get: vi.fn().mockResolvedValue(null), setex: vi.fn().mockResolvedValue('OK'), }, })); vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn() }, })); // Mock drizzle helpers used in the service (count, eq, gte, etc.) vi.mock('drizzle-orm', async (importOriginal) => { const actual = await importOriginal(); return { ...actual }; }); vi.mock('@/lib/db/schema/interests', () => ({ interests: {}, interestNotes: {}, })); vi.mock('@/lib/db/schema/operations', () => ({ reminders: {}, })); vi.mock('@/lib/db/schema/email', () => ({ emailThreads: {}, })); // next/server is not available in the vitest node environment vi.mock('next/server', () => ({ NextResponse: { json: vi.fn() }, })); import { calculateInterestScore } from '@/lib/services/interest-scoring.service'; import { db } from '@/lib/db'; import { redis } from '@/lib/redis'; // ─── Helpers ───────────────────────────────────────────────────────────────── /** Create a fake db.select chain that returns a fixed count result. */ function makeSelectChain(countValue: number) { const chain = { from: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue([{ value: countValue }]), }; return chain; } function daysAgo(days: number): Date { return new Date(Date.now() - days * 24 * 60 * 60 * 1000); } // ─── Tests ─────────────────────────────────────────────────────────────────── describe('calculateInterestScore', () => { beforeEach(() => { vi.clearAllMocks(); (redis.get as ReturnType).mockResolvedValue(null); (redis.setex as ReturnType).mockResolvedValue('OK'); }); it('score is always in the range 0-100', async () => { // Worst-case scenario: interest created 365 days ago, no docs, no engagement (db.query.interests.findFirst as ReturnType).mockResolvedValue({ id: 'i1', portId: 'p1', clientId: 'c1', createdAt: daysAgo(365), pipelineStage: 'open', eoiStatus: null, contractStatus: null, depositStatus: null, dateEoiSigned: null, dateContractSigned: null, dateDepositReceived: null, berthId: null, }); const selectChain = makeSelectChain(0); (db.select as ReturnType).mockReturnValue(selectChain); const result = await calculateInterestScore('i1', 'p1'); expect(result.totalScore).toBeGreaterThanOrEqual(0); expect(result.totalScore).toBeLessThanOrEqual(100); }); it('new interest (0 days, no docs, no engagement) → low total score', async () => { (db.query.interests.findFirst as ReturnType).mockResolvedValue({ id: 'i1', portId: 'p1', clientId: 'c1', createdAt: daysAgo(0), pipelineStage: 'open', eoiStatus: null, contractStatus: null, depositStatus: null, dateEoiSigned: null, dateContractSigned: null, dateDepositReceived: null, berthId: null, }); const selectChain = makeSelectChain(0); (db.select as ReturnType).mockReturnValue(selectChain); const result = await calculateInterestScore('i1', 'p1'); // pipelineAge=100, stageSpeed=0 (still open), docs=0, engagement=0, berth=0 // raw = 100/425*100 ≈ 24 expect(result.totalScore).toBeLessThan(30); expect(result.breakdown.stageSpeed).toBe(0); expect(result.breakdown.documentCompleteness).toBe(0); expect(result.breakdown.engagement).toBe(0); expect(result.breakdown.berthLinked).toBe(0); }); it('interest with all docs signed and berth linked → high total score', async () => { (db.query.interests.findFirst as ReturnType).mockResolvedValue({ id: 'i2', portId: 'p1', clientId: 'c1', createdAt: daysAgo(10), pipelineStage: 'contract_signed', eoiStatus: 'signed', contractStatus: 'signed', depositStatus: 'received', dateEoiSigned: daysAgo(5), dateContractSigned: daysAgo(3), dateDepositReceived: daysAgo(1), berthId: 'berth-1', }); // High engagement: 5 notes, 3 emails, 2 reminders const selectChain = { from: vi.fn().mockReturnThis(), where: vi .fn() .mockResolvedValueOnce([{ value: 5 }]) // notes .mockResolvedValueOnce([{ value: 2 }]) // reminders .mockResolvedValueOnce([{ value: 3 }]), // emails }; (db.select as ReturnType).mockReturnValue(selectChain); const result = await calculateInterestScore('i2', 'p1'); expect(result.totalScore).toBeGreaterThan(60); expect(result.breakdown.documentCompleteness).toBe(100); expect(result.breakdown.berthLinked).toBe(25); }); it('pipeline age: interest created 0-30 days ago → pipelineAge = 100', async () => { (db.query.interests.findFirst as ReturnType).mockResolvedValue({ id: 'i3', portId: 'p1', clientId: 'c1', createdAt: daysAgo(15), pipelineStage: 'open', eoiStatus: null, contractStatus: null, depositStatus: null, dateEoiSigned: null, dateContractSigned: null, dateDepositReceived: null, berthId: null, }); const selectChain = makeSelectChain(0); (db.select as ReturnType).mockReturnValue(selectChain); const result = await calculateInterestScore('i3', 'p1'); expect(result.breakdown.pipelineAge).toBe(100); }); it('pipeline age: interest created 180+ days ago → pipelineAge = 20', async () => { (db.query.interests.findFirst as ReturnType).mockResolvedValue({ id: 'i4', portId: 'p1', clientId: 'c1', createdAt: daysAgo(200), pipelineStage: 'open', eoiStatus: null, contractStatus: null, depositStatus: null, dateEoiSigned: null, dateContractSigned: null, dateDepositReceived: null, berthId: null, }); const selectChain = makeSelectChain(0); (db.select as ReturnType).mockReturnValue(selectChain); const result = await calculateInterestScore('i4', 'p1'); expect(result.breakdown.pipelineAge).toBe(20); }); it('document completeness: only EOI signed → score = 30', async () => { (db.query.interests.findFirst as ReturnType).mockResolvedValue({ id: 'i5', portId: 'p1', clientId: 'c1', createdAt: daysAgo(10), pipelineStage: 'open', eoiStatus: 'signed', contractStatus: null, depositStatus: null, dateEoiSigned: daysAgo(5), dateContractSigned: null, dateDepositReceived: null, berthId: null, }); const selectChain = makeSelectChain(0); (db.select as ReturnType).mockReturnValue(selectChain); const result = await calculateInterestScore('i5', 'p1'); expect(result.breakdown.documentCompleteness).toBe(30); }); it('berthLinked is 25 when berthId is set, 0 when null', async () => { const base = { portId: 'p1', clientId: 'c1', createdAt: daysAgo(10), pipelineStage: 'open', eoiStatus: null, contractStatus: null, depositStatus: null, dateEoiSigned: null, dateContractSigned: null, dateDepositReceived: null, }; const selectChain = makeSelectChain(0); (db.select as ReturnType).mockReturnValue(selectChain); (db.query.interests.findFirst as ReturnType).mockResolvedValue({ ...base, id: 'i6', berthId: 'b1', }); const withBerth = await calculateInterestScore('i6', 'p1'); expect(withBerth.breakdown.berthLinked).toBe(25); (redis.get as ReturnType).mockResolvedValue(null); (db.query.interests.findFirst as ReturnType).mockResolvedValue({ ...base, id: 'i7', berthId: null, }); const withoutBerth = await calculateInterestScore('i7', 'p1'); expect(withoutBerth.breakdown.berthLinked).toBe(0); }); it('throws when interest not found', async () => { (db.query.interests.findFirst as ReturnType).mockResolvedValue(null); await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow('Interest not found'); }); it('returns cached result when redis has a hit (after port-scope DB check)', async () => { // Security fix: the DB lookup runs FIRST to confirm the interest is // in the caller's port. Only then is the (port-scoped) cache key read. // A test that asserts the DB is bypassed would be asserting the // pre-fix bug; this test asserts the new ordering. const cachedScore = { totalScore: 42, breakdown: { pipelineAge: 80, stageSpeed: 0, documentCompleteness: 0, engagement: 0, berthLinked: 0, }, calculatedAt: new Date().toISOString(), }; (db.query.interests.findFirst as ReturnType).mockResolvedValue({ id: 'cached-id', portId: 'p1', clientId: 'c1', createdAt: daysAgo(10), pipelineStage: 'open', eoiStatus: null, contractStatus: null, depositStatus: null, dateEoiSigned: null, dateContractSigned: null, dateDepositReceived: null, berthId: null, }); (redis.get as ReturnType).mockResolvedValue(JSON.stringify(cachedScore)); const result = await calculateInterestScore('cached-id', 'p1'); expect(result.totalScore).toBe(42); // Port-scope check: the DB IS hit, but no other queries (notes/threads) // are needed since the cache served the score body. expect(db.query.interests.findFirst).toHaveBeenCalled(); }); });