Files
pn-new-crm/tests/integration/analytics-service.test.ts
Matt Ciaccio 886119cbde refactor(sales): consolidate pipeline stages + wire EOI auto-advance
The 8→9 stage refresh from earlier today only updated constants.ts and the DB —
20 component/service files still hardcoded the old enum, leaving labels blank,
filter dropdowns wrong, kanban columns mismatched, and the analytics funnel
silently dropping new-stage rows. The platform also never advanced
pipelineStage on EOI lifecycle events: documents.service.ts wrote eoiStatus
but left the user-visible stage stuck.

This commit closes both gaps:

  1. Single source of truth in src/lib/constants.ts — adds STAGE_LABELS,
     STAGE_BADGE, STAGE_DOT, STAGE_WEIGHTS, STAGE_TRANSITIONS plus
     stageLabel / stageBadgeClass / stageDotClass / safeStage /
     canTransitionStage helpers. components/clients/pipeline-constants.ts
     becomes a re-export shim so existing imports keep working.

  2. 18 stale-enum surfaces migrated — interest list (table, card, filters,
     form, stage picker), pipeline board, client card, berth interests tab,
     portal client interests page, dashboard pipeline / funnel / revenue-
     forecast charts, settings pipeline_weights default, dashboard.service
     weights, analytics.service funnel stages, alert-rules stale-interest
     filter, interest-scoring stage rank.

  3. Documents tab wired into interest detail — replaced the placeholder in
     interest-tabs.tsx with InterestDocumentsTab + InterestFilesTab so the
     EOI launcher is back where salespeople work.

  4. Auto-advance — new advanceStageIfBehind() in interests.service.ts
     (forward-only, no-op if interest is already past the target). Called
     from documents.service.ts on send (→ eoi_sent), Documenso completed
     webhook (→ eoi_signed), and manual signed-EOI upload (→ eoi_signed).

  5. Transition guard — canTransitionStage() blocks egregious skips
     (e.g. completed → open, open → contract_signed). Enforced in
     changeInterestStage before the DB write.

Tests updated to reflect the 9-stage model. tsc clean, vitest 832/832,
ESLint clean on every file touched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 23:33:53 +02:00

