diff --git a/backend/analytics/__tests__/cohortAnalyticsRouter.test.ts b/backend/analytics/__tests__/cohortAnalyticsRouter.test.ts new file mode 100644 index 00000000..9fc23555 --- /dev/null +++ b/backend/analytics/__tests__/cohortAnalyticsRouter.test.ts @@ -0,0 +1,321 @@ +/** + * Unit tests for cohortAnalyticsRouter.ts + * + * Covers: + * - GET /analytics/cohorts → cohort table (monthly & weekly) + * - GET /analytics/cohorts/retention → Day 1/7/30/60/90 retention curve + * - GET /analytics/cohorts/churn → revenue churn vs logo churn + * - GET /analytics/cohorts/export → CSV and PDF download + * - GET /analytics/cohorts/mrr → MRR/ARR/ARPU/LTV summary + * - POST /analytics/cohorts/subscribers → upsert subscriber record + * - Validation: missing merchantId → 400 + * - Non-GET to read endpoint → 405 + * - Unmatched sub-path → returns false + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import type http from 'node:http'; +import { cohortAnalyticsRouter } from '../cohortAnalyticsRouter'; +import { subscriberRecordRepository } from '../../services/analytics/subscriberRecordRepository'; +import type { SubscriberRecord } from '../../../src/types/cohortAnalytics'; + +// ─── Minimal mock helpers ───────────────────────────────────────────────────── + +type MockRes = http.ServerResponse & { + statusCode: number; + body: string; + headers: Record; + json(): unknown; +}; + +function makeRes(): MockRes { + const headers: Record = {}; + let statusCode = 0; + let body = ''; + return { + writeHead(code: number, hdrs?: Record) { + statusCode = code; + Object.assign(headers, hdrs ?? {}); + }, + end(data?: unknown) { body = data ? String(data) : ''; }, + get statusCode() { return statusCode; }, + get body() { return body; }, + get headers() { return headers; }, + json() { return JSON.parse(body); }, + } as unknown as MockRes; +} + +function makeReq(method: string, url: string, bodyObj?: unknown): http.IncomingMessage { + const bodyStr = bodyObj ? JSON.stringify(bodyObj) : ''; + const chunks = bodyStr ? [Buffer.from(bodyStr)] : []; + const req = { + method, + url, + headers: { host: 'localhost' }, + on(event: string, handler: (chunk?: unknown) => void) { + if (event === 'data') for (const c of chunks) handler(c); + if (event === 'end') handler(); + return req; + }, + } as unknown as http.IncomingMessage; + return req; +} + +// ─── Fixtures ───────────────────────────────────────────────────────────────── + +const NOW = Date.now(); +const TWO_MONTHS_AGO = NOW - 60 * 24 * 60 * 60 * 1000; +const ONE_MONTH_AGO = NOW - 30 * 24 * 60 * 60 * 1000; +const MERCHANT = 'merch_test'; + +function makeRecord(overrides: Partial = {}): SubscriberRecord { + return { + subscriberId: `sub_${Math.random().toString(36).slice(2)}`, + merchantId: MERCHANT, + planId: 'plan_basic', + planName: 'Basic', + signupAt: TWO_MONTHS_AGO, + mrr: 29, + ...overrides, + }; +} + +beforeEach(() => { + subscriberRecordRepository.seed(MERCHANT, [ + makeRecord({ signupAt: TWO_MONTHS_AGO }), + makeRecord({ signupAt: TWO_MONTHS_AGO }), + makeRecord({ + signupAt: ONE_MONTH_AGO, + churnedAt: ONE_MONTH_AGO + 5 * 24 * 60 * 60 * 1000, + }), + ]); +}); + +// ─── Non-matching paths ─────────────────────────────────────────────────────── + +describe('cohortAnalyticsRouter — non-matching paths', () => { + it('returns false for paths outside /analytics/cohorts', async () => { + const res = makeRes(); + const handled = await cohortAnalyticsRouter(makeReq('GET', '/other'), res, '/other'); + expect(handled).toBe(false); + }); +}); + +// ─── Validation ─────────────────────────────────────────────────────────────── + +describe('cohortAnalyticsRouter — validation', () => { + it('returns 400 when merchantId missing', async () => { + const res = makeRes(); + await cohortAnalyticsRouter(makeReq('GET', '/analytics/cohorts'), res, '/analytics/cohorts'); + expect(res.statusCode).toBe(400); + expect((res.json() as { success: boolean }).success).toBe(false); + }); + + it('returns 405 for non-GET to /analytics/cohorts', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('DELETE', `/analytics/cohorts?merchantId=${MERCHANT}`), + res, + '/analytics/cohorts' + ); + expect(res.statusCode).toBe(405); + }); +}); + +// ─── GET /analytics/cohorts (cohort table) ──────────────────────────────────── + +describe('GET /analytics/cohorts', () => { + it('returns a cohort table array', async () => { + const res = makeRes(); + const handled = await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts?merchantId=${MERCHANT}`), + res, '/analytics/cohorts' + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + const body = res.json() as { success: boolean; data: unknown[] }; + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + expect(body.data.length).toBeGreaterThan(0); + }); + + it('buckets have required fields', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts?merchantId=${MERCHANT}`), + res, '/analytics/cohorts' + ); + const bucket = (res.json() as { data: Array> }).data[0]!; + expect(bucket).toHaveProperty('cohortKey'); + expect(bucket).toHaveProperty('size'); + expect(bucket).toHaveProperty('activeCount'); + expect(bucket).toHaveProperty('retentionRate'); + }); + + it('accepts granularity=week and returns week-keyed buckets', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts?merchantId=${MERCHANT}&granularity=week`), + res, '/analytics/cohorts' + ); + expect(res.statusCode).toBe(200); + const buckets = (res.json() as { data: Array<{ granularity: string }> }).data; + expect(buckets.every((b) => b.granularity === 'week')).toBe(true); + }); +}); + +// ─── GET /analytics/cohorts/retention ──────────────────────────────────────── + +describe('GET /analytics/cohorts/retention', () => { + it('returns an array of retention curve points', async () => { + const res = makeRes(); + const handled = await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/retention?merchantId=${MERCHANT}`), + res, '/analytics/cohorts/retention' + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + const body = res.json() as { success: boolean; data: unknown[] }; + expect(body.success).toBe(true); + expect(Array.isArray(body.data)).toBe(true); + }); + + it('each point has day, retainedCount, cohortSize, retentionRate', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/retention?merchantId=${MERCHANT}`), + res, '/analytics/cohorts/retention' + ); + const points = (res.json() as { data: Array> }).data; + if (points.length > 0) { + expect(points[0]).toHaveProperty('day'); + expect(points[0]).toHaveProperty('retainedCount'); + expect(points[0]).toHaveProperty('cohortSize'); + expect(points[0]).toHaveProperty('retentionRate'); + } + }); +}); + +// ─── GET /analytics/cohorts/churn ──────────────────────────────────────────── + +describe('GET /analytics/cohorts/churn', () => { + it('returns churn breakdown with success=true', async () => { + const res = makeRes(); + const handled = await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/churn?merchantId=${MERCHANT}&from=${TWO_MONTHS_AGO}&to=${NOW}`), + res, '/analytics/cohorts/churn' + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect((res.json() as { success: boolean }).success).toBe(true); + }); + + it('defaults from/to when params omitted', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/churn?merchantId=${MERCHANT}`), + res, '/analytics/cohorts/churn' + ); + expect(res.statusCode).toBe(200); + }); +}); + +// ─── GET /analytics/cohorts/mrr ─────────────────────────────────────────────── + +describe('GET /analytics/cohorts/mrr', () => { + it('returns MRR/ARR/ARPU/LTV summary', async () => { + const res = makeRes(); + const handled = await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/mrr?merchantId=${MERCHANT}`), + res, '/analytics/cohorts/mrr' + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + const data = (res.json() as { data: Record }).data; + expect(data).toHaveProperty('mrr'); + expect(data).toHaveProperty('arr'); + expect(data).toHaveProperty('arpu'); + expect(data).toHaveProperty('ltv'); + expect(data).toHaveProperty('activeCount'); + expect(data).toHaveProperty('subscriberCount'); + }); + + it('mrr is positive for seeded data', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/mrr?merchantId=${MERCHANT}`), + res, '/analytics/cohorts/mrr' + ); + const data = (res.json() as { data: { mrr: number } }).data; + expect(data.mrr).toBeGreaterThan(0); + }); +}); + +// ─── GET /analytics/cohorts/export ─────────────────────────────────────────── + +describe('GET /analytics/cohorts/export', () => { + it('CSV: returns 200 with text/csv Content-Type and Content-Disposition', async () => { + const res = makeRes(); + const handled = await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/export?merchantId=${MERCHANT}&granularity=month&format=csv`), + res, '/analytics/cohorts/export' + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect(res.headers['Content-Type']).toContain('text/csv'); + expect(res.headers['Content-Disposition']).toContain('attachment'); + expect(res.headers['Content-Disposition']).toContain('.csv'); + }); + + it('CSV body contains expected column headers', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/export?merchantId=${MERCHANT}&format=csv`), + res, '/analytics/cohorts/export' + ); + expect(res.body).toContain('cohort'); + expect(res.body).toContain('retentionRate'); + }); + + it('PDF: returns 200 with application/pdf Content-Type', async () => { + const res = makeRes(); + await cohortAnalyticsRouter( + makeReq('GET', `/analytics/cohorts/export?merchantId=${MERCHANT}&format=pdf`), + res, '/analytics/cohorts/export' + ); + expect(res.statusCode).toBe(200); + expect(res.headers['Content-Type']).toContain('application/pdf'); + }); +}); + +// ─── POST /analytics/cohorts/subscribers ───────────────────────────────────── + +describe('POST /analytics/cohorts/subscribers', () => { + it('upserts a record and returns success', async () => { + const record: SubscriberRecord = makeRecord({ subscriberId: 'sub_new', merchantId: 'merch_new' }); + const res = makeRes(); + const handled = await cohortAnalyticsRouter( + makeReq('POST', '/analytics/cohorts/subscribers', record), + res, '/analytics/cohorts/subscribers' + ); + expect(handled).toBe(true); + expect(res.statusCode).toBe(200); + expect((res.json() as { success: boolean }).success).toBe(true); + }); + + it('returns 400 for invalid JSON body', async () => { + const req = { + method: 'POST', + url: '/analytics/cohorts/subscribers', + headers: { host: 'localhost' }, + on(event: string, handler: (chunk?: unknown) => void) { + if (event === 'data') handler(Buffer.from('{bad json')); + if (event === 'end') handler(); + return req; + }, + } as unknown as http.IncomingMessage; + const res = makeRes(); + await cohortAnalyticsRouter(req, res, '/analytics/cohorts/subscribers'); + expect(res.statusCode).toBe(400); + }); +}); diff --git a/backend/analytics/cohortAnalyticsRouter.ts b/backend/analytics/cohortAnalyticsRouter.ts new file mode 100644 index 00000000..c383c14f --- /dev/null +++ b/backend/analytics/cohortAnalyticsRouter.ts @@ -0,0 +1,150 @@ +/** + * cohortAnalyticsRouter + * + * Plain Node.js HTTP handler for cohort retention and churn analytics. + * Mounts under `/analytics/cohorts` in server.ts. + * + * Routes + * ────── + * GET /analytics/cohorts?merchantId=…&granularity=month|week + * Cohort table grouped by signup period + * + * GET /analytics/cohorts/retention?merchantId=… + * Day 1/7/30/60/90 retention curve + * + * GET /analytics/cohorts/churn?merchantId=…&from=&to= + * Revenue churn vs logo churn breakdown for a period + * + * GET /analytics/cohorts/export?merchantId=…&granularity=month|week&format=csv|pdf + * Downloadable cohort table (CSV or PDF) + * + * GET /analytics/cohorts/mrr?merchantId=… + * MRR / ARR / ARPU / LTV summary + * + * POST /analytics/cohorts/subscribers + * Upsert a subscriber record (seed data into the in-memory store) + * Body: SubscriberRecord (JSON) + * + * All JSON responses follow { success, data } | { success, error }. + * Export responses stream file bytes with Content-Disposition for download. + */ + +import type http from 'node:http'; +import { URL } from 'node:url'; +import { analyticsDashboardApi } from '../services/analytics/analyticsDashboardApi'; +import { subscriberRecordRepository } from '../services/analytics/subscriberRecordRepository'; +import type { CohortGranularity, AnalyticsExportFormat } from '../../src/types/cohortAnalytics'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function sendJson(res: http.ServerResponse, status: number, body: unknown): void { + res.writeHead(status, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(body)); +} + +async function readJsonBody(req: http.IncomingMessage, maxBytes = 64_000): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + req.on('data', (chunk: Buffer) => { + total += chunk.length; + if (total > maxBytes) { reject(new Error('Request body too large')); return; } + chunks.push(chunk); + }); + req.on('end', () => { + try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } + catch { reject(new Error('Invalid JSON body')); } + }); + req.on('error', reject); + }); +} + +// ─── Router ─────────────────────────────────────────────────────────────────── + +const PREFIX = '/analytics/cohorts'; + +export async function cohortAnalyticsRouter( + req: http.IncomingMessage, + res: http.ServerResponse, + pathname: string +): Promise { + if (!pathname.startsWith(PREFIX)) return false; + + const method = req.method ?? 'GET'; + const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`); + const merchantId = url.searchParams.get('merchantId') ?? ''; + const subPath = pathname.slice(PREFIX.length); // e.g. '', '/retention', '/churn', ... + + // ── POST /analytics/cohorts/subscribers (seed/upsert a subscriber record) ─ + if (subPath === '/subscribers' && method === 'POST') { + try { + const body = await readJsonBody(req) as Record; + subscriberRecordRepository.upsert(body as never); + sendJson(res, 200, { success: true, data: { upserted: true } }); + } catch (err) { + sendJson(res, 400, { success: false, error: err instanceof Error ? err.message : String(err) }); + } + return true; + } + + if (method !== 'GET') { + sendJson(res, 405, { success: false, error: 'Method not allowed' }); + return true; + } + + if (!merchantId) { + sendJson(res, 400, { success: false, error: 'merchantId query parameter is required' }); + return true; + } + + // ── GET /analytics/cohorts/retention ────────────────────────────────────── + if (subPath === '/retention') { + const result = analyticsDashboardApi.getRetentionCurve(merchantId); + sendJson(res, result.success ? 200 : 500, result); + return true; + } + + // ── GET /analytics/cohorts/churn ────────────────────────────────────────── + if (subPath === '/churn') { + const from = parseInt(url.searchParams.get('from') ?? '0', 10); + const to = parseInt(url.searchParams.get('to') ?? String(Date.now()), 10); + const result = analyticsDashboardApi.getChurnBreakdown(merchantId, from, to); + sendJson(res, result.success ? 200 : 500, result); + return true; + } + + // ── GET /analytics/cohorts/export ───────────────────────────────────────── + if (subPath === '/export') { + const granularity = (url.searchParams.get('granularity') ?? 'month') as CohortGranularity; + const format = (url.searchParams.get('format') ?? 'csv') as AnalyticsExportFormat; + const result = analyticsDashboardApi.exportCohortReport(merchantId, granularity, format); + if (!result.success || !result.data) { + sendJson(res, 500, result); + return true; + } + const { filename, contentType, body } = result.data; + res.writeHead(200, { + 'Content-Type': contentType, + 'Content-Disposition': `attachment; filename="${filename}"`, + }); + res.end(body); + return true; + } + + // ── GET /analytics/cohorts/mrr ──────────────────────────────────────────── + if (subPath === '/mrr') { + const result = analyticsDashboardApi.getMrrArrReport(merchantId); + sendJson(res, result.success ? 200 : 500, result); + return true; + } + + // ── GET /analytics/cohorts (cohort table — default) ────────────────────── + if (subPath === '' || subPath === '/') { + const granularity = (url.searchParams.get('granularity') ?? 'month') as CohortGranularity; + const result = analyticsDashboardApi.getCohortTable(merchantId, granularity); + sendJson(res, result.success ? 200 : 500, result); + return true; + } + + return false; +}