Files
pn-new-crm/tests/unit/interest-scoring.test.ts

320 lines
10 KiB
TypeScript
Raw Normal View History

/**
* 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<typeof import('drizzle-orm')>();
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<typeof vi.fn>).mockResolvedValue(null);
(redis.setex as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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(),
sec: lock down 5 cross-tenant IDORs uncovered in second-pass review 1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin (manage_settings) mutate any other tenant's port row by passing the foreign id in the path. Now non-super-admins must target their own ctx.portId; listPorts and createPort are super-admin only. 2. HIGH — Invoice create/update accepted arbitrary expenseIds and linked them into invoice_expenses with no port check; the GET response then re-emitted those foreign expense rows via the linkedExpenses join. assertExpensesInPort now validates each id belongs to the caller's portId before insert; getInvoiceById's join filters by expenses.portId as defense-in-depth. 3. HIGH — Document creation paths (createDocument, createFromWizard, createFromUpload) persisted user-supplied clientId/interestId/ companyId/yachtId/reservationId without verifying those FKs were in-port. sendForSigning then loaded the foreign client/interest by id alone and pushed their PII into the Documenso payload. New assertSubjectFksInPort helper rejects out-of-port FKs at create time; sendForSigning's interest+client lookups now also filter by portId. 4. MEDIUM — calculateInterestScore read its redis cache before verifying portId, and the cache key was interestId-only — a foreign-port caller could observe a cached score breakdown. Cache key now includes portId, and the port-scope DB lookup runs before any cache.get. 5. MEDIUM — AI email-draft job results were retrievable by anyone who could guess the BullMQ jobId (default sequential integers). Job ids are now random UUIDs, requestEmailDraft validates interestId/ clientId belong to ctx.portId before enqueueing, the worker's client lookup is port-scoped, and getEmailDraftResult requires the caller to match the original requester's userId+portId before returning the drafted subject/body. The interest-scoring unit test that asserted "DB is bypassed on cache hit" is updated to reflect the new (security-correct) ordering. Two new regression test files cover the email-draft binding (5 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
where: vi
.fn()
.mockResolvedValueOnce([{ value: 5 }]) // notes
.mockResolvedValueOnce([{ value: 2 }]) // reminders
.mockResolvedValueOnce([{ value: 3 }]), // emails
};
(db.select as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(selectChain);
sec: lock down 5 cross-tenant IDORs uncovered in second-pass review 1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin (manage_settings) mutate any other tenant's port row by passing the foreign id in the path. Now non-super-admins must target their own ctx.portId; listPorts and createPort are super-admin only. 2. HIGH — Invoice create/update accepted arbitrary expenseIds and linked them into invoice_expenses with no port check; the GET response then re-emitted those foreign expense rows via the linkedExpenses join. assertExpensesInPort now validates each id belongs to the caller's portId before insert; getInvoiceById's join filters by expenses.portId as defense-in-depth. 3. HIGH — Document creation paths (createDocument, createFromWizard, createFromUpload) persisted user-supplied clientId/interestId/ companyId/yachtId/reservationId without verifying those FKs were in-port. sendForSigning then loaded the foreign client/interest by id alone and pushed their PII into the Documenso payload. New assertSubjectFksInPort helper rejects out-of-port FKs at create time; sendForSigning's interest+client lookups now also filter by portId. 4. MEDIUM — calculateInterestScore read its redis cache before verifying portId, and the cache key was interestId-only — a foreign-port caller could observe a cached score breakdown. Cache key now includes portId, and the port-scope DB lookup runs before any cache.get. 5. MEDIUM — AI email-draft job results were retrievable by anyone who could guess the BullMQ jobId (default sequential integers). Job ids are now random UUIDs, requestEmailDraft validates interestId/ clientId belong to ctx.portId before enqueueing, the worker's client lookup is port-scoped, and getEmailDraftResult requires the caller to match the original requester's userId+portId before returning the drafted subject/body. The interest-scoring unit test that asserted "DB is bypassed on cache hit" is updated to reflect the new (security-correct) ordering. Two new regression test files cover the email-draft binding (5 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
...base,
id: 'i6',
berthId: 'b1',
});
const withBerth = await calculateInterestScore('i6', 'p1');
expect(withBerth.breakdown.berthLinked).toBe(25);
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(null);
sec: lock down 5 cross-tenant IDORs uncovered in second-pass review 1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin (manage_settings) mutate any other tenant's port row by passing the foreign id in the path. Now non-super-admins must target their own ctx.portId; listPorts and createPort are super-admin only. 2. HIGH — Invoice create/update accepted arbitrary expenseIds and linked them into invoice_expenses with no port check; the GET response then re-emitted those foreign expense rows via the linkedExpenses join. assertExpensesInPort now validates each id belongs to the caller's portId before insert; getInvoiceById's join filters by expenses.portId as defense-in-depth. 3. HIGH — Document creation paths (createDocument, createFromWizard, createFromUpload) persisted user-supplied clientId/interestId/ companyId/yachtId/reservationId without verifying those FKs were in-port. sendForSigning then loaded the foreign client/interest by id alone and pushed their PII into the Documenso payload. New assertSubjectFksInPort helper rejects out-of-port FKs at create time; sendForSigning's interest+client lookups now also filter by portId. 4. MEDIUM — calculateInterestScore read its redis cache before verifying portId, and the cache key was interestId-only — a foreign-port caller could observe a cached score breakdown. Cache key now includes portId, and the port-scope DB lookup runs before any cache.get. 5. MEDIUM — AI email-draft job results were retrievable by anyone who could guess the BullMQ jobId (default sequential integers). Job ids are now random UUIDs, requestEmailDraft validates interestId/ clientId belong to ctx.portId before enqueueing, the worker's client lookup is port-scoped, and getEmailDraftResult requires the caller to match the original requester's userId+portId before returning the drafted subject/body. The interest-scoring unit test that asserted "DB is bypassed on cache hit" is updated to reflect the new (security-correct) ordering. Two new regression test files cover the email-draft binding (5 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(null);
await expect(calculateInterestScore('missing', 'p1')).rejects.toThrow('Interest not found');
});
sec: lock down 5 cross-tenant IDORs uncovered in second-pass review 1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin (manage_settings) mutate any other tenant's port row by passing the foreign id in the path. Now non-super-admins must target their own ctx.portId; listPorts and createPort are super-admin only. 2. HIGH — Invoice create/update accepted arbitrary expenseIds and linked them into invoice_expenses with no port check; the GET response then re-emitted those foreign expense rows via the linkedExpenses join. assertExpensesInPort now validates each id belongs to the caller's portId before insert; getInvoiceById's join filters by expenses.portId as defense-in-depth. 3. HIGH — Document creation paths (createDocument, createFromWizard, createFromUpload) persisted user-supplied clientId/interestId/ companyId/yachtId/reservationId without verifying those FKs were in-port. sendForSigning then loaded the foreign client/interest by id alone and pushed their PII into the Documenso payload. New assertSubjectFksInPort helper rejects out-of-port FKs at create time; sendForSigning's interest+client lookups now also filter by portId. 4. MEDIUM — calculateInterestScore read its redis cache before verifying portId, and the cache key was interestId-only — a foreign-port caller could observe a cached score breakdown. Cache key now includes portId, and the port-scope DB lookup runs before any cache.get. 5. MEDIUM — AI email-draft job results were retrievable by anyone who could guess the BullMQ jobId (default sequential integers). Job ids are now random UUIDs, requestEmailDraft validates interestId/ clientId belong to ctx.portId before enqueueing, the worker's client lookup is port-scoped, and getEmailDraftResult requires the caller to match the original requester's userId+portId before returning the drafted subject/body. The interest-scoring unit test that asserted "DB is bypassed on cache hit" is updated to reflect the new (security-correct) ordering. Two new regression test files cover the email-draft binding (5 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
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(),
};
sec: lock down 5 cross-tenant IDORs uncovered in second-pass review 1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin (manage_settings) mutate any other tenant's port row by passing the foreign id in the path. Now non-super-admins must target their own ctx.portId; listPorts and createPort are super-admin only. 2. HIGH — Invoice create/update accepted arbitrary expenseIds and linked them into invoice_expenses with no port check; the GET response then re-emitted those foreign expense rows via the linkedExpenses join. assertExpensesInPort now validates each id belongs to the caller's portId before insert; getInvoiceById's join filters by expenses.portId as defense-in-depth. 3. HIGH — Document creation paths (createDocument, createFromWizard, createFromUpload) persisted user-supplied clientId/interestId/ companyId/yachtId/reservationId without verifying those FKs were in-port. sendForSigning then loaded the foreign client/interest by id alone and pushed their PII into the Documenso payload. New assertSubjectFksInPort helper rejects out-of-port FKs at create time; sendForSigning's interest+client lookups now also filter by portId. 4. MEDIUM — calculateInterestScore read its redis cache before verifying portId, and the cache key was interestId-only — a foreign-port caller could observe a cached score breakdown. Cache key now includes portId, and the port-scope DB lookup runs before any cache.get. 5. MEDIUM — AI email-draft job results were retrievable by anyone who could guess the BullMQ jobId (default sequential integers). Job ids are now random UUIDs, requestEmailDraft validates interestId/ clientId belong to ctx.portId before enqueueing, the worker's client lookup is port-scoped, and getEmailDraftResult requires the caller to match the original requester's userId+portId before returning the drafted subject/body. The interest-scoring unit test that asserted "DB is bypassed on cache hit" is updated to reflect the new (security-correct) ordering. Two new regression test files cover the email-draft binding (5 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockResolvedValue(JSON.stringify(cachedScore));
const result = await calculateInterestScore('cached-id', 'p1');
expect(result.totalScore).toBe(42);
sec: lock down 5 cross-tenant IDORs uncovered in second-pass review 1. HIGH — /api/v1/admin/ports/[id] PATCH+GET let any port-admin (manage_settings) mutate any other tenant's port row by passing the foreign id in the path. Now non-super-admins must target their own ctx.portId; listPorts and createPort are super-admin only. 2. HIGH — Invoice create/update accepted arbitrary expenseIds and linked them into invoice_expenses with no port check; the GET response then re-emitted those foreign expense rows via the linkedExpenses join. assertExpensesInPort now validates each id belongs to the caller's portId before insert; getInvoiceById's join filters by expenses.portId as defense-in-depth. 3. HIGH — Document creation paths (createDocument, createFromWizard, createFromUpload) persisted user-supplied clientId/interestId/ companyId/yachtId/reservationId without verifying those FKs were in-port. sendForSigning then loaded the foreign client/interest by id alone and pushed their PII into the Documenso payload. New assertSubjectFksInPort helper rejects out-of-port FKs at create time; sendForSigning's interest+client lookups now also filter by portId. 4. MEDIUM — calculateInterestScore read its redis cache before verifying portId, and the cache key was interestId-only — a foreign-port caller could observe a cached score breakdown. Cache key now includes portId, and the port-scope DB lookup runs before any cache.get. 5. MEDIUM — AI email-draft job results were retrievable by anyone who could guess the BullMQ jobId (default sequential integers). Job ids are now random UUIDs, requestEmailDraft validates interestId/ clientId belong to ctx.portId before enqueueing, the worker's client lookup is port-scoped, and getEmailDraftResult requires the caller to match the original requester's userId+portId before returning the drafted subject/body. The interest-scoring unit test that asserted "DB is bypassed on cache hit" is updated to reflect the new (security-correct) ordering. Two new regression test files cover the email-draft binding (5 tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 02:48:43 +02:00
// 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();
});
});