171 lines
5.2 KiB
TypeScript
171 lines
5.2 KiB
TypeScript
|
|
import { describe, it, expect } from 'vitest';
|
||
|
|
import { computeDealHealth } from '@/lib/services/deal-health';
|
||
|
|
|
||
|
|
const daysAgo = (n: number): string => new Date(Date.now() - n * 86_400_000).toISOString();
|
||
|
|
|
||
|
|
describe('computeDealHealth', () => {
|
||
|
|
it('returns baseline 50 + warm pulse when no signals fire', () => {
|
||
|
|
const r = computeDealHealth({ pipelineStage: 'enquiry' });
|
||
|
|
expect(r.score).toBe(50);
|
||
|
|
expect(r.pulse).toBe('warm');
|
||
|
|
expect(r.signals).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns neutral baseline when interest is closed/won', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'contract',
|
||
|
|
outcome: 'won',
|
||
|
|
dateLastContact: daysAgo(2),
|
||
|
|
});
|
||
|
|
expect(r.score).toBe(50);
|
||
|
|
expect(r.signals).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('returns neutral baseline when interest is archived', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'eoi',
|
||
|
|
archivedAt: daysAgo(10),
|
||
|
|
dateLastContact: daysAgo(2),
|
||
|
|
});
|
||
|
|
expect(r.signals).toEqual([]);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('+20 for contact in last 7 days', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'enquiry',
|
||
|
|
dateLastContact: daysAgo(3),
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'contact_recent');
|
||
|
|
expect(sig?.delta).toBe(20);
|
||
|
|
expect(r.score).toBeGreaterThanOrEqual(70);
|
||
|
|
expect(r.pulse).toBe('hot');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('+10 for contact within 14 days', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'enquiry',
|
||
|
|
dateLastContact: daysAgo(10),
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'contact_warm');
|
||
|
|
expect(sig?.delta).toBe(10);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('-15 when no contact in 30+ days', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'qualified',
|
||
|
|
dateLastContact: daysAgo(40),
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'contact_stale');
|
||
|
|
expect(sig?.delta).toBe(-15);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('-10 when EOI sent >14d ago and still unsigned', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'eoi',
|
||
|
|
dateEoiSent: daysAgo(20),
|
||
|
|
eoiDocStatus: 'sent',
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'eoi_awaiting');
|
||
|
|
expect(sig?.delta).toBe(-10);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does NOT penalize EOI awaiting when sub-status is signed', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'eoi',
|
||
|
|
dateEoiSent: daysAgo(20),
|
||
|
|
eoiDocStatus: 'signed',
|
||
|
|
});
|
||
|
|
expect(r.signals.find((s) => s.id === 'eoi_awaiting')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('-10 when reservation signed >21d ago but no deposit', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'reservation',
|
||
|
|
dateReservationSigned: daysAgo(25),
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'deposit_pending');
|
||
|
|
expect(sig?.delta).toBe(-10);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('does NOT penalize deposit_pending when deposit already received', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'deposit_paid',
|
||
|
|
dateReservationSigned: daysAgo(25),
|
||
|
|
dateDepositReceived: daysAgo(2),
|
||
|
|
});
|
||
|
|
expect(r.signals.find((s) => s.id === 'deposit_pending')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('stage progress bonus caps at +30', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'contract',
|
||
|
|
contractDocStatus: 'signed',
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'stage_progress');
|
||
|
|
expect(sig?.delta).toBeLessThanOrEqual(30);
|
||
|
|
expect(sig?.delta).toBeGreaterThan(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('-10 for deals stuck pre-EOI for 30+ days', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'enquiry',
|
||
|
|
dateFirstContact: daysAgo(45),
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'stuck_top_funnel');
|
||
|
|
expect(sig?.delta).toBe(-10);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('clamps score to [0, 100]', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'contract',
|
||
|
|
dateLastContact: daysAgo(2),
|
||
|
|
contractDocStatus: 'signed',
|
||
|
|
});
|
||
|
|
expect(r.score).toBeLessThanOrEqual(100);
|
||
|
|
expect(r.score).toBeGreaterThanOrEqual(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rates a fresh contract-signed deal as hot', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'contract',
|
||
|
|
dateLastContact: daysAgo(1),
|
||
|
|
contractDocStatus: 'signed',
|
||
|
|
});
|
||
|
|
expect(r.pulse).toBe('hot');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('rates a stalled mid-funnel deal with no recent contact as cold', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'qualified',
|
||
|
|
dateFirstContact: daysAgo(60),
|
||
|
|
dateLastContact: daysAgo(45),
|
||
|
|
});
|
||
|
|
expect(r.pulse).toBe('cold');
|
||
|
|
});
|
||
|
|
|
||
|
|
it('+5 active_engagement when recentActivityCount >= 1', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'enquiry',
|
||
|
|
recentActivityCount: 3,
|
||
|
|
});
|
||
|
|
const sig = r.signals.find((s) => s.id === 'active_engagement');
|
||
|
|
expect(sig?.delta).toBe(5);
|
||
|
|
});
|
||
|
|
|
||
|
|
it('skips active_engagement when recentActivityCount is null/undefined', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'enquiry',
|
||
|
|
recentActivityCount: null,
|
||
|
|
});
|
||
|
|
expect(r.signals.find((s) => s.id === 'active_engagement')).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
it('skips active_engagement when recentActivityCount is 0', () => {
|
||
|
|
const r = computeDealHealth({
|
||
|
|
pipelineStage: 'enquiry',
|
||
|
|
recentActivityCount: 0,
|
||
|
|
});
|
||
|
|
expect(r.signals.find((s) => s.id === 'active_engagement')).toBeUndefined();
|
||
|
|
});
|
||
|
|
});
|