Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
289 changes: 289 additions & 0 deletions lib/api/analytics/mock.test.ts
Original file line number Diff line number Diff line change
@@ -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`,
)
})
})
105 changes: 105 additions & 0 deletions lib/api/analytics/mock.ts
Original file line number Diff line number Diff line change
@@ -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 }
Loading