216 lines
7.6 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Analytics service integration tests — exercise the four computations
* against a seeded port + assert the cache layer reads/writes correctly.
*/
import { describe, it, expect } from 'vitest';
import { eq, and } from 'drizzle-orm';
import { db } from '@/lib/db';
import { interests } from '@/lib/db/schema/interests';
import { invoices } from '@/lib/db/schema/financial';
import { berthReservations } from '@/lib/db/schema/reservations';
import { analyticsSnapshots } from '@/lib/db/schema/insights';
import {
computePipelineFunnel,
computeOccupancyTimeline,
computeRevenueBreakdown,
computeLeadSourceAttribution,
getPipelineFunnel,
refreshSnapshotsForPort,
ALL_METRICS,
ALL_RANGES,
SNAPSHOT_TTL_MS,
} from '@/lib/services/analytics.service';
import { makePort, makeClient, makeBerth, makeYacht } from '../helpers/factories';
describe('analytics service', () => {
describe('computePipelineFunnel', () => {
it('aggregates interests by stage with conversion percentages', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
// 3 open, 2 details_sent, 1 visited
for (const stage of [
'open',
'open',
'open',
'details_sent',
'details_sent',
'in_communication',
]) {
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: stage,
});
}
const result = await computePipelineFunnel(port.id, '30d');
const open = result.stages.find((s) => s.stage === 'open');
const details = result.stages.find((s) => s.stage === 'details_sent');
const visited = result.stages.find((s) => s.stage === 'in_communication');
expect(open?.count).toBe(3);
expect(open?.conversionPct).toBe(100);
expect(details?.count).toBe(2);
expect(details?.conversionPct).toBeCloseTo(66.7, 0);
expect(visited?.count).toBe(1);
expect(visited?.conversionPct).toBeCloseTo(33.3, 0);
});
it('returns zeros when port has no interests', async () => {
const port = await makePort();
const result = await computePipelineFunnel(port.id, '30d');
expect(result.stages).toHaveLength(9);
expect(result.stages.every((s) => s.count === 0)).toBe(true);
});
});
describe('computeOccupancyTimeline', () => {
it('returns 7 points for 7d range with occupancy percentages', async () => {
const port = await makePort();
await makeBerth({ portId: port.id });
await makeBerth({ portId: port.id });
const client = await makeClient({ portId: port.id });
const yacht = await makeYacht({
portId: port.id,
ownerType: 'client',
ownerId: client.id,
});
const berth = await makeBerth({ portId: port.id });
// Active reservation covering today
await db.insert(berthReservations).values({
portId: port.id,
berthId: berth.id,
clientId: client.id,
yachtId: yacht.id,
status: 'active',
startDate: new Date(Date.now() - 5 * 86_400_000),
createdBy: 'seed',
});
const result = await computeOccupancyTimeline(port.id, '7d');
expect(result.points).toHaveLength(7);
// Last point is today; should reflect 1/3 occupancy.
const today = result.points[result.points.length - 1]!;
expect(today.total).toBe(3);
expect(today.occupied).toBe(1);
expect(today.occupancyPct).toBeCloseTo(33.3, 0);
});
});
describe('computeRevenueBreakdown', () => {
it('groups invoice totals by status and currency', async () => {
const port = await makePort();
const baseInvoice = {
portId: port.id,
clientName: 'Acme',
billingEntityType: 'client' as const,
billingEntityId: 'client-id',
dueDate: '2026-12-31',
currency: 'USD',
subtotal: '0',
createdBy: 'seed',
};
await db.insert(invoices).values([
{ ...baseInvoice, invoiceNumber: 'INV-001', total: '1000', status: 'paid' },
{ ...baseInvoice, invoiceNumber: 'INV-002', total: '500', status: 'paid' },
{ ...baseInvoice, invoiceNumber: 'INV-003', total: '2000', status: 'sent' },
]);
const result = await computeRevenueBreakdown(port.id, '30d');
const paid = result.bars.find((b) => b.status === 'paid');
const sent = result.bars.find((b) => b.status === 'sent');
expect(paid?.amount).toBe(1500);
expect(sent?.amount).toBe(2000);
});
});
describe('computeLeadSourceAttribution', () => {
it('counts interests grouped by source descending', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
for (const source of ['website', 'website', 'website', 'manual', 'referral', 'referral']) {
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: 'open',
source,
});
}
const result = await computeLeadSourceAttribution(port.id, '30d');
expect(result.slices[0]).toEqual({ source: 'website', count: 3 });
expect(result.slices[1]).toEqual({ source: 'referral', count: 2 });
expect(result.slices[2]).toEqual({ source: 'manual', count: 1 });
});
it('groups null source as "unspecified"', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: 'open',
source: null,
});
const result = await computeLeadSourceAttribution(port.id, '30d');
expect(result.slices.find((s) => s.source === 'unspecified')?.count).toBe(1);
});
});
describe('cache', () => {
it('getPipelineFunnel writes a snapshot and returns it on subsequent calls', async () => {
const port = await makePort();
const client = await makeClient({ portId: port.id });
await db.insert(interests).values({
portId: port.id,
clientId: client.id,
pipelineStage: 'open',
});
const first = await getPipelineFunnel(port.id, '30d');
// Snapshot written.
const row = await db.query.analyticsSnapshots.findFirst({
where: and(
eq(analyticsSnapshots.portId, port.id),
eq(analyticsSnapshots.metricId, 'pipeline_funnel.30d'),
),
});
expect(row).toBeDefined();
expect(row?.data).toEqual(first);
// Mutate the snapshot row directly to confirm cache is being read,
// not recomputed.
const sentinel = { stages: [{ stage: 'sentinel', count: 999, conversionPct: 0 }] };
await db
.update(analyticsSnapshots)
.set({ data: sentinel })
.where(
and(
eq(analyticsSnapshots.portId, port.id),
eq(analyticsSnapshots.metricId, 'pipeline_funnel.30d'),
),
);
const second = await getPipelineFunnel(port.id, '30d');
expect(second).toEqual(sentinel);
});
it('refreshSnapshotsForPort warms every metric × range combo', async () => {
const port = await makePort();
await refreshSnapshotsForPort(port.id);
const rows = await db
.select({ metricId: analyticsSnapshots.metricId })
.from(analyticsSnapshots)
.where(eq(analyticsSnapshots.portId, port.id));
const expected = ALL_METRICS.length * ALL_RANGES.length;
expect(rows).toHaveLength(expected);
});
it('snapshot ttl constant is 15 minutes', () => {
expect(SNAPSHOT_TTL_MS).toBe(15 * 60 * 1000);
});
});
});