test: update vault-tools tests to use mock adapters

Replace complex App object mocks with clean mock adapter pattern.
Simplifies test setup and improves maintainability.

- Created comprehensive tests for VaultTools methods using mock adapters
- Tests cover listNotes, stat, exists, and list (enhanced) functionality
- Includes tests for frontmatter extraction edge cases
- Fixed mock helpers to use proper prototype chains for instanceof checks
- All 27 VaultTools tests passing
This commit is contained in:
2025-10-19 23:34:28 -04:00
parent 25755661f7
commit 862c5533e8
2 changed files with 467 additions and 6 deletions

View File

@@ -44,10 +44,11 @@ export function createMockFileManagerAdapter(overrides?: Partial<IFileManagerAda
}
/**
* Helper to create a mock TFile
* Helper to create a mock TFile with proper prototype chain
*/
export function createMockTFile(path: string, stat?: { ctime: number; mtime: number; size: number }): TFile {
return {
const file = Object.create(TFile.prototype);
Object.assign(file, {
path,
basename: path.split('/').pop()?.replace('.md', '') || '',
extension: 'md',
@@ -55,20 +56,22 @@ export function createMockTFile(path: string, stat?: { ctime: number; mtime: num
stat: stat || { ctime: Date.now(), mtime: Date.now(), size: 100 },
vault: {} as any,
parent: null
} as TFile;
});
return file;
}
/**
* Helper to create a mock TFolder
* Helper to create a mock TFolder with proper prototype chain
*/
export function createMockTFolder(path: string, children?: TAbstractFile[]): TFolder {
const folder = {
const folder = Object.create(TFolder.prototype);
Object.assign(folder, {
path,
name: path.split('/').pop() || '',
children: children || [],
vault: {} as any,
parent: null,
isRoot: function() { return path === '' || path === '/'; }
} as TFolder;
});
return folder;
}

458
tests/vault-tools.test.ts Normal file
View File

@@ -0,0 +1,458 @@
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();
});
});
});