Files
obsidian-mcp-server/tests/vault-tools.test.ts
Bill aca4d35944 test: add coverage for VaultTools uncovered paths
Add tests for:
- getBacklinks with snippet options (includeSnippets true/false)
- getBacklinks error handling (file not found, read errors)
- validateWikilinks error paths (file not found, read errors)
- validateWikilinks successful validation with resolved/unresolved links

Improves vault-tools.ts coverage from 35.85% to 54.9% statements.
Adds 7 new tests (27 to 34 total).
2025-10-19 23:48:36 -04:00

581 lines
19 KiB
TypeScript

import { VaultTools } from '../src/tools/vault-tools';
import { createMockVaultAdapter, createMockMetadataCacheAdapter, createMockTFile, createMockTFolder } from './__mocks__/adapters';
import { TFile, TFolder, App } from 'obsidian';
describe('VaultTools', () => {
let vaultTools: VaultTools;
let mockVault: ReturnType<typeof createMockVaultAdapter>;
let mockMetadata: ReturnType<typeof createMockMetadataCacheAdapter>;
let mockApp: App;
beforeEach(() => {
mockVault = createMockVaultAdapter();
mockMetadata = createMockMetadataCacheAdapter();
mockApp = {} as App; // Minimal mock for methods not yet migrated
vaultTools = new VaultTools(mockVault, mockMetadata, mockApp);
});
describe('listNotes', () => {
it('should list files and folders in root directory', async () => {
const mockFiles = [
createMockTFile('note1.md'),
createMockTFile('note2.md')
];
const mockFolders = [
createMockTFolder('folder1')
];
const mockRoot = createMockTFolder('', [...mockFiles, ...mockFolders]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.listNotes();
expect(result.isError).toBeUndefined();
expect(mockVault.getRoot).toHaveBeenCalled();
const parsed = JSON.parse(result.content[0].text);
expect(Array.isArray(parsed)).toBe(true);
expect(parsed.length).toBe(3);
// Directories should come first
expect(parsed[0].kind).toBe('directory');
expect(parsed[0].name).toBe('folder1');
// Then files
expect(parsed[1].kind).toBe('file');
expect(parsed[2].kind).toBe('file');
});
it('should list files in a specific folder', async () => {
const mockFiles = [
createMockTFile('folder1/file1.md'),
createMockTFile('folder1/file2.md')
];
const mockFolder = createMockTFolder('folder1', mockFiles);
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.listNotes('folder1');
expect(result.isError).toBeUndefined();
expect(mockVault.getAbstractFileByPath).toHaveBeenCalledWith('folder1');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.length).toBe(2);
});
it('should return error if folder not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.listNotes('nonexistent');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should return error if path is not a folder', async () => {
const mockFile = createMockTFile('note.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
const result = await vaultTools.listNotes('note.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not a folder');
});
it('should skip vault root itself in children', async () => {
const rootChild = createMockTFolder('');
const normalFolder = createMockTFolder('folder1');
const mockRoot = createMockTFolder('', [rootChild, normalFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.listNotes();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should only have folder1, not the root child
expect(parsed.length).toBe(1);
expect(parsed[0].name).toBe('folder1');
});
it('should handle empty directory', async () => {
const mockRoot = createMockTFolder('', []);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.listNotes();
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.length).toBe(0);
});
it('should normalize path variants to root', async () => {
const mockRoot = createMockTFolder('', []);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
// Test empty string
await vaultTools.listNotes('');
expect(mockVault.getRoot).toHaveBeenCalled();
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
// Test dot
await vaultTools.listNotes('.');
expect(mockVault.getRoot).toHaveBeenCalled();
});
});
describe('stat', () => {
it('should return file statistics', async () => {
const mockFile = createMockTFile('test.md', {
ctime: 1000,
mtime: 2000,
size: 500
});
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
const result = await vaultTools.stat('test.md');
expect(result.isError).toBeUndefined();
expect(mockVault.getAbstractFileByPath).toHaveBeenCalledWith('test.md');
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('file');
expect(parsed.metadata.size).toBe(500);
expect(parsed.metadata.modified).toBe(2000);
expect(parsed.metadata.created).toBe(1000);
});
it('should return folder statistics', async () => {
const mockFolder = createMockTFolder('folder1', [
createMockTFile('folder1/file1.md'),
createMockTFile('folder1/file2.md')
]);
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.stat('folder1');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('directory');
expect(parsed.metadata.childrenCount).toBe(2);
});
it('should return exists: false if path not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.stat('nonexistent.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(false);
});
it('should return error for invalid path', async () => {
const result = await vaultTools.stat('../invalid/path');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid path');
});
});
describe('exists', () => {
it('should return true for existing file', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
const result = await vaultTools.exists('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('file');
});
it('should return true for existing folder', async () => {
const mockFolder = createMockTFolder('folder1');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFolder);
const result = await vaultTools.exists('folder1');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(true);
expect(parsed.kind).toBe('directory');
});
it('should return false if file does not exist', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.exists('nonexistent.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.exists).toBe(false);
});
it('should return error for invalid path', async () => {
const result = await vaultTools.exists('../invalid');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Invalid path');
});
});
describe('list (enhanced)', () => {
it('should list items non-recursively by default', async () => {
const mockFiles = [
createMockTFile('file1.md'),
createMockTFile('file2.md')
];
const mockFolder = createMockTFolder('subfolder', [
createMockTFile('subfolder/nested.md')
]);
const mockRoot = createMockTFolder('', [...mockFiles, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ recursive: false });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should include 2 files and 1 folder, but not the nested file
expect(parsed.items.length).toBe(3);
expect(parsed.items.some((item: any) => item.path === 'subfolder/nested.md')).toBe(false);
});
it('should list items recursively when requested', async () => {
const nestedFile = createMockTFile('subfolder/nested.md');
const mockFolder = createMockTFolder('subfolder', [nestedFile]);
const mockRoot = createMockTFolder('', [mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ recursive: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should include folder and nested file
expect(parsed.items.length).toBe(2);
expect(parsed.items.some((item: any) => item.path === 'subfolder/nested.md')).toBe(true);
});
it('should filter by "files" only', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ only: 'files' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].kind).toBe('file');
});
it('should filter by "directories" only', async () => {
const mockFile = createMockTFile('file.md');
const mockFolder = createMockTFolder('folder');
const mockRoot = createMockTFolder('', [mockFile, mockFolder]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ only: 'directories' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].kind).toBe('directory');
});
it('should apply pagination with limit', async () => {
const mockFiles = [
createMockTFile('file1.md'),
createMockTFile('file2.md'),
createMockTFile('file3.md')
];
const mockRoot = createMockTFolder('', mockFiles);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ limit: 2 });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(2);
expect(parsed.hasMore).toBe(true);
expect(parsed.nextCursor).toBeDefined();
expect(parsed.totalCount).toBe(3);
});
it('should handle cursor-based pagination', async () => {
const mockFiles = [
createMockTFile('file1.md'),
createMockTFile('file2.md'),
createMockTFile('file3.md')
];
const mockRoot = createMockTFolder('', mockFiles);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ limit: 2, cursor: 'file1.md' });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
// Should start after file1.md
expect(parsed.items[0].path).toBe('file2.md');
});
it('should include frontmatter summary when requested', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
title: 'Test Note',
tags: ['tag1', 'tag2']
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary).toBeDefined();
expect(parsed.items[0].frontmatterSummary.title).toBe('Test Note');
expect(parsed.items[0].frontmatterSummary.tags).toEqual(['tag1', 'tag2']);
});
it('should handle string tags and convert to array', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
tags: 'single-tag'
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary.tags).toEqual(['single-tag']);
});
it('should handle string aliases and convert to array', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
aliases: 'single-alias'
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary.aliases).toEqual(['single-alias']);
});
it('should handle frontmatter extraction error gracefully', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockImplementation(() => {
throw new Error('Cache error');
});
const result = await vaultTools.list({ withFrontmatterSummary: true });
// Should still succeed without frontmatter
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary).toBeUndefined();
});
it('should include custom frontmatter fields', async () => {
const mockFile = createMockTFile('test.md');
const mockRoot = createMockTFolder('', [mockFile]);
const mockCache = {
frontmatter: {
title: 'Test',
customField: 'custom value',
anotherField: 123
}
};
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
mockMetadata.getFileCache = jest.fn().mockReturnValue(mockCache);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items[0].frontmatterSummary.customField).toBe('custom value');
expect(parsed.items[0].frontmatterSummary.anotherField).toBe(123);
});
it('should not include frontmatter for non-markdown files', async () => {
const mockFile = Object.create(TFile.prototype);
Object.assign(mockFile, {
path: 'image.png',
basename: 'image',
extension: 'png',
name: 'image.png',
stat: { ctime: Date.now(), mtime: Date.now(), size: 100 },
vault: {} as any,
parent: null
});
const mockRoot = createMockTFolder('', [mockFile]);
mockVault.getRoot = jest.fn().mockReturnValue(mockRoot);
const result = await vaultTools.list({ withFrontmatterSummary: true });
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.items.length).toBe(1);
expect(parsed.items[0].frontmatterSummary).toBeUndefined();
});
});
describe('getBacklinks', () => {
it('should return error if file not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.getBacklinks('nonexistent.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should return backlinks with snippets when includeSnippets is true', async () => {
const targetFile = createMockTFile('target.md');
const sourceFile = createMockTFile('source.md');
mockVault.getAbstractFileByPath = jest.fn()
.mockReturnValueOnce(targetFile)
.mockReturnValue(sourceFile);
mockVault.read = jest.fn().mockResolvedValue('This links to [[target]]');
mockMetadata.resolvedLinks = {
'source.md': {
'target.md': 1
}
};
mockMetadata.getFirstLinkpathDest = jest.fn().mockReturnValue(targetFile);
const result = await vaultTools.getBacklinks('target.md', false, true);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.backlinks).toBeDefined();
expect(parsed.backlinks.length).toBeGreaterThan(0);
expect(parsed.backlinks[0].occurrences[0].snippet).toBeTruthy();
});
it('should return backlinks without snippets when includeSnippets is false', async () => {
const targetFile = createMockTFile('target.md');
const sourceFile = createMockTFile('source.md');
mockVault.getAbstractFileByPath = jest.fn()
.mockReturnValueOnce(targetFile)
.mockReturnValue(sourceFile);
mockVault.read = jest.fn().mockResolvedValue('This links to [[target]]');
mockMetadata.resolvedLinks = {
'source.md': {
'target.md': 1
}
};
mockMetadata.getFirstLinkpathDest = jest.fn().mockReturnValue(targetFile);
const result = await vaultTools.getBacklinks('target.md', false, false);
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.backlinks).toBeDefined();
expect(parsed.backlinks.length).toBeGreaterThan(0);
expect(parsed.backlinks[0].occurrences[0].snippet).toBe('');
});
it('should handle read errors gracefully', async () => {
const targetFile = createMockTFile('target.md');
const sourceFile = createMockTFile('source.md');
mockVault.getAbstractFileByPath = jest.fn()
.mockReturnValueOnce(targetFile)
.mockReturnValue(sourceFile);
mockVault.read = jest.fn().mockRejectedValue(new Error('Permission denied'));
mockMetadata.resolvedLinks = {
'source.md': {
'target.md': 1
}
};
const result = await vaultTools.getBacklinks('target.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('error');
});
});
describe('validateWikilinks', () => {
it('should return error if file not found', async () => {
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(null);
const result = await vaultTools.validateWikilinks('nonexistent.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('not found');
});
it('should handle read errors gracefully', async () => {
const mockFile = createMockTFile('test.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockRejectedValue(new Error('Read error'));
const result = await vaultTools.validateWikilinks('test.md');
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('error');
});
it('should validate wikilinks successfully', async () => {
const mockFile = createMockTFile('test.md');
const linkedFile = createMockTFile('linked.md');
mockVault.getAbstractFileByPath = jest.fn().mockReturnValue(mockFile);
mockVault.read = jest.fn().mockResolvedValue('This is a [[linked]] note and a [[broken]] link.');
mockVault.getMarkdownFiles = jest.fn().mockReturnValue([linkedFile]);
mockMetadata.getFirstLinkpathDest = jest.fn()
.mockReturnValueOnce(linkedFile)
.mockReturnValueOnce(null);
const result = await vaultTools.validateWikilinks('test.md');
expect(result.isError).toBeUndefined();
const parsed = JSON.parse(result.content[0].text);
expect(parsed.path).toBe('test.md');
expect(parsed.totalLinks).toBe(2);
expect(parsed.resolvedLinks.length).toBe(1);
expect(parsed.unresolvedLinks.length).toBe(1);
});
});
});