andy-stack_vaultkeeper-ai/__tests__/Services/VaultService.test.ts
Andrew Beal 167a2b13a5 refactor: standardize error handling with Exception helper and improve return types
Add Exception helper class for consistent error handling and logging. Replace throw statements and console.error calls with Exception methods. Update service methods to return Error | T instead of mixed success/failure objects. Improve type safety in Claude.extractContents with explicit return type.

Add WikiLinks helper to VaultCacheService for managing wiki link references.

Update unit tests.
2025-11-17 19:02:15 +00:00

1351 lines
49 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VaultService } from '../../Services/VaultService';
import { TFile, TFolder, TAbstractFile, FileManager } from 'obsidian';
import { Path } from '../../Enums/Path';
import { RegisterSingleton, DeregisterAllServices } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { SanitiserService } from '../../Services/SanitiserService';
import { SettingsService, IVaultkeeperAISettings } from '../../Services/SettingsService';
import { AIProviderModel } from '../../Enums/ApiProvider';
import { Exception } from '../../Helpers/Exception';
/**
* INTEGRATION TESTS
*
* These tests use real dependencies (SanitiserService, DependencyService) and only mock
* the Obsidian API (Vault, FileManager, TFile, etc.) which is unavoidable in a test environment.
*
* This approach tests the actual integration between services and avoids complex mocking.
*/
// Create mock instances
const mockVault = {
getMarkdownFiles: vi.fn(),
getAbstractFileByPath: vi.fn(),
read: vi.fn(),
cachedRead: vi.fn(),
create: vi.fn(),
process: vi.fn(),
delete: vi.fn(),
trash: vi.fn(),
createFolder: vi.fn(),
getFiles: vi.fn(),
getAllFolders: vi.fn(),
on: vi.fn(),
adapter: {
exists: vi.fn()
}
};
const mockFileManager = {
renameFile: vi.fn(),
trashFile: vi.fn()
} as unknown as FileManager & {
renameFile: ReturnType<typeof vi.fn>;
trashFile: ReturnType<typeof vi.fn>;
};
// Create a mutable settings object that tests can modify
const mockSettings: IVaultkeeperAISettings = {
firstTimeStart: false,
model: AIProviderModel.ClaudeSonnet_4_5,
apiKeys: {
claude: 'test-claude-key',
openai: 'test-openai-key',
gemini: 'test-gemini-key'
},
exclusions: [],
userInstruction: '',
searchResultsLimit: 15,
snippetSizeLimit: 300
};
const mockPlugin = {
app: {
vault: mockVault,
fileManager: mockFileManager
},
saveData: vi.fn().mockResolvedValue(undefined),
registerEvent: vi.fn()
};
// Helper to create mock TFile
function createMockFile(path: string): TFile {
const name = path.split('/').pop() || '';
const basename = name.split('.')[0];
const file = new TFile();
file.path = path;
file.name = name;
file.basename = basename;
file.extension = 'md';
file.stat = { ctime: Date.now(), mtime: Date.now(), size: 100 };
file.parent = null;
file.vault = mockVault as any;
return file;
}
// Helper to create mock TFolder
function createMockFolder(path: string, children: TAbstractFile[] = []): TFolder {
const name = path.split('/').pop() || '';
const folder = new TFolder();
folder.path = path;
folder.name = name;
folder.children = children;
folder.parent = null;
folder.vault = mockVault as any;
return folder;
}
describe('VaultService - Integration Tests', () => {
let vaultService: VaultService;
let settingsService: SettingsService;
let consoleErrorSpy: any;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Reset settings to defaults
mockSettings.exclusions = [];
mockSettings.searchResultsLimit = 15;
mockSettings.snippetSizeLimit = 300;
// Set default mock for adapter.exists (can be overridden in individual tests)
mockVault.adapter.exists.mockResolvedValue(false);
// Mock console.error to prevent noise in tests
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Mock Exception.log
vi.spyOn(Exception, 'log').mockImplementation(() => {});
// Register real dependencies in DependencyService
RegisterSingleton(Services.VaultkeeperAIPlugin, mockPlugin as any);
RegisterSingleton(Services.FileManager, mockFileManager);
RegisterSingleton(Services.SanitiserService, new SanitiserService());
// Create and register SettingsService
settingsService = new SettingsService(mockSettings);
RegisterSingleton(Services.SettingsService, settingsService);
// Create a fresh instance - it will resolve real dependencies
vaultService = new VaultService();
});
afterEach(() => {
// Clear singleton registry to prevent memory leaks
DeregisterAllServices();
vi.restoreAllMocks();
});
describe('getMarkdownFiles', () => {
it('should return all markdown files when no exclusions are set', () => {
const files = [
createMockFile('note1.md'),
createMockFile('note2.md'),
createMockFile('folder/note3.md')
];
mockVault.getMarkdownFiles.mockReturnValue(files);
const result = vaultService.getMarkdownFiles();
expect(result).toHaveLength(3);
expect(mockVault.getMarkdownFiles).toHaveBeenCalledOnce();
});
it('should filter out files in the Vaultkeeper AI root directory by default', () => {
const files = [
createMockFile('note1.md'),
createMockFile('Vaultkeeper AI/conversation.md'),
createMockFile('Vaultkeeper AI/subfolder/data.md')
];
mockVault.getMarkdownFiles.mockReturnValue(files);
const result = vaultService.getMarkdownFiles(false);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('note1.md');
});
it('should allow access to Vaultkeeper AI directory when allowAccessToPluginRoot is true', () => {
const files = [
createMockFile('note1.md'),
createMockFile('Vaultkeeper AI/conversation.md')
];
mockVault.getMarkdownFiles.mockReturnValue(files);
const result = vaultService.getMarkdownFiles(true);
expect(result).toHaveLength(2);
});
it('should filter out user-defined exclusions', () => {
const files = [
createMockFile('public/note1.md'),
createMockFile('private/secret.md'),
createMockFile('public/note2.md')
];
mockVault.getMarkdownFiles.mockReturnValue(files);
// Update settings to include exclusion
settingsService.settings.exclusions = ['private/**'];
const result = vaultService.getMarkdownFiles();
expect(result).toHaveLength(2);
expect(result.every(f => !f.path.startsWith('private/'))).toBe(true);
});
});
describe('getAbstractFileByPath', () => {
it('should return file when path is not excluded', () => {
const mockFile = createMockFile('note.md');
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
const result = vaultService.getAbstractFileByPath('note.md');
expect(result).toBe(mockFile);
});
it('should return null when path is excluded', () => {
mockVault.getAbstractFileByPath.mockReturnValue(createMockFile('Vaultkeeper AI/test.md'));
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI/test.md', false);
expect(result).toBeNull();
});
it('should sanitize the path before checking', () => {
// Path with illegal characters that will be sanitized
const unsanitizedPath = 'folder/file?.md';
const mockFile = createMockFile('folder/file.md');
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
vaultService.getAbstractFileByPath(unsanitizedPath);
// SanitiserService will remove the ? character
expect(mockVault.getAbstractFileByPath).toHaveBeenCalledWith('folder/file.md');
});
it('should allow access to excluded paths when allowAccessToPluginRoot is true', () => {
const mockFile = createMockFile('Vaultkeeper AI/conversation.md');
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI/conversation.md', true);
expect(result).toBe(mockFile);
});
it('should exclude the Vaultkeeper AI directory itself', () => {
const mockFolder = createMockFolder('Vaultkeeper AI');
mockVault.getAbstractFileByPath.mockReturnValue(mockFolder);
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI', false);
expect(result).toBeNull();
});
it('should allow access to Vaultkeeper AI directory when allowAccessToPluginRoot is true', () => {
const mockFolder = createMockFolder('Vaultkeeper AI');
mockVault.getAbstractFileByPath.mockReturnValue(mockFolder);
const result = vaultService.getAbstractFileByPath('Vaultkeeper AI', true);
expect(result).toBe(mockFolder);
});
});
describe('exists', () => {
it('should return true when file exists and is not excluded', async () => {
mockVault.adapter.exists.mockResolvedValue(true);
const result = await vaultService.exists('note.md');
expect(result).toBe(true);
});
it('should return false when file is excluded', async () => {
const result = await vaultService.exists('Vaultkeeper AI/test.md', false);
expect(result).toBe(false);
});
it('should return false when file does not exist', async () => {
mockVault.adapter.exists.mockResolvedValue(false);
const result = await vaultService.exists('nonexistent.md');
expect(result).toBe(false);
});
it('should return true when folder exists', async () => {
mockVault.adapter.exists.mockResolvedValue(true);
const result = await vaultService.exists('folder');
expect(result).toBe(true);
});
});
describe('read', () => {
it('should read file content when file is not excluded', async () => {
const mockFile = createMockFile('note.md');
mockVault.read.mockResolvedValue('file content');
const result = await vaultService.read(mockFile);
expect(result).toBe('file content');
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
});
it('should return empty string when file is excluded', async () => {
const mockFile = createMockFile('Vaultkeeper AI/test.md');
const result = await vaultService.read(mockFile, false);
expect(result).toBe('');
expect(mockVault.read).not.toHaveBeenCalled();
});
it('should allow reading excluded files when allowAccessToPluginRoot is true', async () => {
const mockFile = createMockFile('Vaultkeeper AI/test.md');
mockVault.read.mockResolvedValue('content');
const result = await vaultService.read(mockFile, true);
expect(result).toBe('content');
expect(mockVault.read).toHaveBeenCalledWith(mockFile);
});
});
describe('create', () => {
it('should create a file with sanitized path', async () => {
const mockFile = createMockFile('note.md');
mockVault.getAbstractFileByPath.mockReturnValue(null); // No existing directories
mockVault.create.mockResolvedValue(mockFile);
mockVault.createFolder.mockResolvedValue(createMockFolder('folder'));
const result = await vaultService.create('note.md', 'content');
expect(mockVault.create).toHaveBeenCalledWith('note.md', 'content');
expect(result).toBe(mockFile);
});
it('should return error when trying to create file in excluded path', async () => {
const result = await vaultService.create('Vaultkeeper AI/test.md', 'content', false);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('Failed to create file, permission denied');
});
it('should create parent directories if they do not exist', async () => {
const mockFile = createMockFile('folder/subfolder/note.md');
mockVault.getAbstractFileByPath.mockReturnValue(null);
mockVault.create.mockResolvedValue(mockFile);
mockVault.createFolder.mockResolvedValue(createMockFolder('folder'));
await vaultService.create('folder/subfolder/note.md', 'content');
expect(mockVault.createFolder).toHaveBeenCalledTimes(2);
expect(mockVault.createFolder).toHaveBeenCalledWith('folder');
expect(mockVault.createFolder).toHaveBeenCalledWith('folder/subfolder');
});
it('should not create directories that already exist', async () => {
const mockFile = createMockFile('existing/note.md');
mockVault.adapter.exists.mockImplementation(async (path: string) => {
return path === 'existing';
});
mockVault.create.mockResolvedValue(mockFile);
await vaultService.create('existing/note.md', 'content');
// Should not call createFolder for 'existing' since it already exists
expect(mockVault.createFolder).not.toHaveBeenCalled();
});
});
describe('modify', () => {
it('should modify file content when file is not excluded', async () => {
const mockFile = createMockFile('note.md');
mockVault.process.mockResolvedValue(undefined);
await vaultService.modify(mockFile, 'new content');
expect(mockVault.process).toHaveBeenCalledWith(mockFile, expect.any(Function));
});
it('should not modify file when file is excluded', async () => {
const mockFile = createMockFile('Vaultkeeper AI/test.md');
await vaultService.modify(mockFile, 'new content', false);
expect(mockVault.process).not.toHaveBeenCalled();
});
it('should call vault.process with function that returns new content', async () => {
const mockFile = createMockFile('note.md');
let processCallback: any;
mockVault.process.mockImplementation((file, fn) => {
processCallback = fn;
return Promise.resolve();
});
await vaultService.modify(mockFile, 'new content');
expect(processCallback()).toBe('new content');
});
});
describe('delete', () => {
it('should delete file successfully when not excluded', async () => {
const mockFile = createMockFile('note.md');
mockFileManager.trashFile.mockResolvedValue(undefined);
const result = await vaultService.delete(mockFile);
expect(result).toBeUndefined(); // void = success
expect(mockFileManager.trashFile).toHaveBeenCalledWith(mockFile);
});
it('should not delete file and return error when excluded', async () => {
const mockFile = createMockFile('Vaultkeeper AI/test.md');
const result = await vaultService.delete(mockFile, false);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('File does not exist');
expect(mockFileManager.trashFile).not.toHaveBeenCalled();
});
it('should call fileManager.trashFile to delete file', async () => {
const mockFile = createMockFile('note.md');
mockFileManager.trashFile.mockResolvedValue(undefined);
await vaultService.delete(mockFile, true);
expect(mockFileManager.trashFile).toHaveBeenCalledWith(mockFile);
});
it('should return error when deletion fails', async () => {
const mockFile = createMockFile('note.md');
mockFileManager.trashFile.mockRejectedValue(new Error('Deletion failed'));
const result = await vaultService.delete(mockFile);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('Deletion failed');
});
it('should handle non-Error objects in catch block', async () => {
const mockFile = createMockFile('note.md');
mockFileManager.trashFile.mockRejectedValue('string error');
const result = await vaultService.delete(mockFile);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('string error');
});
});
describe('move', () => {
it('should move file successfully when source is not excluded', async () => {
const mockFile = createMockFile('source.md');
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
mockFileManager.renameFile.mockResolvedValue(undefined);
const result = await vaultService.move('source.md', 'dest.md');
expect(result).toBeUndefined(); // void = success
expect(mockFileManager.renameFile).toHaveBeenCalledWith(mockFile, 'dest.md');
});
it('should return error when source file is excluded', async () => {
const result = await vaultService.move('Vaultkeeper AI/test.md', 'dest.md', false);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('File does not exist');
expect(mockFileManager.renameFile).not.toHaveBeenCalled();
});
it('should return error when source file does not exist', async () => {
mockVault.getAbstractFileByPath.mockReturnValue(null);
const result = await vaultService.move('nonexistent.md', 'dest.md');
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('File does not exist');
expect(mockFileManager.renameFile).not.toHaveBeenCalled();
});
it('should create destination directories if needed', async () => {
const mockFile = createMockFile('source.md');
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === 'source.md') return mockFile;
return null;
});
mockVault.createFolder.mockResolvedValue(createMockFolder('folder'));
mockFileManager.renameFile.mockResolvedValue(undefined);
await vaultService.move('source.md', 'folder/dest.md');
expect(mockVault.createFolder).toHaveBeenCalledWith('folder');
});
it('should return error when move operation fails', async () => {
const mockFile = createMockFile('source.md');
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
mockFileManager.renameFile.mockRejectedValue(new Error('Move failed'));
const result = await vaultService.move('source.md', 'dest.md');
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toBe('Move failed');
});
});
describe('createFolder', () => {
it('should create folder with sanitized path', async () => {
const mockFolder = createMockFolder('folder');
mockVault.createFolder.mockResolvedValue(mockFolder);
const result = await vaultService.createFolder('folder');
expect(mockVault.createFolder).toHaveBeenCalledWith('folder');
expect(result).toBe(mockFolder);
});
it('should return error when trying to create folder in excluded path', async () => {
const result = await vaultService.createFolder('Vaultkeeper AI/subfolder', false);
expect(result).toBeInstanceOf(Error);
expect((result as Error).message).toContain('Failed to create folder, permission denied');
});
});
describe('listFilesInDirectory', () => {
it('should list all files in directory non-recursively', async () => {
const file1 = createMockFile('folder/file1.md');
const file2 = createMockFile('folder/file2.md');
const folder = createMockFolder('folder', [file1, file2]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
const result = await vaultService.listFilesInDirectory('folder', false);
expect(result).toHaveLength(2);
expect(result).toContain(file1);
expect(result).toContain(file2);
});
it('should list all files recursively', async () => {
const file1 = createMockFile('folder/file1.md');
const file2 = createMockFile('folder/sub/file2.md');
const subfolder = createMockFolder('folder/sub', [file2]);
const folder = createMockFolder('folder', [file1, subfolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === 'folder') return folder;
if (path === 'folder/sub') return subfolder;
return null;
});
const result = await vaultService.listFilesInDirectory('folder', true);
expect(result).toHaveLength(2);
expect(result).toContain(file1);
expect(result).toContain(file2);
});
it('should filter out excluded files from results', async () => {
const file1 = createMockFile('folder/public.md');
const file2 = createMockFile('folder/private.md');
const folder = createMockFolder('folder', [file1, file2]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
settingsService.settings.exclusions = ['**/private.md'];
const result = await vaultService.listFilesInDirectory('folder', false);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('folder/public.md');
});
it('should return empty array when directory does not exist', async () => {
mockVault.getAbstractFileByPath.mockReturnValue(null);
const result = await vaultService.listFilesInDirectory('nonexistent');
expect(result).toEqual([]);
});
it('should return empty array when path is a file, not a directory', async () => {
const mockFile = createMockFile('file.md');
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
const result = await vaultService.listFilesInDirectory('file.md');
expect(result).toEqual([]);
});
it('should respect allowAccessToPluginRoot parameter when accessing directory', async () => {
const file1 = createMockFile('Vaultkeeper AI/test.md');
const file2 = createMockFile('Vaultkeeper AI/notes.md');
const agentFolder = createMockFolder('Vaultkeeper AI', [file1, file2]);
mockVault.getAbstractFileByPath.mockReturnValue(agentFolder);
// Create a spy on the vaultService.getAbstractFileByPath method
const getAbstractFileSpy = vi.spyOn(vaultService, 'getAbstractFileByPath');
// Call with allowAccessToPluginRoot = false (should block access)
await vaultService.listFilesInDirectory('Vaultkeeper AI', true, false);
// Verify getAbstractFileByPath was called with the correct parameter
expect(getAbstractFileSpy).toHaveBeenCalledWith('Vaultkeeper AI', false);
// Reset
getAbstractFileSpy.mockClear();
// Call with allowAccessToPluginRoot = true (should allow access)
await vaultService.listFilesInDirectory('Vaultkeeper AI', true, true);
// Verify getAbstractFileByPath was called with the correct parameter
expect(getAbstractFileSpy).toHaveBeenCalledWith('Vaultkeeper AI', true);
});
it('should not access excluded directory when allowAccessToPluginRoot is false', async () => {
// Mock to simulate VaultService exclusion behavior
mockVault.getAbstractFileByPath.mockReturnValue(null);
// Create a spy to verify the correct parameter is passed
const getAbstractFileSpy = vi.spyOn(vaultService, 'getAbstractFileByPath');
// Try to list files in Vaultkeeper AI directory with allowAccessToPluginRoot = false
const result = await vaultService.listFilesInDirectory('Vaultkeeper AI', true, false);
// Should call getAbstractFileByPath with false (not hardcoded true)
expect(getAbstractFileSpy).toHaveBeenCalledWith('Vaultkeeper AI', false);
// Should return empty array since directory is excluded
expect(result).toEqual([]);
});
});
describe('listFoldersInDirectory', () => {
it('should list all folders in directory non-recursively', async () => {
const folder1 = createMockFolder('parent/folder1', []);
const folder2 = createMockFolder('parent/folder2', []);
const parentFolder = createMockFolder('parent', [folder1, folder2]);
mockVault.getAbstractFileByPath.mockReturnValue(parentFolder);
const result = await vaultService.listFoldersInDirectory('parent', false);
expect(result).toHaveLength(2);
expect(result).toContain(folder1);
expect(result).toContain(folder2);
});
it('should list all folders recursively', async () => {
const subfolder1 = createMockFolder('parent/child/subfolder1', []);
const childFolder = createMockFolder('parent/child', [subfolder1]);
const folder1 = createMockFolder('parent/folder1', []);
const parentFolder = createMockFolder('parent', [folder1, childFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === 'parent') return parentFolder;
if (path === 'parent/folder1') return folder1;
if (path === 'parent/child') return childFolder;
return null;
});
const result = await vaultService.listFoldersInDirectory('parent', true);
expect(result).toHaveLength(3);
expect(result).toContain(folder1);
expect(result).toContain(childFolder);
expect(result).toContain(subfolder1);
});
it('should filter out excluded folders from results', async () => {
const publicFolder = createMockFolder('parent/public', []);
const privateFolder = createMockFolder('parent/private', []);
const parentFolder = createMockFolder('parent', [publicFolder, privateFolder]);
mockVault.getAbstractFileByPath.mockReturnValue(parentFolder);
settingsService.settings.exclusions = ['**/private'];
const result = await vaultService.listFoldersInDirectory('parent', false);
expect(result).toHaveLength(1);
expect(result[0].path).toBe('parent/public');
});
it('should return empty array when directory does not exist', async () => {
mockVault.getAbstractFileByPath.mockReturnValue(null);
const result = await vaultService.listFoldersInDirectory('nonexistent');
expect(result).toEqual([]);
});
it('should return empty array when path is a file, not a directory', async () => {
const mockFile = createMockFile('file.md');
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
const result = await vaultService.listFoldersInDirectory('file.md');
expect(result).toEqual([]);
});
it('should respect allowAccessToPluginRoot parameter', async () => {
const agentFolder = createMockFolder('Vaultkeeper AI', []);
const notesFolder = createMockFolder('notes', []);
const rootFolder = createMockFolder('/', [agentFolder, notesFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
// Vaultkeeper AI blocked when allowAccessToPluginRoot = false
if (path === 'Vaultkeeper AI') return null;
if (path === 'notes') return notesFolder;
return null;
});
// With allowAccessToPluginRoot = false (should exclude Vaultkeeper AI)
const result1 = await vaultService.listFoldersInDirectory('/', true, false);
expect(result1.some((folder) => folder.path === 'Vaultkeeper AI')).toBe(false);
expect(result1.some((folder) => folder.path === 'notes')).toBe(true);
// With allowAccessToPluginRoot = true (should include Vaultkeeper AI)
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
if (path === 'Vaultkeeper AI') return agentFolder; // Now allowed
if (path === 'notes') return notesFolder;
return null;
});
const result2 = await vaultService.listFoldersInDirectory('/', true, true);
expect(result2.some((folder) => folder.path === 'Vaultkeeper AI')).toBe(true);
expect(result2.some((folder) => folder.path === 'notes')).toBe(true);
});
it('should not recurse into excluded folders', async () => {
const excludedSubfolder = createMockFolder('parent/excluded/sub', []);
const excludedFolder = createMockFolder('parent/excluded', [excludedSubfolder]);
const allowedFolder = createMockFolder('parent/allowed', []);
const parentFolder = createMockFolder('parent', [excludedFolder, allowedFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === 'parent') return parentFolder;
if (path === 'parent/excluded') return excludedFolder; // Should not be accessed
if (path === 'parent/allowed') return allowedFolder;
return null;
});
// Use pattern that matches the folder itself and its contents
settingsService.settings.exclusions = ['parent/excluded/**', 'parent/excluded'];
const result = await vaultService.listFoldersInDirectory('parent', true);
// Should not include excluded folder or its subfolders
expect(result.some((folder) => folder.path === 'parent/excluded')).toBe(false);
expect(result.some((folder) => folder.path === 'parent/excluded/sub')).toBe(false);
expect(result.some((folder) => folder.path === 'parent/allowed')).toBe(true);
});
});
describe('searchVaultFiles', () => {
it('should find matches in file content', async () => {
const file1 = createMockFile('note1.md');
const file2 = createMockFile('note2.md');
const folder = createMockFolder('/', [file1, file2]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockImplementation((file: TFile) => {
if (file.path === 'note1.md') return Promise.resolve('This is a test document with test word');
if (file.path === 'note2.md') return Promise.resolve('Another document');
return Promise.resolve('');
});
const results = await vaultService.searchVaultFiles('test');
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'note1.md');
expect(match).toBeDefined();
expect(match!.snippets.length).toBeGreaterThan(0);
});
it('should find filename matches', async () => {
const file = createMockFile('test-file.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('No matches in content');
const results = await vaultService.searchVaultFiles('test');
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'test-file.md');
expect(match).toBeDefined();
});
it('should handle invalid regex gracefully by escaping', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('Contains [special] characters');
// Should not throw error
const results = await vaultService.searchVaultFiles('[invalid regex');
expect(results).toBeDefined();
});
it('should extract snippets with context around matches', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
const content = 'a'.repeat(100) + 'MATCH' + 'b'.repeat(100);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue(content);
const results = await vaultService.searchVaultFiles('MATCH');
expect(results.length).toBeGreaterThan(0);
const match = results[0];
expect(match.snippets[0].text.length).toBeGreaterThan('MATCH'.length);
expect(match.snippets[0].text).toContain('MATCH');
});
it('should merge overlapping snippets', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
// Two matches close together that should be merged
const content = 'test ' + 'a'.repeat(50) + ' test';
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue(content);
const results = await vaultService.searchVaultFiles('test');
// Should merge into one snippet since they overlap
expect(results.length).toBeGreaterThan(0);
// The exact behavior depends on implementation details
});
it('should randomly sample when more than searchResultsLimit matches', async () => {
// Create 25 files, each with a match
const files: TFile[] = [];
for (let i = 0; i < 25; i++) {
files.push(createMockFile(`note${i}.md`));
}
const folder = createMockFolder('/', files);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('This contains the search term');
const results = await vaultService.searchVaultFiles('search');
// Should have at most searchResultsLimit snippet matches (plus potentially filename matches)
const totalSnippets = results.reduce((sum, r) => sum + r.snippets.length, 0);
expect(totalSnippets).toBeLessThanOrEqual(settingsService.settings.searchResultsLimit);
});
it('should respect custom searchResultsLimit setting', async () => {
// Set custom limit
settingsService.settings.searchResultsLimit = 5;
// Create 10 files, each with a match
const files: TFile[] = [];
for (let i = 0; i < 10; i++) {
files.push(createMockFile(`note${i}.md`));
}
const folder = createMockFolder('/', files);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('This contains the search term');
const results = await vaultService.searchVaultFiles('search');
// Should have at most 5 snippet matches
const totalSnippets = results.reduce((sum, r) => sum + r.snippets.length, 0);
expect(totalSnippets).toBeLessThanOrEqual(5);
});
it('should respect custom snippetSizeLimit setting for snippet extraction', async () => {
// Set custom snippet size
settingsService.settings.snippetSizeLimit = 20;
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
const content = 'a'.repeat(100) + 'MATCH' + 'b'.repeat(100);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue(content);
const results = await vaultService.searchVaultFiles('MATCH');
expect(results.length).toBeGreaterThan(0);
const match = results[0];
// Snippet should be approximately snippetSizeLimit characters (10 before + 5 for MATCH + 10 after)
// Allow some margin for the match itself
expect(match.snippets[0].text.length).toBeLessThanOrEqual(settingsService.settings.snippetSizeLimit + 10);
});
it('should perform case-insensitive search', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('Test TEST tEsT');
const results = await vaultService.searchVaultFiles('test');
expect(results.length).toBeGreaterThan(0);
// Should find all three variants
});
it('should parse regex literal with case-insensitive flag', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('This note contains information about MAUI development');
// Build the regex pattern string to avoid vitest transformer issues
const pattern = '/' + 'maui' + '/' + 'i';
const results = await vaultService.searchVaultFiles(pattern);
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'note.md');
expect(match).toBeDefined();
expect(match!.snippets.length).toBeGreaterThan(0);
expect(match!.snippets[0].text).toContain('MAUI');
});
it('should parse regex with word boundary patterns', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('docker dockerfiles and dockers');
// Should match only whole word "docker", not "dockers" or "dockerfiles"
const pattern = '/' + '\\b' + 'docker' + '\\b' + '/' + 'i';
const results = await vaultService.searchVaultFiles(pattern);
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'note.md');
expect(match).toBeDefined();
});
it('should parse regex with alternation patterns', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('I prefer gray over grey');
// Should match both "gray" and "grey"
const pattern = '/' + 'gr(a|e)y' + '/' + 'i';
const results = await vaultService.searchVaultFiles(pattern);
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'note.md');
expect(match).toBeDefined();
});
it('should parse regex with optional characters', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('kubernetes k8s and kube configurations');
// Should match kubernetes, k8s, or kube
const pattern = '/' + '(kubernetes|k8s|kube)' + '/' + 'i';
const results = await vaultService.searchVaultFiles(pattern);
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'note.md');
expect(match).toBeDefined();
});
it('should parse regex with wildcard sequences', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('project alpha and proj_alpha are both valid');
// Should match "project alpha", "proj_alpha", "proj alpha", etc.
const pattern = '/' + 'proj.*alpha' + '/' + 'i';
const results = await vaultService.searchVaultFiles(pattern);
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'note.md');
expect(match).toBeDefined();
});
it('should parse regex with character classes', async () => {
const file = createMockFile('note.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('Version v1.2 and v10.5 released');
// Should match version numbers like v1.2, v10.5, etc.
const pattern = '/' + 'v\\d+\\.\\d+' + '/';
const results = await vaultService.searchVaultFiles(pattern);
expect(results.length).toBeGreaterThan(0);
const match = results.find(r => r.file.path === 'note.md');
expect(match).toBeDefined();
});
it('should not log errors when vault contains excluded directories', async () => {
// Setup: Create a vault structure with excluded "Vaultkeeper AI" directory
const normalFile = createMockFile('notes/document.md');
const excludedFile = createMockFile('Vaultkeeper AI/secret.md');
const notesFolder = createMockFolder('notes', [normalFile]);
const excludedFolder = createMockFolder('Vaultkeeper AI', [excludedFile]);
const rootFolder = createMockFolder('/', [normalFile, notesFolder, excludedFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
if (path === 'notes') return notesFolder;
if (path === 'notes/document.md') return normalFile;
// Vaultkeeper AI directory should be blocked by getAbstractFileByPath
if (path === 'Vaultkeeper AI') return null;
return null;
});
mockVault.cachedRead.mockResolvedValue('test content');
// Spy on console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
// Execute search
const results = await vaultService.searchVaultFiles('test');
// Should not log exclusion errors during normal search operation
expect(consoleErrorSpy).not.toHaveBeenCalledWith(
expect.stringContaining('Plugin attempted to retrieve a file that is in the exclusions list')
);
// Should still return results from non-excluded files
expect(results.length).toBeGreaterThan(0);
consoleErrorSpy.mockRestore();
});
it('should respect allowAccessToPluginRoot parameter when true', async () => {
// Setup: Create vault with Vaultkeeper AI directory
const agentFile = createMockFile('Vaultkeeper AI/notes.md');
const normalFile = createMockFile('normal.md');
const agentFolder = createMockFolder('Vaultkeeper AI', [agentFile]);
const rootFolder = createMockFolder('/', [normalFile, agentFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
if (path === 'Vaultkeeper AI') return agentFolder;
if (path === 'Vaultkeeper AI/notes.md') return agentFile;
if (path === 'normal.md') return normalFile;
return null;
});
mockVault.cachedRead.mockResolvedValue('searchable content');
// Search with allowAccessToPluginRoot = true
const results = await vaultService.searchVaultFiles('searchable', true);
// Should include files from Vaultkeeper AI directory
const paths = results.map(r => r.file.path);
expect(paths).toContain('Vaultkeeper AI/notes.md');
expect(paths).toContain('normal.md');
});
it('should exclude Vaultkeeper AI directory when allowAccessToPluginRoot is false', async () => {
// Setup: Create vault with Vaultkeeper AI directory
const agentFile = createMockFile('Vaultkeeper AI/notes.md');
const normalFile = createMockFile('normal.md');
const agentFolder = createMockFolder('Vaultkeeper AI', [agentFile]);
const rootFolder = createMockFolder('/', [normalFile, agentFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
// When allowAccessToPluginRoot is false, Vaultkeeper AI should be blocked
if (path === 'Vaultkeeper AI') return null;
if (path === 'normal.md') return normalFile;
return null;
});
mockVault.cachedRead.mockResolvedValue('searchable content');
// Search with allowAccessToPluginRoot = false (default)
const results = await vaultService.searchVaultFiles('searchable', false);
// Should NOT include files from Vaultkeeper AI directory
const paths = results.map(r => r.file.path);
expect(paths).not.toContain('Vaultkeeper AI/notes.md');
expect(paths).toContain('normal.md');
});
it('should pass allowAccessToPluginRoot to listFilesInDirectory', async () => {
const file = createMockFile('test.md');
const folder = createMockFolder('/', [file]);
mockVault.getAbstractFileByPath.mockReturnValue(folder);
mockVault.cachedRead.mockResolvedValue('content');
// Create a spy on listFilesInDirectory
const listFilesSpy = vi.spyOn(vaultService, 'listFilesInDirectory');
// Call with allowAccessToPluginRoot = true
await vaultService.searchVaultFiles('test', true);
// Verify listFilesInDirectory was called with the correct parameter
expect(listFilesSpy).toHaveBeenCalledWith(Path.Root, true, true);
listFilesSpy.mockClear();
// Call with allowAccessToPluginRoot = false
await vaultService.searchVaultFiles('test', false);
// Verify listFilesInDirectory was called with the correct parameter
expect(listFilesSpy).toHaveBeenCalledWith(Path.Root, true, false);
listFilesSpy.mockRestore();
});
});
describe('isExclusion (private method behavior)', () => {
it('should exclude exact path matches', async () => {
settingsService.settings.exclusions = ['secret.md'];
const result = await vaultService.exists('secret.md');
expect(result).toBe(false);
});
it('should handle wildcard * (matches any non-slash)', async () => {
settingsService.settings.exclusions = ['folder/*.md'];
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
expect(await vaultService.exists('folder/file.md')).toBe(false);
expect(await vaultService.exists('folder/sub/file.md')).toBe(true); // * doesn't match /
});
it('should handle double wildcard ** (matches anything including slashes)', async () => {
settingsService.settings.exclusions = ['private/**'];
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
expect(await vaultService.exists('private/file.md')).toBe(false);
expect(await vaultService.exists('private/sub/deep/file.md')).toBe(false);
expect(await vaultService.exists('public/file.md')).toBe(true);
});
it('should handle patterns ending with / to match directory and contents', async () => {
settingsService.settings.exclusions = ['temp/'];
expect(await vaultService.exists('temp/file.md')).toBe(false);
expect(await vaultService.exists('temp/sub/file.md')).toBe(false);
});
it('should always exclude Vaultkeeper AI root by default', async () => {
const result = await vaultService.exists('Vaultkeeper AI/file.md', false);
expect(result).toBe(false);
});
it('should handle special regex characters in patterns', async () => {
settingsService.settings.exclusions = ['folder[test].md'];
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
// Should match literally, not as regex character class
expect(await vaultService.exists('folder[test].md')).toBe(false);
expect(await vaultService.exists('foldert.md')).toBe(true);
});
it('should handle multiple exclusion patterns', async () => {
settingsService.settings.exclusions = ['private/**', 'temp/', '*.secret'];
// Mock files to exist in vault
mockVault.adapter.exists.mockResolvedValue(true);
expect(await vaultService.exists('private/file.md')).toBe(false);
expect(await vaultService.exists('temp/file.md')).toBe(false);
expect(await vaultService.exists('data.secret')).toBe(false);
expect(await vaultService.exists('public/file.md')).toBe(true);
});
});
describe('registerFileEvents', () => {
it('should register all file event handlers', () => {
const mockEventRef = { event: 'mock' };
mockVault.on.mockReturnValue(mockEventRef);
const handler = vi.fn();
vaultService.registerFileEvents(handler);
// Should register 4 events (create, modify, rename, delete)
expect(mockVault.on).toHaveBeenCalledTimes(4);
expect(mockVault.on).toHaveBeenCalledWith('create', expect.any(Function));
expect(mockVault.on).toHaveBeenCalledWith('modify', expect.any(Function));
expect(mockVault.on).toHaveBeenCalledWith('rename', expect.any(Function));
expect(mockVault.on).toHaveBeenCalledWith('delete', expect.any(Function));
expect(mockPlugin.registerEvent).toHaveBeenCalledTimes(4);
});
it('should call handler with correct parameters for create event', () => {
const handler = vi.fn();
let createCallback: any;
mockVault.on.mockImplementation((event: string, callback: any) => {
if (event === 'create') {
createCallback = callback;
}
return { event: 'mock' };
});
vaultService.registerFileEvents(handler);
const mockFile = createMockFile('test.md');
createCallback(mockFile);
expect(handler).toHaveBeenCalledWith('create', mockFile, { oldPath: '' });
});
it('should call handler with correct parameters for rename event', () => {
const handler = vi.fn();
let renameCallback: any;
mockVault.on.mockImplementation((event: string, callback: any) => {
if (event === 'rename') {
renameCallback = callback;
}
return { event: 'mock' };
});
vaultService.registerFileEvents(handler);
const mockFile = createMockFile('new.md');
renameCallback(mockFile, 'old.md');
expect(handler).toHaveBeenCalledWith('rename', mockFile, { oldPath: 'old.md' });
});
});
describe('listDirectoryContents', () => {
it('should return all files and folders when no exclusions', async () => {
const file1 = createMockFile('note1.md');
const file2 = createMockFile('note2.md');
const folder1 = createMockFolder('folder1', []);
const folder2 = createMockFolder('folder2', []);
const rootFolder = createMockFolder('/', [file1, file2, folder1, folder2]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
if (path === 'folder1') return folder1;
if (path === 'folder2') return folder2;
return null;
});
const result = await vaultService.listDirectoryContents(Path.Root);
expect(result).toHaveLength(4);
expect(result).toEqual(expect.arrayContaining([file1, file2, folder1, folder2]));
});
it('should filter out excluded files and folders', async () => {
const publicNote = createMockFile('public/note.md');
const agentNote = createMockFile('Vaultkeeper AI/conversation.md');
const privateNote = createMockFile('private/secret.md');
const publicFolder = createMockFolder('public', [publicNote]);
const agentFolder = createMockFolder('Vaultkeeper AI', [agentNote]);
const privateFolder = createMockFolder('private', [privateNote]);
const rootFolder = createMockFolder('/', [publicNote, agentNote, privateNote, publicFolder, agentFolder, privateFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
if (path === 'public') return publicFolder;
if (path === 'Vaultkeeper AI') return null; // Excluded by default
if (path === 'private') return privateFolder;
return null;
});
settingsService.settings.exclusions = ['private/**'];
const result = await vaultService.listDirectoryContents(Path.Root, true, false);
// Should include: public/note.md and public folder
// Should exclude: Vaultkeeper AI folder (default exclusion), private/** content
expect(result.some((item: any) => item.path === 'public/note.md')).toBe(true);
expect(result.some((item: any) => item.path === 'public')).toBe(true);
expect(result.some((item: any) => item.path === 'Vaultkeeper AI/conversation.md')).toBe(false);
expect(result.some((item: any) => item.path === 'private/secret.md')).toBe(false);
expect(result.some((item: any) => item.path === 'private')).toBe(true); // Folder itself not excluded by 'private/**'
});
it('should exclude the Vaultkeeper AI directory itself from folder listings', async () => {
const publicNote = createMockFile('public/note.md');
const publicFolder = createMockFolder('public', [publicNote]);
const agentFolder = createMockFolder('Vaultkeeper AI', []);
const notesFolder = createMockFolder('notes', []);
const rootFolder = createMockFolder('/', [publicNote, publicFolder, agentFolder, notesFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
if (path === 'public') return publicFolder;
if (path === 'Vaultkeeper AI') return null; // Excluded by default
if (path === 'notes') return notesFolder;
return null;
});
const result = await vaultService.listDirectoryContents(Path.Root, true, false);
// Vaultkeeper AI directory itself should be excluded
expect(result.some((item: any) => item.path === 'Vaultkeeper AI')).toBe(false);
// Other folders should be included
expect(result.some((item: any) => item.path === 'public')).toBe(true);
expect(result.some((item: any) => item.path === 'notes')).toBe(true);
});
it('should include Vaultkeeper AI directory when allowAccessToPluginRoot is true', async () => {
const note = createMockFile('note.md');
const agentNote = createMockFile('Vaultkeeper AI/conversation.md');
const agentFolder = createMockFolder('Vaultkeeper AI', [agentNote]);
const rootFolder = createMockFolder('/', [note, agentFolder]);
mockVault.getAbstractFileByPath.mockImplementation((path: string) => {
if (path === '/' || path === '') return rootFolder;
if (path === 'Vaultkeeper AI') return agentFolder; // Allowed with flag
return null;
});
const result = await vaultService.listDirectoryContents(Path.Root, true, true);
expect(result).toHaveLength(3);
expect(result.some((item: any) => item.path === 'Vaultkeeper AI/conversation.md')).toBe(true);
});
it('should return empty array when vault is empty', async () => {
const emptyRoot = createMockFolder('/', []);
mockVault.getAbstractFileByPath.mockReturnValue(emptyRoot);
const result = await vaultService.listDirectoryContents(Path.Root);
expect(result).toEqual([]);
});
});
});