diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 9cf98cb..d127a4d 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -2062,7 +2062,6 @@ export class MockAccessApi implements AccessApi { } public analytics: import('./types').AnalyticsDataSource = { - public analytics: any = { getMembershipTrend: async (_signal?: AbortSignal) => { await initPromise; return getMemberGrowth(); diff --git a/lib/api/analytics/mock.test.ts b/test/mock-analytics.test.ts similarity index 65% rename from lib/api/analytics/mock.test.ts rename to test/mock-analytics.test.ts index d3c13f1..bd4e25d 100644 --- a/lib/api/analytics/mock.test.ts +++ b/test/mock-analytics.test.ts @@ -1,26 +1,141 @@ /** - * lib/api/analytics/mock.test.ts + * test/mock-analytics.test.ts * - * Tests for analytics mock data type safety and structure. + * Tests for the public analytics mock API and its fixture invariants. * - * 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 + * Testing approach: + * Public API tests intentionally use literal expectations instead of deriving + * expected values from MOCK_ANALYTICS_SUMMARY. The remaining tests protect the + * internal fixture invariants that were covered before this file moved into the + * repository's executable test directory. */ -import { test } from 'node:test' +import './setup-env' +import { describe, test } from 'node:test' import * as assert from 'node:assert/strict' +import { MockAccessApi } from '../lib/api/mock' import { MOCK_ANALYTICS_SUMMARY, getResourceAccess, getMemberGrowth, getMockAnalyticsSummary, -} from './mock' -import type { AnalyticsSummary, ResourceAccessCount } from './types' +} from '../lib/api/analytics/mock' +import type { + AnalyticsSummary, + ResourceAccessCount, +} from '../lib/api/analytics/types' + +const ADMIN_ADDRESS = '0x0000000000000000000000000000000000000001' + +function createAdminApi(): MockAccessApi { + return new MockAccessApi(ADMIN_ADDRESS, 'guildpass-demo') +} + +// -- Public MockAccessApi analytics contract --------------------------------- + +describe('MockAccessApi.analytics public accessors', () => { + test('exposes exactly the analytics accessors declared by the public contract', () => { + assert.deepStrictEqual( + Object.keys(createAdminApi().analytics).sort(), + ['getAccessAttempts', 'getMembershipTrend', 'getRoleDistribution'], + ) + }) + + test('getMembershipTrend returns the public member-growth shape', async () => { + const trend = await createAdminApi().analytics.getMembershipTrend() + + assert.equal(trend.length, 30) + trend.forEach((point, index) => { + assert.deepStrictEqual( + Object.keys(point).sort(), + ['date', 'newMembers', 'totalMembers'], + `memberGrowth[${index}] must keep the public response shape`, + ) + assert.match(point.date, /^\d{4}-\d{2}-\d{2}$/) + assert.ok(Number.isInteger(point.newMembers)) + assert.ok(point.newMembers >= 0) + assert.ok(Number.isInteger(point.totalMembers)) + assert.ok(point.totalMembers >= 0) + }) + + assert.equal(trend[0].totalMembers, 80 + trend[0].newMembers) + trend.slice(1).forEach((point, index) => { + const previous = trend[index] + const previousDate = Date.parse(`${previous.date}T00:00:00Z`) + const currentDate = Date.parse(`${point.date}T00:00:00Z`) + + assert.equal(currentDate - previousDate, 24 * 60 * 60 * 1000) + assert.equal(point.totalMembers - previous.totalMembers, point.newMembers) + }) + }) + + test('getRoleDistribution returns every role with valid seeded counts', async () => { + const distribution = await createAdminApi().analytics.getRoleDistribution() + + distribution.forEach((entry, index) => { + assert.deepStrictEqual( + Object.keys(entry).sort(), + ['count', 'role'], + `roleDistribution[${index}] must keep the public response shape`, + ) + assert.ok(Number.isInteger(entry.count)) + assert.ok(entry.count >= 0) + }) + assert.deepStrictEqual( + [...distribution].sort((left, right) => left.role.localeCompare(right.role)), + [ + { role: 'admin', count: 1 }, + { role: 'member', count: 49001 }, + { role: 'moderator', count: 1062 }, + ], + ) + }) + + test('getAccessAttempts exposes resourceAccess through the public API', async () => { + const accessAttempts = await createAdminApi().analytics.getAccessAttempts() + + assert.equal(accessAttempts.length, 3) + accessAttempts.forEach((entry, index) => { + assert.deepStrictEqual( + Object.keys(entry).sort(), + ['accessCount', 'deniedCount', 'resourceId', 'resourceTitle'], + `resourceAccess[${index}] must keep the public response shape`, + ) + assert.ok(Number.isInteger(entry.accessCount)) + assert.ok(entry.accessCount >= 0) + assert.ok(Number.isInteger(entry.deniedCount)) + assert.ok(entry.deniedCount >= 0) + }) + + const expectedEntries = [ + { + 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, + }, + ] + + expectedEntries.forEach((expected) => { + assert.deepStrictEqual( + accessAttempts.find((entry) => entry.resourceId === expected.resourceId), + expected, + ) + }) + }) +}) // ── Mock structure tests ───────────────────────────────────────────────────── @@ -193,7 +308,7 @@ test('getMockAnalyticsSummary() returns complete AnalyticsSummary object', () => ) }) -test('getMockAnalyticsSummary() returns same object as MOCK_ANALYTICS_SUMMARY', () => { +test('getMockAnalyticsSummary() returns the same data as MOCK_ANALYTICS_SUMMARY', () => { const result = getMockAnalyticsSummary() const direct = MOCK_ANALYTICS_SUMMARY