Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
/**
|
|
|
|
|
* 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',
|
|
|
|
|
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(),
|
2026-04-29 02:48:43 +02:00
|
|
|
where: vi
|
|
|
|
|
.fn()
|
|
|
|
|
.mockResolvedValueOnce([{ value: 5 }]) // notes
|
|
|
|
|
.mockResolvedValueOnce([{ value: 2 }]) // reminders
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
.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);
|
|
|
|
|
|
2026-04-29 02:48:43 +02:00
|
|
|
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
|
|
|
...base,
|
|
|
|
|
id: 'i6',
|
|
|
|
|
berthId: 'b1',
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const withBerth = await calculateInterestScore('i6', 'p1');
|
|
|
|
|
expect(withBerth.breakdown.berthLinked).toBe(25);
|
|
|
|
|
|
|
|
|
|
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(null);
|
2026-04-29 02:48:43 +02:00
|
|
|
(db.query.interests.findFirst as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
|
|
|
...base,
|
|
|
|
|
id: 'i7',
|
|
|
|
|
berthId: null,
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
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');
|
|
|
|
|
});
|
|
|
|
|
|
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.
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
const cachedScore = {
|
|
|
|
|
totalScore: 42,
|
|
|
|
|
breakdown: {
|
|
|
|
|
pipelineAge: 80,
|
|
|
|
|
stageSpeed: 0,
|
|
|
|
|
documentCompleteness: 0,
|
|
|
|
|
engagement: 0,
|
|
|
|
|
berthLinked: 0,
|
|
|
|
|
},
|
|
|
|
|
calculatedAt: new Date().toISOString(),
|
|
|
|
|
};
|
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,
|
|
|
|
|
});
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
(redis.get as ReturnType<typeof vi.fn>).mockResolvedValue(JSON.stringify(cachedScore));
|
|
|
|
|
|
|
|
|
|
const result = await calculateInterestScore('cached-id', 'p1');
|
|
|
|
|
expect(result.totalScore).toBe(42);
|
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();
|
Initial commit: Port Nimara CRM (Layers 0-4)
Full CRM rebuild with Next.js 15, TypeScript, Tailwind, Drizzle ORM,
PostgreSQL, Redis, BullMQ, MinIO, and Socket.io. Includes 461 source
files covering clients, berths, interests/pipeline, documents/EOI,
expenses/invoices, email, notifications, dashboard, admin, and
client portal. CI/CD via Gitea Actions with Docker builds.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:52:51 +01:00
|
|
|
});
|
|
|
|
|
});
|