/** * 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, interestBerths } from '@/lib/db/schema/interests'; import { analyticsSnapshots } from '@/lib/db/schema/insights'; import { computePipelineFunnel, computeOccupancyTimeline, 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 enquiry, 2 qualified, 1 nurturing for (const stage of [ 'enquiry', 'enquiry', 'enquiry', 'qualified', 'qualified', 'nurturing', ]) { await db.insert(interests).values({ portId: port.id, clientId: client.id, pipelineStage: stage, }); } const result = await computePipelineFunnel(port.id, '30d'); const enquiry = result.stages.find((s) => s.stage === 'enquiry'); const qualified = result.stages.find((s) => s.stage === 'qualified'); const nurturing = result.stages.find((s) => s.stage === 'nurturing'); expect(enquiry?.count).toBe(3); expect(enquiry?.conversionPct).toBe(100); expect(qualified?.count).toBe(2); expect(qualified?.conversionPct).toBeCloseTo(66.7, 0); expect(nurturing?.count).toBe(1); expect(nurturing?.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(7); expect(result.stages.every((s) => s.count === 0)).toBe(true); }); }); describe('computeOccupancyTimeline', () => { it('returns 7 points for 7d range with cumulative won-deal occupancy', async () => { // Post 2026-05-14 the timeline derives occupancy from won // interests (cumulative as of each day) rather than active // reservations — see analytics.service.ts comment + PRE-DEPLOY- // PLAN § 1.1.3. Fixture: 3 berths, one of which sold 5 days ago. const port = await makePort(); await makeBerth({ portId: port.id }); await makeBerth({ portId: port.id }); const client = await makeClient({ portId: port.id }); await makeYacht({ portId: port.id, ownerType: 'client', ownerId: client.id, }); const berth = await makeBerth({ portId: port.id }); const fiveDaysAgo = new Date(Date.now() - 5 * 86_400_000); const [interest] = await db .insert(interests) .values({ portId: port.id, clientId: client.id, pipelineStage: 'contract', outcome: 'won', outcomeAt: fiveDaysAgo, }) .returning(); await db.insert(interestBerths).values({ interestId: interest!.id, berthId: berth.id, isPrimary: true, }); 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('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); }); }); });