diff --git a/lib/api/mock.ts b/lib/api/mock.ts index 42c4848..f459ed6 100644 --- a/lib/api/mock.ts +++ b/lib/api/mock.ts @@ -6,6 +6,7 @@ * The implementation is organised into focused modules under `lib/api/mock/` * so a structural mistake in one domain cannot break the whole API layer: * + * - domains.ts — canonical domain registry used by structure tests * - fixtures.ts — fixture/seeded data (communities, members, events…) * - state.ts — the in-memory per-community store + persistence * - session.ts — SIWE endpoints + cookie-session simulation diff --git a/lib/api/mock/domains.ts b/lib/api/mock/domains.ts new file mode 100644 index 0000000..17543c2 --- /dev/null +++ b/lib/api/mock/domains.ts @@ -0,0 +1,201 @@ +/** + * Canonical mock API domain registry. + * + * Each entry is an independently parseable module under `lib/api/mock/`. + * The aggregator (`lib/api/mock.ts`) must stay a thin composition layer so a + * syntax error in one domain does not require editing a monolithic file. + * + * `requiredExports` is the stable function/value surface that other mock + * modules and the aggregator rely on. Keep this list in lockstep with the + * domain file's public exports. + */ +export const MOCK_API_DOMAINS = [ + { + file: 'fixtures.ts', + requiredExports: [ + 'DEFAULT_COMMUNITY', + 'mockConnections', + 'mockPrivacySettings', + 'mockReports', + 'setMockConnections', + 'setMockPrivacySettings', + 'setMockReports', + ], + }, + { + file: 'state.ts', + requiredExports: ['communityStates', 'getCommunityState', 'initPromise'], + }, + { + file: 'session.ts', + requiredExports: [ + 'mockGetNonce', + 'mockGetSession', + 'mockGetSessionStatus', + 'mockSiweLogout', + 'mockSiweRefresh', + 'mockSiweVerify', + ], + }, + { + file: 'core.ts', + requiredExports: [ + 'mockGetCommunity', + 'mockGetMeta', + 'mockGetPolicy', + 'mockGetResource', + 'mockListPolicies', + 'mockListResources', + 'mockVerifyWallet', + ], + }, + { + file: 'members.ts', + requiredExports: ['mockGetMembership', 'mockGetProfile', 'mockListMembers', 'mockUpdateProfile'], + }, + { + file: 'analytics.ts', + requiredExports: ['buildAnalyticsDataSource', 'mockGetAnalyticsSummary'], + }, + { + file: 'webhooks.ts', + requiredExports: [ + 'mockListAdminEvents', + 'mockListWebhookEvents', + 'mockReplayEvent', + 'mockSubscribeWebhookEvents', + 'replayMockEvent', + ], + }, + { + file: 'approvals.ts', + requiredExports: [ + 'mockApproveAction', + 'mockAssignRole', + 'mockGetPendingActions', + 'mockRejectAction', + 'mockRemoveRole', + 'mockUpdateApprovalConfig', + 'mockUpdatePolicy', + ], + }, + { + file: 'social.ts', + requiredExports: [ + 'mockAcceptConnectionRequest', + 'mockBlockMember', + 'mockCreateConnectionRequest', + 'mockGetConnections', + 'mockGetPrivacySettings', + 'mockRejectConnectionRequest', + 'mockUnblockMember', + 'mockUpdatePrivacySettings', + ], + }, + { + file: 'moderation.ts', + requiredExports: ['mockGetReport', 'mockListReports', 'mockUpdateReportState'], + }, + { + file: 'governance.ts', + requiredExports: [ + 'mockCastVote', + 'mockCloseProposalVoting', + 'mockCreateProposal', + 'mockDeleteProposal', + 'mockGetMemberVote', + 'mockGetProposal', + 'mockListProposalVotes', + 'mockListProposals', + 'mockPublishProposal', + 'mockResolveProposal', + 'mockUpdateProposal', + ], + }, + { + file: 'controls.ts', + requiredExports: [ + 'MOCK_META_VERSION_OVERRIDE', + 'setMockMetaVersion', + 'setMockResourceFetchDelay', + 'setMockResourceFetchFailure', + 'setMockRoleMutationFailure', + ], + }, + { + file: 'scenarios.ts', + requiredExports: ['applyMockScenario', 'resetMockData'], + }, +] as const + +export type MockApiDomainFile = (typeof MOCK_API_DOMAINS)[number]['file'] + +/** Public aggregator re-exports that existing `lib/api/mock` consumers rely on. */ +export const MOCK_API_PUBLIC_REEXPORTS = [ + 'applyMockScenario', + 'communityStates', + 'getCommunityState', + 'MOCK_META_VERSION_OVERRIDE', + 'mockConnections', + 'mockPrivacySettings', + 'mockReports', + 'replayMockEvent', + 'resetMockData', + 'setMockMetaVersion', + 'setMockResourceFetchDelay', + 'setMockResourceFetchFailure', + 'setMockRoleMutationFailure', +] as const + +/** AccessApi methods that MockAccessApi must keep implementing. */ +export const MOCK_ACCESS_API_METHODS = [ + 'getSession', + 'getCommunity', + 'getMembership', + 'verifyWallet', + 'getProfile', + 'listMembers', + 'listResources', + 'listPolicies', + 'getResource', + 'getPolicy', + 'updateProfile', + 'getMeta', + 'getConnections', + 'getPrivacySettings', + 'updatePrivacySettings', + 'blockMember', + 'unblockMember', + 'createConnectionRequest', + 'acceptConnectionRequest', + 'rejectConnectionRequest', + 'listWebhookEvents', + 'listAdminEvents', + 'subscribeWebhookEvents', + 'getPendingActions', + 'approveAction', + 'rejectAction', + 'updateApprovalConfig', + 'assignRole', + 'removeRole', + 'updatePolicy', + 'listReports', + 'getReport', + 'updateReportState', + 'listProposals', + 'getProposal', + 'getMemberVote', + 'listProposalVotes', + 'castVote', + 'createProposal', + 'updateProposal', + 'publishProposal', + 'closeProposalVoting', + 'resolveProposal', + 'deleteProposal', + 'getNonce', + 'siweVerify', + 'siweRefresh', + 'siweLogout', + 'getSessionStatus', +] as const diff --git a/test/mock-api-structure.test.ts b/test/mock-api-structure.test.ts new file mode 100644 index 0000000..3ad6f8f --- /dev/null +++ b/test/mock-api-structure.test.ts @@ -0,0 +1,170 @@ +import './setup-env' +import { describe, test } from 'node:test' +import * as assert from 'node:assert/strict' +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import ts from 'typescript' + +import { MockAccessApi } from '../lib/api/mock' +import { + MOCK_ACCESS_API_METHODS, + MOCK_API_DOMAINS, + MOCK_API_PUBLIC_REEXPORTS, +} from '../lib/api/mock/domains' + +const REPO_ROOT = path.resolve(__dirname, '..', '..') +const MOCK_DIR = path.join(REPO_ROOT, 'lib', 'api', 'mock') +const AGGREGATOR = path.join(REPO_ROOT, 'lib', 'api', 'mock.ts') + +function parseSyntactics(fileName: string, sourceText: string): readonly ts.Diagnostic[] { + const options: ts.CompilerOptions = { + noResolve: true, + noLib: true, + skipLibCheck: true, + isolatedModules: true, + target: ts.ScriptTarget.ES2020, + module: ts.ModuleKind.ESNext, + } + + const host: ts.CompilerHost = { + getSourceFile: (requested, languageVersion) => + requested === fileName ? ts.createSourceFile(fileName, sourceText, languageVersion, true) : undefined, + getDefaultLibFileName: () => '', + writeFile: () => undefined, + getCurrentDirectory: () => '', + getCanonicalFileName: (file) => file, + useCaseSensitiveFileNames: () => true, + getNewLine: () => '\n', + fileExists: (file) => file === fileName, + readFile: (file) => (file === fileName ? sourceText : undefined), + } + + return ts.createProgram([fileName], options, host).getSyntacticDiagnostics() +} + +function exportedNames(fileName: string, sourceText: string): Set { + const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.ES2020, true, ts.ScriptKind.TS) + const names = new Set() + + for (const statement of sourceFile.statements) { + if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) { + names.add(element.name.text) + } + continue + } + + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined + const isExport = Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)) + if (!isExport) continue + + if (ts.isFunctionDeclaration(statement) && statement.name) names.add(statement.name.text) + else if (ts.isClassDeclaration(statement) && statement.name) names.add(statement.name.text) + else if (ts.isVariableStatement(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) names.add(declaration.name.text) + } + } + } + + return names +} + +function formatDiagnostics(diagnostics: readonly ts.Diagnostic[]): string { + return ts.formatDiagnostics(diagnostics, { + getCanonicalFileName: (file) => file, + getCurrentDirectory: () => '', + getNewLine: () => '\n', + }) +} + +describe('mock API domain module structure', () => { + test('each domain is an independently parseable module with clear exports', () => { + for (const domain of MOCK_API_DOMAINS) { + const filePath = path.join(MOCK_DIR, domain.file) + assert.equal(existsSync(filePath), true, `missing domain module ${domain.file}`) + + const sourceText = readFileSync(filePath, 'utf8') + const diagnostics = parseSyntactics(filePath, sourceText) + assert.equal( + diagnostics.length, + 0, + `${domain.file} has syntax errors:\n${formatDiagnostics(diagnostics)}`, + ) + + const names = exportedNames(filePath, sourceText) + for (const required of domain.requiredExports) { + assert.equal( + names.has(required), + true, + `${domain.file} must export ${required} (found: ${[...names].sort().join(', ')})`, + ) + } + } + }) + + test('a syntax error in one domain does not affect parsing of an unrelated domain', () => { + const brokenDomain = MOCK_API_DOMAINS.find((domain) => domain.file === 'governance.ts') + const healthyDomain = MOCK_API_DOMAINS.find((domain) => domain.file === 'members.ts') + assert.ok(brokenDomain && healthyDomain) + + const brokenPath = path.join(MOCK_DIR, brokenDomain.file) + const healthyPath = path.join(MOCK_DIR, healthyDomain.file) + const brokenSource = `${readFileSync(brokenPath, 'utf8')}\n}}}\n` + const healthySource = readFileSync(healthyPath, 'utf8') + + const brokenDiagnostics = parseSyntactics(brokenPath, brokenSource) + const healthyDiagnostics = parseSyntactics(healthyPath, healthySource) + + assert.ok(brokenDiagnostics.length > 0, 'expected the malformed governance module to fail syntactic parse') + assert.ok( + brokenDiagnostics.every((diagnostic) => diagnostic.file?.fileName === brokenPath), + 'syntax diagnostics must stay scoped to the malformed domain file', + ) + assert.equal( + healthyDiagnostics.length, + 0, + `unrelated domain ${healthyDomain.file} must still parse:\n${formatDiagnostics(healthyDiagnostics)}`, + ) + }) + + test('aggregator stays a thin composition layer and keeps public exports stable', () => { + const aggregatorSource = readFileSync(AGGREGATOR, 'utf8') + const aggregatorLines = aggregatorSource.split('\n').length + assert.ok( + aggregatorLines < 500, + `mock.ts should remain a thin aggregator, not a 2000+ line monolith (was ${aggregatorLines} lines)`, + ) + assert.match(aggregatorSource, /from '\.\/mock\//) + assert.doesNotMatch( + aggregatorSource, + /export async function mockGetMeta/, + 'domain implementations must live in lib/api/mock/*.ts, not in the aggregator', + ) + + const names = exportedNames(AGGREGATOR, aggregatorSource) + assert.equal(names.has('MockAccessApi'), true) + for (const required of MOCK_API_PUBLIC_REEXPORTS) { + assert.equal(names.has(required), true, `mock.ts must re-export ${required}`) + } + + const api = new MockAccessApi('0xabc') + for (const method of MOCK_ACCESS_API_METHODS) { + assert.equal( + typeof (api as unknown as Record)[method], + 'function', + `MockAccessApi must keep ${method} for existing consumers`, + ) + } + }) + + test('application consumers depend on the API boundary, not mock domain files', () => { + const indexSource = readFileSync(path.join(REPO_ROOT, 'lib', 'api', 'index.ts'), 'utf8') + const navSource = readFileSync(path.join(REPO_ROOT, 'components', 'nav.tsx'), 'utf8') + + assert.match(indexSource, /from '\.\/mock-boundary'/) + assert.doesNotMatch(indexSource, /from '\.\/mock\//) + assert.match(navSource, /from ["']@\/lib\/api["']/) + assert.doesNotMatch(navSource, /from ["']@\/lib\/api\/mock/) + }) +}) diff --git a/test/tsconfig.json b/test/tsconfig.json index 2fda9e9..710b656 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -28,6 +28,8 @@ "../lib/api/access-decision.ts", "../lib/rate-limit.ts", "../lib/api/mock.ts", + "../lib/api/mock/**/*.ts", + "../lib/api/mock-boundary.ts", "../lib/wallet/address.ts", "../lib/wallet/chains.ts", "../lib/wallet/config.ts",