UNPKG

@sofianedjerbi/knowledge-tree-mcp

Version:

MCP server for hierarchical project knowledge management

787 lines (667 loc) 27.7 kB
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { updateKnowledgeHandler } from '../../src/tools/update.js'; import type { UpdateArgs, ServerContext, KnowledgeEntry } from '../../src/types/index.js'; import { fileExists, readKnowledgeEntry, writeKnowledgeEntry, ensureJsonExtension, validateEntryMove, moveEntryWithReferences } from '../../src/utils/index.js'; // Mock the utils for this specific test file vi.mock('../../src/utils/index.js', () => ({ ensureJsonExtension: vi.fn((path: string) => path.endsWith('.json') ? path : `${path}.json`), fileExists: vi.fn(), readKnowledgeEntry: vi.fn(), writeKnowledgeEntry: vi.fn(), validateRequiredFields: vi.fn(), validateEntryMove: vi.fn(), moveEntryWithReferences: vi.fn() })); describe('Update Knowledge Tool', () => { let mockContext: ServerContext; const mockKnowledgeRoot = '/test/knowledge'; beforeEach(() => { vi.clearAllMocks(); // Setup mock context mockContext = { knowledgeRoot: mockKnowledgeRoot, scanKnowledgeTree: vi.fn().mockResolvedValue([]), broadcastUpdate: vi.fn().mockResolvedValue(undefined), logUsage: vi.fn() }; // Setup default mocks vi.mocked(ensureJsonExtension).mockImplementation((path: string) => path.endsWith('.json') ? path : `${path}.json` ); }); describe('Basic update functionality', () => { it('should successfully update basic fields', async () => { const originalEntry: KnowledgeEntry = { priority: 'COMMON', problem: 'Original problem', solution: 'Original solution', title: 'Original title' }; const expectedUpdatedEntry: KnowledgeEntry = { priority: 'CRITICAL', problem: 'Updated problem', solution: 'Updated solution', title: 'Updated title', category: 'testing', updated_at: expect.any(String) }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue(originalEntry); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const args: UpdateArgs = { path: 'test.json', updates: { title: 'Updated title', priority: 'CRITICAL', problem: 'Updated problem', solution: 'Updated solution', category: 'testing' } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated test.json'); expect(result.content[0].text).toContain('Updated fields: title, priority, problem, solution, category'); // Verify the entry was written with updates expect(writeKnowledgeEntry).toHaveBeenCalledWith( '/test/knowledge/test.json', expectedUpdatedEntry ); // Verify broadcast was called expect(mockContext.broadcastUpdate).toHaveBeenCalledWith('entryUpdated', { path: 'test.json', data: expectedUpdatedEntry }); }); it('should handle paths without .json extension', async () => { const originalEntry: KnowledgeEntry = { priority: 'REQUIRED', problem: 'Test problem', solution: 'Test solution' }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue(originalEntry); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const args: UpdateArgs = { path: 'test', // No .json extension updates: { priority: 'CRITICAL' } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated test.json'); expect(ensureJsonExtension).toHaveBeenCalledWith('test'); expect(fileExists).toHaveBeenCalledWith('/test/knowledge/test.json'); }); it('should return error when entry does not exist', async () => { vi.mocked(fileExists).mockResolvedValue(false); const args: UpdateArgs = { path: 'non-existent.json', updates: { title: 'New title' } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toBe('❌ Entry not found: non-existent.json'); expect(readKnowledgeEntry).not.toHaveBeenCalled(); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); it('should handle corrupted JSON files', async () => { vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockRejectedValue(new Error('Invalid JSON')); const args: UpdateArgs = { path: 'corrupted.json', updates: { title: 'New title' } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toBe('❌ Failed to read entry: corrupted.json'); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); }); describe('Field validation', () => { beforeEach(() => { const baseEntry: KnowledgeEntry = { priority: 'COMMON', problem: 'Base problem', solution: 'Base solution' }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue(baseEntry); }); it('should validate title field', async () => { const args: UpdateArgs = { path: 'test.json', updates: { title: '' // Empty title } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Validation failed:'); expect(result.content[0].text).toContain('Title must be a non-empty string'); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); it('should validate priority field', async () => { const args: UpdateArgs = { path: 'test.json', updates: { priority: 'INVALID' as any } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Validation failed:'); expect(result.content[0].text).toContain('Invalid priority value'); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); it('should validate problem field', async () => { const args: UpdateArgs = { path: 'test.json', updates: { problem: '' // Empty problem } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Validation failed:'); expect(result.content[0].text).toContain('Problem must be a non-empty string'); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); it('should validate solution field', async () => { const args: UpdateArgs = { path: 'test.json', updates: { solution: '' // Empty solution } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Validation failed:'); expect(result.content[0].text).toContain('Solution must be a non-empty string'); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); it('should validate tags field', async () => { const args: UpdateArgs = { path: 'test.json', updates: { tags: 'not an array' as any } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Validation failed:'); expect(result.content[0].text).toContain('Tags must be an array of strings'); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); it('should accept valid priority values', async () => { vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const validPriorities = ['CRITICAL', 'REQUIRED', 'COMMON', 'EDGE-CASE']; for (const priority of validPriorities) { const args: UpdateArgs = { path: 'test.json', updates: { priority: priority as any } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); } }); it('should accept valid tags array', async () => { vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const args: UpdateArgs = { path: 'test.json', updates: { tags: ['testing', 'validation', 'arrays'] } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); expect(writeKnowledgeEntry).toHaveBeenCalledWith( '/test/knowledge/test.json', expect.objectContaining({ tags: ['testing', 'validation', 'arrays'] }) ); }); it('should accumulate multiple validation errors', async () => { const args: UpdateArgs = { path: 'test.json', updates: { title: '', // Empty title priority: 'INVALID' as any, // Invalid priority problem: '', // Empty problem tags: 'not an array' as any // Invalid tags } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Validation failed:'); expect(result.content[0].text).toContain('Title must be a non-empty string'); expect(result.content[0].text).toContain('Invalid priority value'); expect(result.content[0].text).toContain('Problem must be a non-empty string'); expect(result.content[0].text).toContain('Tags must be an array of strings'); }); }); describe('Advanced field updates', () => { beforeEach(() => { const baseEntry: KnowledgeEntry = { priority: 'COMMON', problem: 'Base problem', solution: 'Base solution' }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue(baseEntry); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); }); it('should update all optional fields', async () => { const args: UpdateArgs = { path: 'test.json', updates: { title: 'Complete title', slug: 'complete-slug', category: 'complete-category', tags: ['complete', 'test'], context: 'Complete context', examples: [ { title: 'Example 1', description: 'First example', code: 'console.log("example 1");', language: 'javascript' } ], code: 'legacy code example', author: 'Test Author', version: '1.0.0' } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); expect(writeKnowledgeEntry).toHaveBeenCalledWith( '/test/knowledge/test.json', expect.objectContaining({ title: 'Complete title', slug: 'complete-slug', category: 'complete-category', tags: ['complete', 'test'], context: 'Complete context', examples: expect.arrayContaining([ expect.objectContaining({ title: 'Example 1', language: 'javascript' }) ]), code: 'legacy code example', author: 'Test Author', version: '1.0.0', updated_at: expect.any(String) }) ); }); it('should update timestamp on every update', async () => { const beforeUpdate = new Date().toISOString(); const args: UpdateArgs = { path: 'test.json', updates: { title: 'Timestamped update' } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); const writtenEntry = vi.mocked(writeKnowledgeEntry).mock.calls[0][1]; expect(writtenEntry.updated_at).toBeDefined(); expect(new Date(writtenEntry.updated_at!).getTime()).toBeGreaterThanOrEqual(new Date(beforeUpdate).getTime()); }); it('should handle undefined updates gracefully', async () => { const args: UpdateArgs = { path: 'test.json', updates: { title: undefined, category: 'defined', tags: undefined } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); expect(result.content[0].text).toContain('Updated fields: category'); const writtenEntry = vi.mocked(writeKnowledgeEntry).mock.calls[0][1]; expect(writtenEntry.category).toBe('defined'); expect(writtenEntry.title).toBeUndefined(); expect(writtenEntry.tags).toBeUndefined(); }); }); describe('Relationship management', () => { beforeEach(() => { const baseEntry: KnowledgeEntry = { priority: 'CRITICAL', problem: 'Main entry', solution: 'Main solution' }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue(baseEntry); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); }); it('should add new relationships', async () => { // Mock that linked entries exist vi.mocked(fileExists) .mockResolvedValueOnce(true) // Main entry exists .mockResolvedValueOnce(true) // related1.json exists .mockResolvedValueOnce(true); // related2.json exists const args: UpdateArgs = { path: 'main.json', updates: { related_to: [ { path: 'related1.json', relationship: 'implements' }, { path: 'related2.json', relationship: 'supersedes', description: 'Replaces old approach' } ] } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); const writtenEntry = vi.mocked(writeKnowledgeEntry).mock.calls[0][1]; expect(writtenEntry.related_to).toHaveLength(2); expect(writtenEntry.related_to![0]).toEqual({ path: 'related1.json', relationship: 'implements' }); expect(writtenEntry.related_to![1]).toEqual({ path: 'related2.json', relationship: 'supersedes', description: 'Replaces old approach' }); }); it('should validate that linked entries exist', async () => { vi.mocked(fileExists) .mockResolvedValueOnce(true) // Main entry exists .mockResolvedValueOnce(false); // non-existent.json does not exist const args: UpdateArgs = { path: 'main.json', updates: { related_to: [ { path: 'non-existent.json', relationship: 'related' } ] } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Validation failed:'); expect(result.content[0].text).toContain('Linked entry does not exist: non-existent.json'); expect(writeKnowledgeEntry).not.toHaveBeenCalled(); }); it('should handle bidirectional relationships', async () => { const bidirectionalEntry: KnowledgeEntry = { priority: 'REQUIRED', problem: 'Bidirectional', solution: 'Bidirectional solution' }; // Mock file existence and reading vi.mocked(fileExists) .mockResolvedValueOnce(true) // main.json exists .mockResolvedValueOnce(true); // bidirectional.json exists vi.mocked(readKnowledgeEntry) .mockResolvedValueOnce({ priority: 'CRITICAL', problem: 'Main', solution: 'Main solution' }) // Main entry .mockResolvedValueOnce(bidirectionalEntry); // Bidirectional entry for reverse link const args: UpdateArgs = { path: 'main.json', updates: { related_to: [ { path: 'bidirectional.json', relationship: 'related', description: 'Bidirectional link' } ] } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); // Check that writeKnowledgeEntry was called twice - once for main, once for bidirectional expect(writeKnowledgeEntry).toHaveBeenCalledTimes(2); // First call should be for the bidirectional entry (reverse link) const [firstCallPath, firstCallEntry] = vi.mocked(writeKnowledgeEntry).mock.calls[0]; expect(firstCallPath).toBe('/test/knowledge/bidirectional.json'); expect(firstCallEntry.related_to).toContainEqual({ path: 'main.json', relationship: 'related', description: 'Bidirectional link' }); // Second call should be for the main entry const [secondCallPath, secondCallEntry] = vi.mocked(writeKnowledgeEntry).mock.calls[1]; expect(secondCallPath).toBe('/test/knowledge/main.json'); expect(secondCallEntry.related_to).toContainEqual({ path: 'bidirectional.json', relationship: 'related', description: 'Bidirectional link' }); }); it('should handle conflicts_with as bidirectional relationship', async () => { const conflictEntry: KnowledgeEntry = { priority: 'REQUIRED', problem: 'Conflicting approach', solution: 'Alternative solution' }; vi.mocked(fileExists) .mockResolvedValueOnce(true) // main.json exists .mockResolvedValueOnce(true); // conflict.json exists vi.mocked(readKnowledgeEntry) .mockResolvedValueOnce({ priority: 'CRITICAL', problem: 'Main', solution: 'Main solution' }) .mockResolvedValueOnce(conflictEntry); const args: UpdateArgs = { path: 'main.json', updates: { related_to: [ { path: 'conflict.json', relationship: 'conflicts_with', description: 'Conflicting approach' } ] } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); // Both entries should be updated with conflicts_with relationship expect(writeKnowledgeEntry).toHaveBeenCalledTimes(2); const calls = vi.mocked(writeKnowledgeEntry).mock.calls; const conflictCall = calls.find(call => call[0].includes('conflict.json')); const mainCall = calls.find(call => call[0].includes('main.json')); expect(conflictCall![1].related_to![0].relationship).toBe('conflicts_with'); expect(mainCall![1].related_to![0].relationship).toBe('conflicts_with'); }); it('should handle non-bidirectional relationships without creating reverse links', async () => { vi.mocked(fileExists) .mockResolvedValueOnce(true) // main.json exists .mockResolvedValueOnce(true) // related1.json exists .mockResolvedValueOnce(true); // related2.json exists const args: UpdateArgs = { path: 'main.json', updates: { related_to: [ { path: 'related1.json', relationship: 'implements' }, { path: 'related2.json', relationship: 'supersedes' } ] } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); // Only the main entry should be updated (no reverse links for non-bidirectional relationships) expect(writeKnowledgeEntry).toHaveBeenCalledTimes(1); expect(writeKnowledgeEntry).toHaveBeenCalledWith( '/test/knowledge/main.json', expect.objectContaining({ related_to: expect.arrayContaining([ { path: 'related1.json', relationship: 'implements' }, { path: 'related2.json', relationship: 'supersedes' } ]) }) ); }); }); describe('Broadcasting and integration', () => { beforeEach(() => { const baseEntry: KnowledgeEntry = { priority: 'COMMON', problem: 'Base problem', solution: 'Base solution' }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue(baseEntry); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); }); it('should broadcast update events', async () => { const args: UpdateArgs = { path: 'broadcast.json', updates: { title: 'Broadcast test' } }; const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated'); expect(mockContext.broadcastUpdate).toHaveBeenCalledTimes(1); expect(mockContext.broadcastUpdate).toHaveBeenCalledWith('entryUpdated', { path: 'broadcast.json', data: expect.objectContaining({ title: 'Broadcast test', updated_at: expect.any(String) }) }); }); }); describe('explicit path update', () => { it('should move entry to explicit new_path', async () => { const args: UpdateArgs = { path: 'old-path.json', new_path: 'category/subcategory/new-path', updates: { title: 'Updated title' } }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue({ title: 'Original title', priority: 'REQUIRED', problem: 'Test problem', solution: 'Test solution' }); vi.mocked(validateEntryMove).mockResolvedValue({ valid: true, warnings: [] }); vi.mocked(moveEntryWithReferences).mockResolvedValue({ success: true, updatedEntries: [] }); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated and moved to explicit path'); expect(result.content[0].text).toContain('Old path: old-path.json'); expect(result.content[0].text).toContain('New path: category/subcategory/new-path.json'); expect(validateEntryMove).toHaveBeenCalledWith('old-path.json', 'category/subcategory/new-path.json', mockContext); expect(moveEntryWithReferences).toHaveBeenCalledWith('old-path.json', 'category/subcategory/new-path.json', mockContext); }); it('should normalize new_path with .json extension', async () => { const args: UpdateArgs = { path: 'old-entry', new_path: 'new-location/entry', updates: { priority: 'CRITICAL' } }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue({ title: 'Test entry', priority: 'COMMON', problem: 'Test problem', solution: 'Test solution' }); vi.mocked(validateEntryMove).mockResolvedValue({ valid: true, warnings: [] }); vi.mocked(moveEntryWithReferences).mockResolvedValue({ success: true, updatedEntries: [] }); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated and moved to explicit path'); expect(result.content[0].text).toContain('Old path: old-entry.json'); expect(result.content[0].text).toContain('New path: new-location/entry.json'); }); it('should fail when new_path already exists', async () => { const args: UpdateArgs = { path: 'source.json', new_path: 'existing-target.json', updates: { title: 'Updated' } }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue({ title: 'Source entry', priority: 'COMMON', problem: 'Test problem', solution: 'Test solution' }); vi.mocked(validateEntryMove).mockResolvedValue({ valid: false, warnings: ['Target path already exists'] }); const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Cannot move entry to existing-target.json'); expect(result.content[0].text).toContain('Target path already exists'); }); it('should handle move failure gracefully', async () => { const args: UpdateArgs = { path: 'source.json', new_path: 'target.json', updates: { title: 'Updated' } }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue({ title: 'Source entry', priority: 'COMMON', problem: 'Test problem', solution: 'Test solution' }); vi.mocked(validateEntryMove).mockResolvedValue({ valid: true, warnings: [] }); vi.mocked(moveEntryWithReferences).mockResolvedValue({ success: false, error: 'Permission denied' }); const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('❌ Failed to move entry: Permission denied'); }); it('should not move when new_path equals current path', async () => { const args: UpdateArgs = { path: 'same-path.json', new_path: 'same-path', updates: { title: 'Updated title' } }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue({ title: 'Original title', priority: 'REQUIRED', problem: 'Test problem', solution: 'Test solution' }); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('✅ Successfully updated same-path.json'); expect(result.content[0].text).not.toContain('moved'); expect(validateEntryMove).not.toHaveBeenCalled(); expect(moveEntryWithReferences).not.toHaveBeenCalled(); }); it('should prioritize new_path over regenerate_path', async () => { const args: UpdateArgs = { path: 'old.json', new_path: 'explicit-path', regenerate_path: true, updates: { title: 'This Should Not Generate Path' } }; vi.mocked(fileExists).mockResolvedValue(true); vi.mocked(readKnowledgeEntry).mockResolvedValue({ title: 'Old title', priority: 'REQUIRED', problem: 'Test problem', solution: 'Test solution' }); vi.mocked(validateEntryMove).mockResolvedValue({ valid: true, warnings: [] }); vi.mocked(moveEntryWithReferences).mockResolvedValue({ success: true, updatedEntries: [] }); vi.mocked(writeKnowledgeEntry).mockResolvedValue(undefined); const result = await updateKnowledgeHandler(args, mockContext); expect(result.content[0].text).toContain('moved to explicit path'); expect(result.content[0].text).toContain('New path: explicit-path.json'); // Should not call generatePathFromTitle expect(result.content[0].text).not.toContain('regenerated'); }); }); });