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
1 change: 1 addition & 0 deletions lib/api/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
201 changes: 201 additions & 0 deletions lib/api/mock/domains.ts
Original file line number Diff line number Diff line change
@@ -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
170 changes: 170 additions & 0 deletions test/mock-api-structure.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const sourceFile = ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.ES2020, true, ts.ScriptKind.TS)
const names = new Set<string>()

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<string, unknown>)[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/)
})
})
2 changes: 2 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down