Skip to main content
Glama
DocumentationService.test.ts28.5 kB
import { DocumentationService } from '../services/DocumentationService'; import { DocumentationRequest, DocumentationResponse } from '../types/documentation.types'; import { MockSyncFileSystem, MockPathUtils } from '../interfaces/FileSystemAdapter'; import { Stats } from 'fs'; // No more module-level mocking - using dependency injection instead describe('DocumentationService', () => { let service: DocumentationService; let mockFs: MockSyncFileSystem; let mockPath: MockPathUtils; const testProjectRoot = '/test/project'; // Test fixtures const createMockDocumentationRequest = (overrides?: Partial<DocumentationRequest>): DocumentationRequest => ({ action: 'reference', files: ['/test/project/frontend/components/Button.tsx'], context: 'implement new button component', workType: 'implement', ...overrides }); const createMockStats = (isDirectory = false): Stats => ({ isDirectory: () => isDirectory, isFile: () => !isDirectory, mtime: new Date('2024-01-01'), size: 1024, mode: 0o644, uid: 1000, gid: 1000, atime: new Date('2024-01-01'), ctime: new Date('2024-01-01'), birthtime: new Date('2024-01-01'), blksize: 4096, blocks: 8, dev: 2114, ino: 48064969, nlink: 1, rdev: 0, isBlockDevice: () => false, isCharacterDevice: () => false, isFIFO: () => false, isSocket: () => false, isSymbolicLink: () => false } as Stats); beforeEach(() => { // Create fresh mock instances for each test mockFs = new MockSyncFileSystem(); mockPath = new MockPathUtils(); // Setup default directories mockFs.setDirectory(testProjectRoot + '/docs'); mockFs.setDirectory(testProjectRoot + '/MASTER_DOCS'); service = new DocumentationService(testProjectRoot, mockFs, mockPath); }); describe('constructor', () => { it('should initialize with project root and create required directories', () => { // Create fresh mocks without existing directories const freshMockFs = new MockSyncFileSystem(); const freshMockPath = new MockPathUtils(); new DocumentationService(testProjectRoot, freshMockFs, freshMockPath); // Verify directories were created expect(freshMockFs.existsSync(testProjectRoot + '/docs')).toBe(true); expect(freshMockFs.existsSync(testProjectRoot + '/MASTER_DOCS')).toBe(true); }); it('should not create directories if they already exist', () => { // Use the default setup where directories already exist const existingService = new DocumentationService(testProjectRoot, mockFs, mockPath); // Should not throw any errors and work normally expect(existingService).toBeDefined(); }); }); describe('processDocumentationRequest', () => { it('should handle reference action for frontend files', async () => { const request = createMockDocumentationRequest({ action: 'reference', files: ['/test/project/frontend/components/Button.tsx'], workType: 'implement' }); // Mock documentation directories and files mockFs.setDirectory(testProjectRoot + '/docs/frontend'); mockFs.setFile(testProjectRoot + '/docs/frontend/README.md', 'Frontend docs'); mockFs.setStat(testProjectRoot + '/docs/frontend/README.md', createMockStats(false)); const response = await service.processDocumentationRequest(request); expect(response.success).toBe(true); expect(response.documents).toBeDefined(); expect(response.message).toContain('relevant documents'); expect(response.message).toContain('frontend'); }); it('should handle update action with context changes', async () => { const request = createMockDocumentationRequest({ action: 'update', context: 'Updated button component with new accessibility features' }); // Mock existing documentation files mockFs.setDirectory(testProjectRoot + '/docs/frontend'); mockFs.setFile(testProjectRoot + '/docs/frontend/README.md', '# Frontend Documentation\n\nExisting content'); const response = await service.processDocumentationRequest(request); expect(response.success).toBe(true); expect(response.updates).toBeDefined(); expect(response.updates!.length).toBeGreaterThan(0); // Check that the file was updated const updatedContent = mockFs.readFileSync(testProjectRoot + '/docs/frontend/README.md', 'utf8'); expect(updatedContent).toContain('2025-07-30'); }); it('should handle unknown action with error', async () => { const request = createMockDocumentationRequest({ action: 'unknown' as any }); const response = await service.processDocumentationRequest(request); expect(response.success).toBe(false); expect(response.message).toContain('Unknown action'); }); it('should handle processing errors gracefully', async () => { const request = createMockDocumentationRequest(); // Mock fs error by overriding the existsSync method const originalExistsSync = mockFs.existsSync; mockFs.existsSync = jest.fn().mockImplementation(() => { throw new Error('File system error'); }); const response = await service.processDocumentationRequest(request); // Restore original method mockFs.existsSync = originalExistsSync; expect(response.success).toBe(false); expect(response.message).toContain('Documentation processing failed'); }); }); describe('category detection', () => { it('should detect frontend category from file paths', () => { const frontendFiles = [ '/test/project/frontend/components/Button.tsx', '/test/project/castplan-electron/src/renderer/App.tsx', '/test/project/src/components/Header.jsx' ]; frontendFiles.forEach(file => { const category = service['detectCategory'](file); expect(category).toBe('frontend'); }); }); it('should detect backend category from file paths', () => { const backendFiles = [ '/test/project/backend/src/routes/api.ts', '/test/project/backend/services/UserService.js', '/test/project/src/graphql/resolvers.ts' ]; backendFiles.forEach(file => { const category = service['detectCategory'](file); expect(category).toBe('backend'); }); }); it('should detect electron category for castplan-electron paths without conflicting patterns', () => { // Files with /castplan-electron/ but no backend patterns (.js, .ts, etc.) match electron const electronFiles = [ '/test/project/castplan-electron/main.txt', // No backend patterns '/test/project/castplan-electron/config.xml', // No .js substring '/test/project/castplan-electron/renderer/app.html' // No backend extensions ]; electronFiles.forEach(file => { const category = service['detectCategory'](file); expect(category).toBe('electron'); }); }); it('should prioritize backend patterns over electron for JS/TS files', () => { // Files with both /castplan-electron/ and backend extensions match backend first const backendFiles = [ '/test/project/castplan-electron/main.js', // .js matches backend first '/test/project/castplan-electron/config.ts' // .ts matches backend first ]; backendFiles.forEach(file => { const category = service['detectCategory'](file); expect(category).toBe('backend'); }); }); it('should prioritize backend over electron for ambiguous paths', () => { // Files with both backend and electron patterns should match backend first const backendFiles = [ '/test/project/src/collectors/DataCollector.ts', // Has collectors/ but not /castplan-electron/ '/test/project/api/services/parser.js' // Has services/ pattern ]; backendFiles.forEach(file => { const category = service['detectCategory'](file); expect(category).toBe('backend'); }); }); it('should detect database category from file paths', () => { const dbFiles = [ '/test/project/prisma/schema.prisma', '/test/project/migrations/init.sql', '/test/project/src/prisma/schema.prisma' ]; dbFiles.forEach(file => { const category = service['detectCategory'](file); expect(category).toBe('database'); }); }); it('should detect testing category for test-specific patterns', () => { // Files that clearly match testing patterns before other categories const testFiles = [ '/test/project/test-utils/helper.test.txt', // .test. pattern, avoid .js '/test/project/spec/validation.spec.txt', // .spec. pattern, avoid .js '/test/project/tests/unit/helper.txt' // /tests/ pattern, avoid .js ]; testFiles.forEach(file => { const category = service['detectCategory'](file); expect(category).toBe('testing'); }); }); it('should prioritize frontend/backend over testing for ambiguous test files', () => { // Test files that also match frontend/backend patterns should go to those categories const ambiguousFiles = [ '/test/project/components/Button.test.tsx', // Frontend wins (.tsx) '/test/project/api/UserService.spec.ts' // Backend wins (.ts) ]; const expectedCategories = ['frontend', 'backend']; ambiguousFiles.forEach((file, index) => { const category = service['detectCategory'](file); expect(category).toBe(expectedCategories[index]); }); }); it('should handle category precedence based on pattern matching order', () => { const categoryTests = [ // These should match development (no matching patterns) { file: '/test/project/README.md', expected: 'development' }, { file: '/test/project/CHANGELOG.txt', expected: 'development' }, // Files that match specific patterns should go to their categories { file: '/test/project/castplan-electron/main.exe', expected: 'electron' }, { file: '/test/project/schema.prisma', expected: 'database' }, // Files with backend patterns match backend even if they seem generic { file: '/test/project/config/setup.js', expected: 'backend' }, // .js matches backend { file: '/test/project/utils/helper.ts', expected: 'backend' } // .ts matches backend ]; categoryTests.forEach(({ file, expected }) => { const category = service['detectCategory'](file); expect(category).toBe(expected); }); }); }); describe('work type detection', () => { it('should detect implement work type', () => { const contexts = [ 'implement new user authentication system', 'add payment processing functionality', 'create dashboard component', 'build API endpoint for orders' ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('implement'); }); }); it('should detect fix work type', () => { const contexts = [ 'fix login bug in authentication flow', 'resolve error in payment processing', 'bug in user registration form', 'issue with database connection' ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('fix'); }); }); it('should detect refactor work type', () => { const contexts = [ 'refactor user service for better maintainability', 'cleanup legacy authentication code', 'restructure component hierarchy', 'improve code organization' ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('refactor'); }); }); it('should detect optimize work type', () => { const contexts = [ 'optimize database queries', // Pure optimize keyword 'performance bottleneck analysis', // Performance keyword 'efficiency problems need attention', // Efficiency keyword 'speed optimization required' // Speed keyword ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('optimize'); }); }); it('should handle work type precedence correctly', () => { // Test cases where multiple keywords might match const workTypeTests = [ { context: 'fix performance issues', expected: 'fix' }, // fix comes before optimize { context: 'improve and refactor code', expected: 'refactor' }, // refactor comes before optimize { context: 'security vulnerability found', expected: 'security' }, { context: 'implement optimization features', expected: 'implement' } // implement comes first ]; workTypeTests.forEach(({ context, expected }) => { const workType = service['detectWorkType'](context); expect(workType).toBe(expected); }); }); it('should detect test work type', () => { const contexts = [ 'test coverage for user service', 'spec for authentication flow', 'validate payment processing logic', 'coverage analysis needed' ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('test'); }); }); it('should detect deploy work type', () => { const contexts = [ 'deploy application to production', 'release new version to staging', 'package application for distribution' ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('deploy'); }); }); it('should detect security work type', () => { const contexts = [ 'security audit recommendations', 'authentication vulnerabilities found', 'permission checks needed', 'vulnerability assessment required' ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('security'); }); }); it('should default to implement for unrecognized contexts', () => { const contexts = [ 'some random context', 'update documentation', 'general maintenance' ]; contexts.forEach(context => { const workType = service['detectWorkType'](context); expect(workType).toBe('implement'); }); }); }); describe('searchDocumentation', () => { it('should search for content in documentation files', async () => { // Setup mock files in both docs and master docs directories mockFs.setFile(testProjectRoot + '/docs/doc1.md', 'This file contains the search query'); mockFs.setStat(testProjectRoot + '/docs/doc1.md', createMockStats(false)); mockFs.setFile(testProjectRoot + '/docs/doc2.md', 'This file does not contain it'); mockFs.setStat(testProjectRoot + '/docs/doc2.md', createMockStats(false)); const results = await service.searchDocumentation('search query'); expect(results).toHaveLength(1); expect(results[0]).toMatchObject({ path: expect.stringContaining('doc1.md'), category: expect.any(String), relevance: 'medium', lastModified: expect.any(Date) }); }); it('should search in both docs and master docs directories', async () => { // Setup files in both directories mockFs.setFile(testProjectRoot + '/docs/test1.md', 'contains query'); mockFs.setStat(testProjectRoot + '/docs/test1.md', createMockStats(false)); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/test2.md', 'contains query too'); mockFs.setStat(testProjectRoot + '/MASTER_DOCS/test2.md', createMockStats(false)); const results = await service.searchDocumentation('query'); // Should find files from both directories expect(results.length).toBe(2); expect(results.some(r => r.path.includes('/docs/'))).toBe(true); expect(results.some(r => r.path.includes('/MASTER_DOCS/'))).toBe(true); }); it('should handle file read errors gracefully', async () => { // Setup a file that exists but will cause a read error const errorFile = testProjectRoot + '/docs/error.md'; mockFs.setFile(errorFile, 'content'); // File must exist for scanDirectory to find it mockFs.setStat(errorFile, createMockStats(false)); // Override readFileSync to throw error for specific file const originalReadFileSync = mockFs.readFileSync; mockFs.readFileSync = jest.fn().mockImplementation((path, encoding) => { if (path.includes('error.md')) { throw new Error('File read error'); } return originalReadFileSync.call(mockFs, path, encoding); }); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const results = await service.searchDocumentation('query'); expect(results).toHaveLength(0); // Check that error was logged expect(consoleSpy).toHaveBeenCalled(); expect(consoleSpy.mock.calls.some(call => call[0].includes('Error searching file') )).toBe(true); // Restore mocks mockFs.readFileSync = originalReadFileSync; consoleSpy.mockRestore(); }); it('should handle missing directories', async () => { // Create a service with no directories set up const emptyMockFs = new MockSyncFileSystem(); const emptyService = new DocumentationService(testProjectRoot, emptyMockFs, mockPath); const results = await emptyService.searchDocumentation('query'); expect(results).toHaveLength(0); }); }); describe('validateDocumentationStructure', () => { it('should validate complete documentation structure', async () => { // Setup all required directories and files mockFs.setFile(testProjectRoot + '/MASTER_DOCS/README.md', 'content'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/01-ARCHITECTURE-OVERVIEW.md', 'content'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/03-QUICK-START-GUIDE.md', 'content'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/04-API-REFERENCE.md', 'content'); mockFs.setFile(testProjectRoot + '/CLAUDE.md', 'content'); const result = await service.validateDocumentationStructure(); expect(result.valid).toBe(true); expect(result.issues).toHaveLength(0); }); it('should identify missing directories', async () => { // Create a service but remove directories after construction const emptyMockFs = new MockSyncFileSystem(); const missingDirsService = new DocumentationService(testProjectRoot, emptyMockFs, mockPath); // Remove directories that were just created to simulate missing directories emptyMockFs.clear(); emptyMockFs.setFile(testProjectRoot + '/CLAUDE.md', 'content'); // CLAUDE.md exists const result = await missingDirsService.validateDocumentationStructure(); expect(result.valid).toBe(false); expect(result.issues).toContain('Missing required directory: docs'); expect(result.issues).toContain('Missing required directory: MASTER_DOCS'); }); it('should identify missing master documentation files', async () => { // Setup directories but not the required master doc files // (directories are already set up in beforeEach, just missing files) mockFs.setFile(testProjectRoot + '/CLAUDE.md', 'content'); // CLAUDE.md exists const result = await service.validateDocumentationStructure(); expect(result.valid).toBe(false); expect(result.issues).toContain('Missing master documentation: README.md'); expect(result.issues).toContain('Missing master documentation: 01-ARCHITECTURE-OVERVIEW.md'); expect(result.issues).toContain('Missing master documentation: 03-QUICK-START-GUIDE.md'); expect(result.issues).toContain('Missing master documentation: 04-API-REFERENCE.md'); }); it('should identify missing CLAUDE.md', async () => { // Setup required master docs but not CLAUDE.md mockFs.setFile(testProjectRoot + '/MASTER_DOCS/README.md', 'content'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/01-ARCHITECTURE-OVERVIEW.md', 'content'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/03-QUICK-START-GUIDE.md', 'content'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/04-API-REFERENCE.md', 'content'); // Don't set CLAUDE.md const result = await service.validateDocumentationStructure(); expect(result.valid).toBe(false); expect(result.issues).toContain('Missing CLAUDE.md in project root'); }); }); describe('getChangeHistory', () => { it('should return change history', async () => { const history = await service.getChangeHistory(); expect(Array.isArray(history)).toBe(true); // Initially empty expect(history).toHaveLength(0); }); it('should include changes after update operations', async () => { const updateRequest = createMockDocumentationRequest({ action: 'update', context: 'Updated component documentation' }); // Setup existing documentation mockFs.setDirectory(testProjectRoot + '/docs/frontend'); mockFs.setFile(testProjectRoot + '/docs/frontend/README.md', '# Documentation\n\nContent'); mockFs.setDirectory(testProjectRoot + '/MASTER_DOCS'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/README.md', '# Master Docs\n\nContent'); await service.processDocumentationRequest(updateRequest); const history = await service.getChangeHistory(); expect(history.length).toBeGreaterThan(0); expect(history[0]).toMatchObject({ timestamp: expect.any(String), files: expect.any(Array), changes: expect.any(String), context: expect.any(String), category: expect.any(Array) }); }); }); describe('error handling', () => { it('should handle file system permission errors during directory creation', () => { // Create a mock file system that throws on mkdir const brokenMockFs = new MockSyncFileSystem(); brokenMockFs.mkdirSync = jest.fn().mockImplementation(() => { throw new Error('EACCES: permission denied'); }); expect(() => { new DocumentationService('/restricted/path', brokenMockFs, mockPath); }).toThrow('EACCES: permission denied'); // Should propagate the error }); it('should handle invalid project root paths', () => { const invalidPaths = [''] as any[]; invalidPaths.forEach(invalidPath => { expect(() => { new DocumentationService(invalidPath); }).not.toThrow(); // Should handle gracefully }); }); it('should handle directory scanning errors', async () => { const request = createMockDocumentationRequest(); // Setup a broken readdirSync method const originalReaddirSync = mockFs.readdirSync; mockFs.readdirSync = jest.fn().mockImplementation(() => { throw new Error('Cannot read directory'); }); const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); const response = await service.processDocumentationRequest(request); // Should still succeed despite directory scanning errors expect(response.success).toBe(true); // Restore original method mockFs.readdirSync = originalReaddirSync; consoleSpy.mockRestore(); }); }); describe('integration tests', () => { it('should handle complete workflow for frontend component documentation', async () => { const request = createMockDocumentationRequest({ action: 'reference', files: ['/test/project/frontend/components/Button.tsx'], context: 'implementing accessible button component', workType: 'implement' }); // Mock complete file system setup mockFs.setDirectory(testProjectRoot + '/docs/frontend'); mockFs.setFile(testProjectRoot + '/docs/frontend/README.md', 'Frontend README'); mockFs.setFile(testProjectRoot + '/docs/frontend/components.md', 'Components guide'); mockFs.setStat(testProjectRoot + '/docs/frontend/README.md', createMockStats(false)); mockFs.setStat(testProjectRoot + '/docs/frontend/components.md', createMockStats(false)); const response = await service.processDocumentationRequest(request); expect(response.success).toBe(true); expect(response.documents).toBeDefined(); expect(response.documents!.length).toBeGreaterThan(0); // Verify category detection worked const context = service['buildDocumentationContext'](request); expect(context.category).toBe('frontend'); expect(context.workType).toBe('implement'); }); it('should handle complete workflow for backend API documentation update', async () => { const request = createMockDocumentationRequest({ action: 'update', files: ['/test/project/backend/src/routes/users.ts'], context: 'added new user authentication endpoints', workType: 'implement' }); mockFs.setDirectory(testProjectRoot + '/docs/backend'); mockFs.setFile(testProjectRoot + '/docs/backend/README.md', '# Backend API\n\nExisting documentation'); mockFs.setDirectory(testProjectRoot + '/MASTER_DOCS'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/README.md', '# Master Docs\n\nContent'); const response = await service.processDocumentationRequest(request); expect(response.success).toBe(true); expect(response.updates).toBeDefined(); // Check that files were updated const backendContent = mockFs.readFileSync(testProjectRoot + '/docs/backend/README.md', 'utf8'); expect(backendContent).toContain('2025-07-30'); // Verify change history was recorded const history = await service.getChangeHistory(); expect(history.length).toBeGreaterThan(0); }); it('should maintain data consistency across multiple operations', async () => { const requests = [ createMockDocumentationRequest({ action: 'update', context: 'First update' }), createMockDocumentationRequest({ action: 'update', context: 'Second update' }), createMockDocumentationRequest({ action: 'reference', context: 'Reference check' }) ]; // Setup documentation structure for multiple operations mockFs.setDirectory(testProjectRoot + '/docs/frontend'); mockFs.setFile(testProjectRoot + '/docs/frontend/README.md', '# Documentation\n\nContent'); mockFs.setStat(testProjectRoot + '/docs/frontend/README.md', createMockStats(false)); mockFs.setDirectory(testProjectRoot + '/MASTER_DOCS'); mockFs.setFile(testProjectRoot + '/MASTER_DOCS/README.md', '# Master Documentation\n\nContent'); mockFs.setStat(testProjectRoot + '/MASTER_DOCS/README.md', createMockStats(false)); // Process all requests for (const request of requests) { const response = await service.processDocumentationRequest(request); expect(response.success).toBe(true); } // Verify change history accumulated correctly const history = await service.getChangeHistory(); expect(history).toHaveLength(2); // Only update actions record history // Verify search still works const searchResults = await service.searchDocumentation('Documentation'); expect(searchResults).toBeDefined(); }); }); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/Ghostseller/CastPlan_mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server