Files
pn-new-crm/tests/integration/analytics-service.test.ts
Matt 449b9497ab fix(uat): batch — timeline overshoot, name-sync, reset-password, dashboard cleanup, queue/seed hygiene + alpha UAT findings doc
UAT findings landed across the last few Playwright + React Grab passes;
single grouped commit so the index doesn't fragment into 30 one-liners.

User & auth:
- `user-settings`: name now updates the avatar + topbar menu after save
  (was reading stale session).
- `me/password-reset`: 3 bugs (token validation, error response shape,
  redirect chain).
- Admin user permission-overrides route honours the same envelope as
  the rest of the admin surface.

Dashboard:
- Removed obsolete `revenue-breakdown-chart` + `dashboard-widgets-card`
  (replaced by the customisable widget grid).
- Strip `revenue_breakdown` from analytics route + use-analytics +
  service + integration test so nothing renders an empty card.
- Activity log timeline overshoot fix (`interest-timeline` +
  `entity-activity-feed`).
- Tightened tiles: active-deals, berth-heat-widget, pipeline-value, kpi-tile.
- `dev-mode-banner`: derive dismissed state synchronously instead of
  via an effect (set-state-in-effect lint rule).

Forms & lists (assorted polish):
- client / company / yacht / interest / reminder forms — validation +
  empty-state copy + tab transitions.
- companies/yachts list tweaks; berth recommender panel; qualification
  checklist; supplemental info request button.

Infra & misc:
- Queue workers (ai / email / notifications) — log shape +
  per-job timeout consistency.
- Auth / brochures / users schema small adjustments; seeds reflect
  permissions matrix changes.
- Scan shell + scanner manifest + AI admin page small fixes.
- `next.config.transpilePackages` adds `echarts`/`zrender`/`echarts-for-react`
  (recommended config from echarts-for-react inside Next).

Docs:
- `docs/superpowers/audits/alpha-uat-master.md` — single rolling
  cross-cutting UAT findings doc (per CLAUDE.md convention).
- `docs/BACKLOG.md`: dashboard stats cards (§I) + activity-log
  normalization (§J).
- 2026-05-18 audit log updated with this batch.
- `CLAUDE.md` — small manual UAT scaffold notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 15:56:11 +02:00

196 lines
6.9 KiB
TypeScript
Raw 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, 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);
});
});
});