From 494076fd4e7c2829583d53765f7373e3ad3f902b Mon Sep 17 00:00:00 2001 From: Alu-card19 Date: Wed, 19 Aug 2026 10:40:24 +0100 Subject: [PATCH] feat: add type-safe boundaries for analytics mock data with explicit AnalyticsSummary type --- lib/api/analytics/mock.test.ts | 289 +++++++++++++++++++++++++++++++++ lib/api/analytics/mock.ts | 105 ++++++++++++ lib/api/analytics/types.ts | 69 ++++++++ lib/api/mock.ts | 48 +----- 4 files changed, 471 insertions(+), 40 deletions(-) create mode 100644 lib/api/analytics/mock.test.ts create mode 100644 lib/api/analytics/mock.ts create mode 100644 lib/api/analytics/types.ts diff --git a/lib/api/analytics/mock.test.ts b/lib/api/analytics/mock.test.ts new file mode 100644 index 0000000..d3c13f1 --- /dev/null +++ b/lib/api/analytics/mock.test.ts @@ -0,0 +1,289 @@ +/** + * lib/api/analytics/mock.test.ts + * + * Tests for analytics mock data type safety and structure. + * + * Acceptance criteria: + * - MOCK_ANALYTICS_SUMMARY has explicit resourceAccess field + * - resourceAccess field is not null/undefined + * - Type checking: resource access matches AnalyticsSummary type + * - Accessor functions return same data as direct field access + * - Accessor functions have proper return types (not any/unknown) + * - The mock compiles and satisfies AnalyticsSummary type + */ + +import { test } from 'node:test' +import * as assert from 'node:assert/strict' +import { + MOCK_ANALYTICS_SUMMARY, + getResourceAccess, + getMemberGrowth, + getMockAnalyticsSummary, +} from './mock' +import type { AnalyticsSummary, ResourceAccessCount } from './types' + +// ── Mock structure tests ───────────────────────────────────────────────────── + +test('MOCK_ANALYTICS_SUMMARY has resourceAccess field', () => { + assert.ok( + MOCK_ANALYTICS_SUMMARY.resourceAccess !== undefined, + 'resourceAccess must be defined', + ) + assert.ok( + MOCK_ANALYTICS_SUMMARY.resourceAccess !== null, + 'resourceAccess must not be null', + ) +}) + +test('MOCK_ANALYTICS_SUMMARY.resourceAccess is an array', () => { + assert.ok( + Array.isArray(MOCK_ANALYTICS_SUMMARY.resourceAccess), + 'resourceAccess must be an array', + ) +}) + +test('MOCK_ANALYTICS_SUMMARY.resourceAccess contains objects with required fields', () => { + const { resourceAccess } = MOCK_ANALYTICS_SUMMARY + + assert.ok(resourceAccess.length > 0, 'resourceAccess should have entries') + + resourceAccess.forEach((item, index) => { + assert.ok( + typeof item.resourceId === 'string', + `resourceAccess[${index}].resourceId must be a string`, + ) + assert.ok( + typeof item.resourceTitle === 'string', + `resourceAccess[${index}].resourceTitle must be a string`, + ) + assert.ok( + typeof item.accessCount === 'number', + `resourceAccess[${index}].accessCount must be a number`, + ) + assert.ok( + typeof item.deniedCount === 'number', + `resourceAccess[${index}].deniedCount must be a number`, + ) + }) +}) + +test('MOCK_ANALYTICS_SUMMARY has all required AnalyticsSummary fields', () => { + const summary = MOCK_ANALYTICS_SUMMARY + + assert.ok( + typeof summary.totalMembers === 'number', + 'totalMembers must be a number', + ) + assert.ok( + typeof summary.activeMembers === 'number', + 'activeMembers must be a number', + ) + assert.ok( + Array.isArray(summary.memberGrowth), + 'memberGrowth must be an array', + ) + assert.ok( + typeof summary.generatedAt === 'string', + 'generatedAt must be a string', + ) +}) + +test('MOCK_ANALYTICS_SUMMARY.memberGrowth contains valid daily data', () => { + const { memberGrowth } = MOCK_ANALYTICS_SUMMARY + + assert.ok(memberGrowth.length > 0, 'memberGrowth should have data points') + + memberGrowth.forEach((point, index) => { + assert.ok( + typeof point.date === 'string', + `memberGrowth[${index}].date must be a string`, + ) + assert.ok( + typeof point.newMembers === 'number', + `memberGrowth[${index}].newMembers must be a number`, + ) + assert.ok( + typeof point.totalMembers === 'number', + `memberGrowth[${index}].totalMembers must be a number`, + ) + // Verify dates are in ISO format (YYYY-MM-DD) + assert.match( + point.date, + /^\d{4}-\d{2}-\d{2}$/, + `memberGrowth[${index}].date must be in YYYY-MM-DD format`, + ) + }) +}) + +// ── Accessor function tests ────────────────────────────────────────────────── + +test('getResourceAccess() returns same data as direct field access', () => { + const directAccess = MOCK_ANALYTICS_SUMMARY.resourceAccess + const accessorResult = getResourceAccess() + + assert.deepEqual( + accessorResult, + directAccess, + 'getResourceAccess() must return same data as MOCK_ANALYTICS_SUMMARY.resourceAccess', + ) +}) + +test('getResourceAccess() returns type-safe ResourceAccessCount[]', () => { + const result = getResourceAccess() + + assert.ok(Array.isArray(result), 'getResourceAccess() must return an array') + result.forEach((item) => { + assert.ok( + typeof item.resourceId === 'string' && + typeof item.resourceTitle === 'string' && + typeof item.accessCount === 'number' && + typeof item.deniedCount === 'number', + 'Each item must have proper ResourceAccessCount shape', + ) + }) +}) + +test('getMemberGrowth() returns same data as direct field access', () => { + const directGrowth = MOCK_ANALYTICS_SUMMARY.memberGrowth + const accessorResult = getMemberGrowth() + + assert.deepEqual( + accessorResult, + directGrowth, + 'getMemberGrowth() must return same data as MOCK_ANALYTICS_SUMMARY.memberGrowth', + ) +}) + +test('getMemberGrowth() returns array of MemberGrowthDataPoint', () => { + const result = getMemberGrowth() + + assert.ok(Array.isArray(result), 'getMemberGrowth() must return an array') + result.forEach((point) => { + assert.ok( + typeof point.date === 'string' && + typeof point.newMembers === 'number' && + typeof point.totalMembers === 'number', + 'Each point must have proper MemberGrowthDataPoint shape', + ) + }) +}) + +test('getMockAnalyticsSummary() returns complete AnalyticsSummary object', () => { + const result = getMockAnalyticsSummary() + + assert.ok( + typeof result.totalMembers === 'number', + 'result.totalMembers must be a number', + ) + assert.ok( + typeof result.activeMembers === 'number', + 'result.activeMembers must be a number', + ) + assert.ok( + Array.isArray(result.memberGrowth), + 'result.memberGrowth must be an array', + ) + assert.ok( + Array.isArray(result.resourceAccess), + 'result.resourceAccess must be an array', + ) + assert.ok( + typeof result.generatedAt === 'string', + 'result.generatedAt must be a string', + ) +}) + +test('getMockAnalyticsSummary() returns same object as MOCK_ANALYTICS_SUMMARY', () => { + const result = getMockAnalyticsSummary() + const direct = MOCK_ANALYTICS_SUMMARY + + assert.deepEqual( + result, + direct, + 'getMockAnalyticsSummary() must return same data as MOCK_ANALYTICS_SUMMARY', + ) +}) + +// ── Type safety validation tests ──────────────────────────────────────────── + +test('MOCK_ANALYTICS_SUMMARY is valid AnalyticsSummary (satisfies check)', () => { + // This test verifies compile-time type safety: + // If MOCK_ANALYTICS_SUMMARY doesn't satisfy AnalyticsSummary, + // TypeScript compilation will fail before this test runs. + const _typeCheck: AnalyticsSummary = MOCK_ANALYTICS_SUMMARY + + // At runtime, we just verify the reference is valid + assert.ok( + _typeCheck !== undefined, + 'Type assignment must complete without error', + ) +}) + +test('resourceAccess entries are valid ResourceAccessCount objects', () => { + const { resourceAccess } = MOCK_ANALYTICS_SUMMARY + + resourceAccess.forEach((item) => { + // This type assertion would fail at compile time if item doesn't match + const _typeCheck: ResourceAccessCount = item + + assert.ok(_typeCheck !== undefined) + }) +}) + +// ── Data consistency tests ────────────────────────────────────────────────── + +test('memberGrowth shows monotonic increase (or no change) in totalMembers', () => { + const { memberGrowth } = MOCK_ANALYTICS_SUMMARY + + for (let i = 1; i < memberGrowth.length; i++) { + const prev = memberGrowth[i - 1] + const curr = memberGrowth[i] + + assert.ok( + curr.totalMembers >= prev.totalMembers, + `memberGrowth[${i}].totalMembers (${curr.totalMembers}) must be >= ` + + `memberGrowth[${i - 1}].totalMembers (${prev.totalMembers})`, + ) + } +}) + +test('memberGrowth.newMembers equals difference in totalMembers', () => { + const { memberGrowth } = MOCK_ANALYTICS_SUMMARY + + for (let i = 1; i < memberGrowth.length; i++) { + const prev = memberGrowth[i - 1] + const curr = memberGrowth[i] + const expectedNewMembers = curr.totalMembers - prev.totalMembers + + assert.strictEqual( + curr.newMembers, + expectedNewMembers, + `memberGrowth[${i}].newMembers (${curr.newMembers}) must equal ` + + `difference in totalMembers (${expectedNewMembers})`, + ) + } +}) + +test('activeMembers <= totalMembers', () => { + const { totalMembers, activeMembers } = MOCK_ANALYTICS_SUMMARY + + assert.ok( + activeMembers <= totalMembers, + `activeMembers (${activeMembers}) must be <= totalMembers (${totalMembers})`, + ) +}) + +test('resourceAccess entries have non-negative counts', () => { + const { resourceAccess } = MOCK_ANALYTICS_SUMMARY + + resourceAccess.forEach((item, index) => { + assert.ok( + item.accessCount >= 0, + `resourceAccess[${index}].accessCount must be >= 0`, + ) + assert.ok( + item.deniedCount >= 0, + `resourceAccess[${index}].deniedCount must be >= 0`, + ) + }) +}) diff --git a/lib/api/analytics/mock.ts b/lib/api/analytics/mock.ts new file mode 100644 index 0000000..ee92706 --- /dev/null +++ b/lib/api/analytics/mock.ts @@ -0,0 +1,105 @@ +/** + * lib/api/analytics/mock.ts + * + * Analytics fixture data — the single source of truth for analytics mock responses. + * + * The mock is explicitly typed as AnalyticsSummary so shape changes are caught + * at compile time, not when Next.js compiles the module. + * + * All fields match the production API response shape exactly. Use the access + * functions (getMockAnalyticsSummary, getResourceAccess) instead of importing + * MOCK_ANALYTICS_SUMMARY directly to allow future swapping with real API calls. + */ + +import type { AnalyticsSummary } from '../types' + +/** + * Generates a seeded member growth time series for the last 30 days. + * Starts at 80 members and grows by 1–4 per day with a mild upward trend. + * + * @returns Array of daily member growth data points + */ +function generateMockMemberGrowth(): AnalyticsSummary['memberGrowth'] { + const days = 30 + const points: AnalyticsSummary['memberGrowth'] = [] + let total = 80 + + for (let i = days - 1; i >= 0; i--) { + const d = new Date() + d.setDate(d.getDate() - i) + const dateStr = d.toISOString().slice(0, 10) + // Weekday gets more sign-ups; weekend less + const dayOfWeek = d.getDay() + const isWeekend = dayOfWeek === 0 || dayOfWeek === 6 + const newMembers = isWeekend + ? Math.floor(Math.random() * 2) // 0–1 on weekends + : Math.floor(Math.random() * 4) + 1 // 1–4 on weekdays + total += newMembers + points.push({ date: dateStr, newMembers, totalMembers: total }) + } + + return points +} + +/** + * Analytics fixture data — the single source of truth for analytics mock responses. + * Explicitly typed as AnalyticsSummary so shape changes are caught at compile time, + * not when Next.js compiles the module. + * + * All fields match the production API response shape exactly. + */ +const MOCK_ANALYTICS_SUMMARY: AnalyticsSummary = { + totalMembers: 124, + activeMembers: 98, + memberGrowth: generateMockMemberGrowth(), + resourceAccess: [ + { resourceId: 'alpha', resourceTitle: 'Alpha Docs', accessCount: 312, deniedCount: 47 }, + { resourceId: 'pro-reports', resourceTitle: 'Pro Reports', accessCount: 189, deniedCount: 103 }, + { resourceId: 'mem-updates', resourceTitle: 'Member Updates', accessCount: 541, deniedCount: 12 }, + ], + generatedAt: new Date().toISOString(), +} as const satisfies AnalyticsSummary + +/** + * Returns the resourceAccess field from the analytics summary. + * Type-safe: return type is inferred from AnalyticsSummary, not 'any'. + * + * This is the canonical accessor for resource access analytics data. + * Type: AnalyticsSummary['resourceAccess'] (narrowed from type property) + * + * @returns Resource access counts for all gated resources + */ +export function getResourceAccess(): AnalyticsSummary['resourceAccess'] { + return MOCK_ANALYTICS_SUMMARY.resourceAccess +} + +/** + * Returns the memberGrowth field from the analytics summary. + * Type-safe: return type is inferred from AnalyticsSummary, not 'any'. + * + * This is the canonical accessor for member growth data. + * + * @returns Daily member growth data points + */ +export function getMemberGrowth(): AnalyticsSummary['memberGrowth'] { + return MOCK_ANALYTICS_SUMMARY.memberGrowth +} + +/** + * Returns the full analytics summary mock. + * Type-safe: return type is AnalyticsSummary, not any. + * + * Use this function instead of importing MOCK_ANALYTICS_SUMMARY directly + * to allow future swapping with real API calls or cached responses. + * + * @returns Complete analytics summary object + */ +export function getMockAnalyticsSummary(): AnalyticsSummary { + return MOCK_ANALYTICS_SUMMARY +} + +/** + * Re-export the mock constant for backward compatibility and direct access. + * Explicitly typed as AnalyticsSummary. + */ +export { MOCK_ANALYTICS_SUMMARY } diff --git a/lib/api/analytics/types.ts b/lib/api/analytics/types.ts new file mode 100644 index 0000000..b87bd69 --- /dev/null +++ b/lib/api/analytics/types.ts @@ -0,0 +1,69 @@ +/** + * lib/api/analytics/types.ts + * + * Type definitions for analytics summary data. + * + * These types ensure MOCK_ANALYTICS_SUMMARY and its access functions + * are checked at compile time — shape changes cause TypeScript errors + * rather than silent runtime failures. + * + * The types are re-exported from lib/api/types.ts where they are also + * defined for backward compatibility. This module serves as the focused + * boundary for analytics-specific type safety. + */ + +import type { + AnalyticsSummary, + ResourceAccessCount, + MemberGrowthDataPoint, +} from '../types' + +import { + AnalyticsSummarySchema, + ResourceAccessCountSchema, + MemberGrowthDataPointSchema, +} from '../types' + +/** + * Re-export analytics types for focused module boundaries. + * These are the canonical definitions; this module re-exports them + * to make it clear that analytics is a distinct concern. + */ +export type { AnalyticsSummary, ResourceAccessCount, MemberGrowthDataPoint } + +/** + * Re-export Zod schemas for runtime validation. + */ +export { AnalyticsSummarySchema, ResourceAccessCountSchema, MemberGrowthDataPointSchema } + +/** + * Type guard: runtime check that a value matches AnalyticsSummary shape. + * Use this when parsing untrusted data (API responses, etc). + * + * @example + * const data = await fetch('/api/analytics').then(r => r.json()) + * if (isAnalyticsSummary(data)) { + * // data is now typed as AnalyticsSummary + * console.log(data.totalMembers) + * } + */ +export function isAnalyticsSummary(value: unknown): value is AnalyticsSummary { + try { + AnalyticsSummarySchema.parse(value) + return true + } catch { + return false + } +} + +/** + * Type guard: runtime check that a value matches ResourceAccessCount shape. + */ +export function isResourceAccessCount(value: unknown): value is ResourceAccessCount { + try { + ResourceAccessCountSchema.parse(value) + return true + } catch { + return false + } +} diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 8c15089..9cf98cb 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -73,6 +73,12 @@ import { LS_KEY, } from './mock-storage' import { config } from '../config' +import { + MOCK_ANALYTICS_SUMMARY, + getResourceAccess, + getMemberGrowth, + getMockAnalyticsSummary, +} from './analytics/mock' /** Read once at module load so it is stable across renders. */ const MOCK_SESSION_STATE = @@ -251,44 +257,6 @@ const DEFAULT_WEBHOOK_EVENTS: WebhookEventLog[] = [ }, ] -/** - * Generates a seeded member growth time series for the last 30 days. - * Starts at 80 members and grows by 1–4 per day with a mild upward trend. - */ -function generateMockMemberGrowth(): AnalyticsSummary['memberGrowth'] { - const days = 30 - const points: AnalyticsSummary['memberGrowth'] = [] - let total = 80 - - for (let i = days - 1; i >= 0; i--) { - const d = new Date() - d.setDate(d.getDate() - i) - const dateStr = d.toISOString().slice(0, 10) - // Weekday gets more sign-ups; weekend less - const dayOfWeek = d.getDay() - const isWeekend = dayOfWeek === 0 || dayOfWeek === 6 - const newMembers = isWeekend - ? Math.floor(Math.random() * 2) // 0–1 on weekends - : Math.floor(Math.random() * 4) + 1 // 1–4 on weekdays - total += newMembers - points.push({ date: dateStr, newMembers, totalMembers: total }) - } - - return points -} - -const MOCK_ANALYTICS_SUMMARY: AnalyticsSummary = { - totalMembers: 124, - activeMembers: 98, - memberGrowth: generateMockMemberGrowth(), - resourceAccess: [ - { resourceId: 'alpha', resourceTitle: 'Alpha Docs', accessCount: 312, deniedCount: 47 }, - { resourceId: 'pro-reports', resourceTitle: 'Pro Reports', accessCount: 189, deniedCount: 103 }, - { resourceId: 'mem-updates', resourceTitle: 'Member Updates', accessCount: 541, deniedCount: 12 }, - ], - generatedAt: new Date().toISOString(), -} - const DEFAULT_MEMBER_STORE: Record = {} /** Deterministic name pool for seeded members — gives search-by-name something realistic and varied to match against. */ @@ -2097,7 +2065,7 @@ export class MockAccessApi implements AccessApi { public analytics: any = { getMembershipTrend: async (_signal?: AbortSignal) => { await initPromise; - return MOCK_ANALYTICS_SUMMARY.memberGrowth; + return getMemberGrowth(); }, getRoleDistribution: async (_signal?: AbortSignal) => { await initPromise; @@ -2111,7 +2079,7 @@ export class MockAccessApi implements AccessApi { }, getAccessAttempts: async (_signal?: AbortSignal) => { await initPromise; - return MOCK_ANALYTICS_SUMMARY.resourceAccess; + return getResourceAccess(); } } }