Skip to main content
Glama
WorkDocumentConnectionService.test.ts25 kB
import { WorkDocumentConnectionService } from '../services/FileBasedWorkDocumentConnectionService'; import { Logger } from 'winston'; import { MockAsyncFileSystem, MockPathUtils } from '../interfaces/FileSystemAdapter'; import { Stats } from 'fs'; import * as path from 'path'; // Test constants const TEST_PATHS = { DOCS_DIR: '/docs', TEST_FILE: '/docs/test.md', NESTED_FILE: '/docs/subdir/nested.md', CURRENT_FILE: '/docs/current/test.md', EMPTY_DIR: '/empty', } as const; const TEST_WORK_ITEMS = { TASK_123: 'TASK-123', FEATURE_456: 'FEATURE-456', TASK_789: 'TASK-789', BUG_101: 'BUG-101', TASK_999: 'TASK-999', } as const; const TEST_FILES = { DOC1: 'doc1.md', DOC2: 'doc2.md', ORPHAN: 'orphan.md', NESTED: 'nested.md', HUB: 'hub.md', } as const; const EXPECTED_COUNTS = { BASIC_GRAPH_NODES: 3, BASIC_GRAPH_EDGES: 4, WORK_ITEMS_IN_SAMPLE: 1, HUB_CONNECTIONS: 4, } as const; // Type-safe mock interfaces interface MockLogger extends Logger { info: jest.MockedFunction<Logger['info']>; warn: jest.MockedFunction<Logger['warn']>; error: jest.MockedFunction<Logger['error']>; debug: jest.MockedFunction<Logger['debug']>; } // Mock file system interfaces now provided by MockAsyncFileSystem // Mock stats interface to match Node.js Stats interface MockStats extends Stats { isFile(): boolean; isDirectory(): boolean; } // Mock winston logger with proper typing const mockLogger: MockLogger = { info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(), } as MockLogger; // No more module-level mocking - using dependency injection instead /** * Test data builder for creating consistent mock objects and test fixtures */ class TestDataBuilder { /** * Creates a sample document content with work items and links */ static createSampleDocumentContent(): string { return ` # Test Document Work context: ${TEST_WORK_ITEMS.TASK_123}, ${TEST_WORK_ITEMS.FEATURE_456} Related: [[other-doc.md]], [[another-doc.md]] ## Content This document relates to work items ${TEST_WORK_ITEMS.TASK_789} and ${TEST_WORK_ITEMS.BUG_101}. See also [[reference.md]] for more details. `.trim(); } /** * Creates a mock connection graph with specified structure */ static createMockGraph(options: { documents?: string[]; workItems?: string[]; edges?: Array<{ source: string; target: string; type: 'link' | 'workItem' }>; } = {}) { const { documents = [TEST_PATHS.TEST_FILE], workItems = [TEST_WORK_ITEMS.TASK_123], edges = [] } = options; const nodes = [ ...documents.map(doc => ({ id: doc, type: 'document' as const, label: path.basename(doc) })), ...workItems.map(item => ({ id: item, type: 'workItem' as const, label: item })) ]; return { nodes, edges, workItems }; } /** * Creates mock file system stats */ static createMockStats(isFile: boolean): MockStats { return { isFile: () => isFile, isDirectory: () => !isFile, 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 MockStats; } /** * Creates a complex graph for testing hub documents */ static createHubDocumentGraph() { const hubPath = `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.HUB}`; const doc1Path = `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`; const doc2Path = `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC2}`; const doc3Path = `${TEST_PATHS.DOCS_DIR}/doc3.md`; return this.createMockGraph({ documents: [hubPath, doc1Path, doc2Path, doc3Path], edges: [ { source: hubPath, target: doc1Path, type: 'link' as const }, { source: hubPath, target: doc2Path, type: 'link' as const }, { source: hubPath, target: doc3Path, type: 'link' as const }, { source: doc1Path, target: hubPath, type: 'link' as const }, ] }); } } /** * Test helper functions for common setup patterns */ class TestHelpers { /** * Sets up file system mocks for directory traversal */ static setupDirectoryMocks( mockFs: MockAsyncFileSystem, dirPath: string, files: Array<{ name: string; isFile: boolean; content?: string }> ): void { // Set up directory mockFs.setDirectory(dirPath); // Setup files and directories files.forEach(file => { const filePath = `${dirPath}/${file.name}`; if (file.isFile) { mockFs.setFile(filePath, file.content || ''); mockFs.setStat(filePath, TestDataBuilder.createMockStats(true)); } else { mockFs.setDirectory(filePath); mockFs.setStat(filePath, TestDataBuilder.createMockStats(false)); } }); } /** * Expects a connection graph to have specific structure */ static expectGraphStructure( graph: any, expectedNodes: number, expectedEdges: number, expectedWorkItems: number ): void { expect(graph.nodes).toHaveLength(expectedNodes); expect(graph.edges).toHaveLength(expectedEdges); expect(graph.workItems).toHaveLength(expectedWorkItems); } /** * Expects specific document connections */ static expectDocumentConnections( connections: any, expectedWorkItems: string[], expectedLinks: string[] ): void { expect(connections.workItems).toEqual(expectedWorkItems); expect(connections.linkedDocuments).toEqual(expectedLinks); expect(connections.backlinks).toEqual([]); } } describe('WorkDocumentConnectionService', () => { let service: WorkDocumentConnectionService; let mockFs: MockAsyncFileSystem; let mockPath: MockPathUtils; beforeEach(() => { // Create fresh mock instances for each test mockFs = new MockAsyncFileSystem(); mockPath = new MockPathUtils(); // Create service with injected dependencies service = new WorkDocumentConnectionService(mockLogger, mockFs, mockPath); }); describe('Initialization', () => { it('should initialize service with logger', () => { expect(service).toBeDefined(); expect(mockLogger.info).toHaveBeenCalledWith('WorkDocumentConnectionService initialized'); }); }); describe('Document Connection Analysis', () => { describe('analyzeConnections', () => { beforeEach(() => { // Setup default mock content for most tests mockFs.setFile(TEST_PATHS.TEST_FILE, TestDataBuilder.createSampleDocumentContent()); }); describe('Basic Connection Analysis', () => { it('should analyze document connections from sample content', async () => { const connections = await service.analyzeConnections(TEST_PATHS.TEST_FILE); TestHelpers.expectDocumentConnections( connections, [TEST_WORK_ITEMS.TASK_123, TEST_WORK_ITEMS.FEATURE_456, TEST_WORK_ITEMS.TASK_789, TEST_WORK_ITEMS.BUG_101], [`${TEST_PATHS.DOCS_DIR}/other-doc.md`, `${TEST_PATHS.DOCS_DIR}/another-doc.md`, `${TEST_PATHS.DOCS_DIR}/reference.md`] ); }); it('should find unique work items when duplicated', async () => { const content = `${TEST_WORK_ITEMS.TASK_123} mentioned twice ${TEST_WORK_ITEMS.TASK_123} and ${TEST_WORK_ITEMS.FEATURE_456}`; mockFs.setFile('/test.md', content); const connections = await service.analyzeConnections('/test.md'); expect(connections.workItems).toEqual([TEST_WORK_ITEMS.TASK_123, TEST_WORK_ITEMS.FEATURE_456]); }); it('should handle documents with no connections', async () => { mockFs.setFile('/plain.md', 'Plain text with no links or work items'); const connections = await service.analyzeConnections('/plain.md'); TestHelpers.expectDocumentConnections(connections, [], []); }); }); describe('Path Resolution', () => { it('should resolve relative document links correctly', async () => { const content = '[[../parent/doc.md]] [[./sibling.md]] [[nested/child.md]]'; mockFs.setFile(TEST_PATHS.CURRENT_FILE, content); const connections = await service.analyzeConnections(TEST_PATHS.CURRENT_FILE); // Our simple mock path doesn't resolve paths, so we expect the unresolved paths expect(connections.linkedDocuments).toEqual([ `${TEST_PATHS.DOCS_DIR}/current/../parent/doc.md`, `${TEST_PATHS.DOCS_DIR}/current/./sibling.md`, `${TEST_PATHS.DOCS_DIR}/current/nested/child.md`, ]); }); }); describe('Custom Patterns', () => { it('should handle custom work item patterns', async () => { const content = 'JIRA-123 and GH-456 and CUSTOM-789'; mockFs.setFile('/test.md', content); const connections = await service.analyzeConnections('/test.md', { workItemPattern: /(?:JIRA|GH|CUSTOM)-\d+/g, }); expect(connections.workItems).toEqual(['JIRA-123', 'GH-456', 'CUSTOM-789']); }); }); describe('Backlink Discovery', () => { it('should find backlinks when requested', async () => { // Setup filesystem mock with files that link back to target TestHelpers.setupDirectoryMocks(mockFs, TEST_PATHS.DOCS_DIR, [ { name: TEST_FILES.DOC1, isFile: true, content: 'This links to [[test.md]]' }, { name: TEST_FILES.DOC2, isFile: true, content: 'No links here' }, ]); // Target file content mockFs.setFile(TEST_PATHS.TEST_FILE, TestDataBuilder.createSampleDocumentContent()); const connections = await service.analyzeConnections(TEST_PATHS.TEST_FILE, { findBacklinks: true, }); expect(connections.backlinks).toEqual([`${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`]); }); }); describe('Error Handling', () => { it('should handle file read errors gracefully', async () => { // Don't set up the file, so it will throw when trying to read await expect(service.analyzeConnections('/missing.md')).rejects.toThrow('ENOENT'); expect(mockLogger.error).toHaveBeenCalledWith('Failed to analyze connections:', expect.any(Error)); }); }); }); }); describe('Connection Graph Building', () => { describe('buildConnectionGraph', () => { describe('Basic Graph Construction', () => { it('should build connection graph for directory with linked documents', async () => { // Setup directory with two markdown files that link to each other and share a work item TestHelpers.setupDirectoryMocks(mockFs, TEST_PATHS.DOCS_DIR, [ { name: TEST_FILES.DOC1, isFile: true, content: `Links to [[${TEST_FILES.DOC2}]] and ${TEST_WORK_ITEMS.TASK_123}` }, { name: TEST_FILES.DOC2, isFile: true, content: `Links back to [[${TEST_FILES.DOC1}]] and ${TEST_WORK_ITEMS.TASK_123}` } ]); const graph = await service.buildConnectionGraph(TEST_PATHS.DOCS_DIR); // Expect 3 nodes: 2 documents + 1 work item // Expect 4 edges: 2 document links + 2 work item links TestHelpers.expectGraphStructure(graph, EXPECTED_COUNTS.BASIC_GRAPH_NODES, EXPECTED_COUNTS.BASIC_GRAPH_EDGES, EXPECTED_COUNTS.WORK_ITEMS_IN_SAMPLE); expect(graph.workItems).toEqual([TEST_WORK_ITEMS.TASK_123]); // Verify node types are correct const docNodes = graph.nodes.filter(n => n.type === 'document'); const workNodes = graph.nodes.filter(n => n.type === 'workItem'); expect(docNodes).toHaveLength(2); expect(workNodes).toHaveLength(1); }); it('should handle empty directory gracefully', async () => { mockFs.setDirectory(TEST_PATHS.EMPTY_DIR); const graph = await service.buildConnectionGraph(TEST_PATHS.EMPTY_DIR); TestHelpers.expectGraphStructure(graph, 0, 0, 0); }); }); describe('File Filtering', () => { it('should exclude non-markdown files from graph', async () => { TestHelpers.setupDirectoryMocks(mockFs, TEST_PATHS.DOCS_DIR, [ { name: 'doc.md', isFile: true, content: 'Content of doc.md' }, { name: 'image.png', isFile: true }, { name: 'data.json', isFile: true } ]); const graph = await service.buildConnectionGraph(TEST_PATHS.DOCS_DIR); expect(graph.nodes).toHaveLength(1); expect(graph.nodes[0].id).toBe(`${TEST_PATHS.DOCS_DIR}/doc.md`); }); }); describe('Nested Directory Handling', () => { it('should process nested directories recursively', async () => { // Setup nested directory structure TestHelpers.setupDirectoryMocks(mockFs, TEST_PATHS.DOCS_DIR, [ { name: 'subdir', isFile: false } ]); TestHelpers.setupDirectoryMocks(mockFs, `${TEST_PATHS.DOCS_DIR}/subdir`, [ { name: TEST_FILES.NESTED, isFile: true, content: `Nested content with ${TEST_WORK_ITEMS.TASK_999}` } ]); const graph = await service.buildConnectionGraph(TEST_PATHS.DOCS_DIR); // Should have 1 document + 1 work item TestHelpers.expectGraphStructure(graph, 2, 1, 1); expect(graph.nodes.find(n => n.id === TEST_PATHS.NESTED_FILE)).toBeDefined(); }); }); describe('Error Resilience', () => { it('should handle individual file analysis errors gracefully', async () => { TestHelpers.setupDirectoryMocks(mockFs, TEST_PATHS.DOCS_DIR, [ { name: 'good.md', isFile: true, content: 'Good content' } ]); // Add error file but override readFile to throw error const errorPath = `${TEST_PATHS.DOCS_DIR}/error.md`; mockFs.setFile(errorPath, 'content'); // File must exist for readdir to find it mockFs.setStat(errorPath, TestDataBuilder.createMockStats(true)); // Override readFile to throw error for specific file const originalReadFile = mockFs.readFile; mockFs.readFile = jest.fn().mockImplementation((path) => { if (path.includes('error.md')) { throw new Error('ENOENT: file read error'); } return originalReadFile.call(mockFs, path); }); const graph = await service.buildConnectionGraph(TEST_PATHS.DOCS_DIR); // Should only contain the successfully processed file expect(graph.nodes).toHaveLength(1); expect(mockLogger.warn).toHaveBeenCalledWith( `Failed to analyze ${errorPath}:`, expect.any(Error) ); // Restore original method mockFs.readFile = originalReadFile; }); }); }); }); describe('Orphaned Document Detection', () => { describe('findOrphanedDocuments', () => { describe('Orphan Identification', () => { it('should identify documents with no connections as orphaned', async () => { // Create graph with connected and orphaned documents const mockGraph = TestDataBuilder.createMockGraph({ documents: [`${TEST_PATHS.DOCS_DIR}/connected1.md`, `${TEST_PATHS.DOCS_DIR}/connected2.md`, `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.ORPHAN}`], workItems: [TEST_WORK_ITEMS.TASK_123], edges: [ { source: `${TEST_PATHS.DOCS_DIR}/connected1.md`, target: `${TEST_PATHS.DOCS_DIR}/connected2.md`, type: 'link' }, { source: `${TEST_PATHS.DOCS_DIR}/connected1.md`, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }, ] }); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(mockGraph); const orphaned = await service.findOrphanedDocuments(TEST_PATHS.DOCS_DIR); expect(orphaned).toEqual([`${TEST_PATHS.DOCS_DIR}/${TEST_FILES.ORPHAN}`]); }); it('should not count self-references as valid connections', async () => { const selfRefDoc = `${TEST_PATHS.DOCS_DIR}/self-ref.md`; const mockGraph = TestDataBuilder.createMockGraph({ documents: [selfRefDoc], edges: [{ source: selfRefDoc, target: selfRefDoc, type: 'link' }] }); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(mockGraph); const orphaned = await service.findOrphanedDocuments(TEST_PATHS.DOCS_DIR); expect(orphaned).toEqual([selfRefDoc]); }); it('should consider work item connections as valid', async () => { const docWithTask = `${TEST_PATHS.DOCS_DIR}/with-task.md`; const mockGraph = TestDataBuilder.createMockGraph({ documents: [docWithTask], workItems: [TEST_WORK_ITEMS.TASK_123], edges: [{ source: docWithTask, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }] }); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(mockGraph); const orphaned = await service.findOrphanedDocuments(TEST_PATHS.DOCS_DIR); expect(orphaned).toEqual([]); }); }); describe('Edge Cases', () => { it('should handle empty graphs gracefully', async () => { const emptyGraph = TestDataBuilder.createMockGraph(); // Override to create truly empty graph emptyGraph.nodes = []; emptyGraph.workItems = []; jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(emptyGraph); const orphaned = await service.findOrphanedDocuments(TEST_PATHS.DOCS_DIR); expect(orphaned).toEqual([]); }); }); }); }); describe('Work Item Document Discovery', () => { describe('findWorkItemDocuments', () => { describe('Document Lookup by Work Item', () => { it('should find all documents related to specific work item', async () => { const doc1Path = `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`; const doc2Path = `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC2}`; const doc3Path = `${TEST_PATHS.DOCS_DIR}/doc3.md`; const mockGraph = TestDataBuilder.createMockGraph({ documents: [doc1Path, doc2Path, doc3Path], workItems: [TEST_WORK_ITEMS.TASK_123, TEST_WORK_ITEMS.FEATURE_456], edges: [ { source: doc1Path, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }, { source: doc2Path, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }, { source: doc3Path, target: TEST_WORK_ITEMS.FEATURE_456, type: 'workItem' }, ] }); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(mockGraph); const documents = await service.findWorkItemDocuments(TEST_PATHS.DOCS_DIR, TEST_WORK_ITEMS.TASK_123); expect(documents).toHaveLength(2); expect(documents).toContain(doc1Path); expect(documents).toContain(doc2Path); expect(documents).not.toContain(doc3Path); }); it('should return empty array for unknown work item', async () => { const mockGraph = TestDataBuilder.createMockGraph({ documents: [`${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`], workItems: [TEST_WORK_ITEMS.TASK_123], edges: [{ source: `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }] }); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(mockGraph); const documents = await service.findWorkItemDocuments(TEST_PATHS.DOCS_DIR, TEST_WORK_ITEMS.TASK_999); expect(documents).toEqual([]); }); it('should handle case-insensitive work item search', async () => { const doc1Path = `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`; const mockGraph = TestDataBuilder.createMockGraph({ documents: [doc1Path], workItems: [TEST_WORK_ITEMS.TASK_123], edges: [{ source: doc1Path, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }] }); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(mockGraph); const documents = await service.findWorkItemDocuments(TEST_PATHS.DOCS_DIR, 'task-123'); expect(documents).toHaveLength(1); expect(documents).toContain(doc1Path); }); }); }); }); describe('Report Generation', () => { describe('generateConnectionReport', () => { beforeEach(() => { // No special setup needed - mocks handle this automatically }); describe('Comprehensive Reporting', () => { it('should generate detailed connection report with all sections', async () => { // Create graph with connected and orphaned documents const mockGraph = TestDataBuilder.createMockGraph({ documents: [`${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`, `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC2}`, `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.ORPHAN}`], workItems: [TEST_WORK_ITEMS.TASK_123], edges: [ { source: `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`, target: `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC2}`, type: 'link' }, { source: `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC1}`, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }, { source: `${TEST_PATHS.DOCS_DIR}/${TEST_FILES.DOC2}`, target: TEST_WORK_ITEMS.TASK_123, type: 'workItem' }, ] }); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(mockGraph); const reportPath = await service.generateConnectionReport(TEST_PATHS.DOCS_DIR); // Verify report was created and contains expected content expect(reportPath).toContain('.bmad/reports'); // Read the generated report content const reportContent = await mockFs.readFile(reportPath); expect(reportContent).toContain('# Document Connection Report'); expect(reportContent).toContain('Total Documents: 3'); expect(reportContent).toContain('Total Work Items: 1'); expect(reportContent).toContain('## Orphaned Documents'); expect(reportContent).toContain(`- ${TEST_FILES.ORPHAN}`); expect(reportContent).toContain('## Work Items'); expect(reportContent).toContain(`### ${TEST_WORK_ITEMS.TASK_123}`); expect(reportPath).toBeDefined(); }); it('should handle empty directories with appropriate messaging', async () => { const emptyGraph = TestDataBuilder.createMockGraph(); emptyGraph.nodes = []; emptyGraph.workItems = []; jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(emptyGraph); const reportPath = await service.generateConnectionReport(TEST_PATHS.EMPTY_DIR); const reportContent = await mockFs.readFile(reportPath); expect(reportContent).toContain('No documents found'); }); it('should identify and highlight most connected documents', async () => { const hubGraph = TestDataBuilder.createHubDocumentGraph(); jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce(hubGraph); const reportPath = await service.generateConnectionReport(TEST_PATHS.DOCS_DIR); const reportContent = await mockFs.readFile(reportPath); expect(reportContent).toContain('## Most Connected Documents'); expect(reportContent).toContain(`${TEST_FILES.HUB} (${EXPECTED_COUNTS.HUB_CONNECTIONS} connections)`); }); }); describe('Error Handling', () => { it('should handle report generation errors gracefully', async () => { jest.spyOn(service, 'buildConnectionGraph').mockResolvedValueOnce( TestDataBuilder.createMockGraph() ); // Mock a failure in the file system by overriding writeFile const originalWriteFile = mockFs.writeFile; mockFs.writeFile = jest.fn().mockRejectedValue(new Error('Write failed')); await expect(service.generateConnectionReport(TEST_PATHS.DOCS_DIR)).rejects.toThrow('Write failed'); expect(mockLogger.error).toHaveBeenCalledWith('Failed to generate connection report:', expect.any(Error)); // Restore original method mockFs.writeFile = originalWriteFile; }); }); }); }); });

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