andy-stack_vaultkeeper-ai/__tests__/Services/VaultCacheService.test.ts
Andrew Beal 182d12173a feat: add VaultCacheService and vault monitoring capabilities
Register VaultCacheService as singleton dependency, add file event registration to VaultService for monitoring vault changes, implement listVaultContents method to retrieve all files and folders with exclusion filtering, and add comprehensive test coverage for new functionality.
2025-10-25 12:46:07 +01:00

514 lines
15 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { VaultCacheService } from '../../Services/VaultCacheService';
import { VaultService } from '../../Services/VaultService';
import { TFile, TFolder, TAbstractFile, MetadataCache } from 'obsidian';
import { RegisterSingleton } from '../../Services/DependencyService';
import { Services } from '../../Services/Services';
import { FileEvent } from '../../Enums/FileEvent';
// Mock getAllTags from obsidian
vi.mock('obsidian', async () => {
const actual = await vi.importActual('obsidian');
return {
...actual,
getAllTags: vi.fn((metadata: any) => {
if (!metadata || !metadata.tags) return null;
return metadata.tags.map((t: any) => t.tag);
})
};
});
/**
* INTEGRATION TESTS
*
* These tests use real dependencies where possible and mock only the Obsidian API.
*/
// Create mock instances
const mockMetadataCache = {
getCache: vi.fn()
};
const mockVault = {
getFiles: vi.fn(),
getAllFolders: vi.fn(),
on: vi.fn()
};
const mockPlugin = {
app: {
vault: mockVault,
metadataCache: mockMetadataCache
},
settings: {
exclusions: []
},
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): TFolder {
const name = path.split('/').pop() || '';
const folder = new TFolder();
folder.path = path;
folder.name = name;
folder.children = [];
folder.parent = null;
folder.vault = mockVault as any;
return folder;
}
describe('VaultCacheService - Integration Tests', () => {
let vaultCacheService: VaultCacheService;
let vaultService: VaultService;
let fileEventHandler: any;
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Capture file event handler
fileEventHandler = null;
// Mock VaultService to capture the event handler
const mockVaultService = {
registerFileEvents: vi.fn((handler: any) => {
fileEventHandler = handler;
}),
listVaultContents: vi.fn(() => [])
};
// Register dependencies
RegisterSingleton(Services.AIAgentPlugin, mockPlugin as any);
RegisterSingleton(Services.VaultService, mockVaultService as any);
// Create fresh instance
vaultCacheService = new VaultCacheService();
vaultService = mockVaultService as any;
});
describe('initialization', () => {
it('should register file event handlers on construction', () => {
expect(vaultService.registerFileEvents).toHaveBeenCalledOnce();
expect(fileEventHandler).toBeDefined();
});
it('should initialize cache with existing vault contents', () => {
const files = [
createMockFile('note1.md'),
createMockFile('note2.md')
];
const folders = [createMockFolder('folder')];
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag1' }, { tag: '#tag2' }]
});
const mockListVaultContents = vi.fn(() => [...files, ...folders]);
const mockRegisterEvents = vi.fn((handler: any) => {
fileEventHandler = handler;
});
const mockVaultServiceWithContent = {
registerFileEvents: mockRegisterEvents,
listVaultContents: mockListVaultContents
};
RegisterSingleton(Services.VaultService, mockVaultServiceWithContent as any);
// Create new instance to trigger initialization
new VaultCacheService();
expect(mockListVaultContents).toHaveBeenCalled();
});
});
describe('file event handling - create', () => {
it('should cache file on create event', () => {
const mockFile = createMockFile('new.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Verify by triggering delete and checking orphaned tags
const orphanedTags = fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// If the file was cached, deleting it should not cause issues
expect(orphanedTags).not.toThrow;
});
it('should extract and cache tags on file create', () => {
const mockFile = createMockFile('tagged.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#important' }, { tag: '#project' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Verify tags are cached by creating another file with same tags
const mockFile2 = createMockFile('tagged2.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#important' }]
});
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
// Delete first file - #important should not be orphaned
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// Both files should be tracked
expect(fileEventHandler).toBeDefined();
});
it('should handle files with no tags', () => {
const mockFile = createMockFile('untagged.md');
mockMetadataCache.getCache.mockReturnValue(null);
// Should not throw
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle files with empty tag array', () => {
const mockFile = createMockFile('notags.md');
mockMetadataCache.getCache.mockReturnValue({
tags: []
});
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
});
describe('file event handling - modify', () => {
it('should update tags on file modify', () => {
const mockFile = createMockFile('modified.md');
// Create file with initial tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#old' }, { tag: '#keep' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify file with new tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#new' }, { tag: '#keep' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// The implementation should handle tag updates correctly
expect(fileEventHandler).toBeDefined();
});
it('should remove orphaned tags on modify', () => {
const mockFile = createMockFile('file.md');
// Create with tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#unique' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify to remove all tags
mockMetadataCache.getCache.mockReturnValue({
tags: []
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// #unique should be removed from cache
expect(fileEventHandler).toBeDefined();
});
it('should not remove shared tags on modify', () => {
const mockFile1 = createMockFile('file1.md');
const mockFile2 = createMockFile('file2.md');
// Create two files with shared tag
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#shared' }]
});
fileEventHandler(FileEvent.Create, mockFile1, { oldPath: '' });
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
// Modify file1 to remove shared tag
mockMetadataCache.getCache.mockReturnValue({
tags: []
});
fileEventHandler(FileEvent.Modify, mockFile1, { oldPath: '' });
// #shared should still exist because file2 has it
expect(fileEventHandler).toBeDefined();
});
});
describe('file event handling - rename', () => {
it('should update file path on rename', () => {
const oldPath = 'old.md';
const mockFile = createMockFile('new.md');
// Create file
const oldFile = createMockFile(oldPath);
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, oldFile, { oldPath: '' });
// Rename file
fileEventHandler(FileEvent.Rename, mockFile, { oldPath: oldPath });
// Verify rename worked by deleting with new path
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
expect(() => {
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
}).not.toThrow();
});
it('should preserve tags on rename', () => {
const oldPath = 'folder/old.md';
const newFile = createMockFile('folder/new.md');
// Create file with tags
const oldFile = createMockFile(oldPath);
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#important' }]
});
fileEventHandler(FileEvent.Create, oldFile, { oldPath: '' });
// Rename file
fileEventHandler(FileEvent.Rename, newFile, { oldPath: oldPath });
// Tags should still be associated with file
expect(fileEventHandler).toBeDefined();
});
});
describe('file event handling - delete', () => {
it('should remove file from cache on delete', () => {
const mockFile = createMockFile('deleted.md');
// Create file
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Delete file
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// Deleting again should handle gracefully
expect(() => {
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
}).not.toThrow();
});
it('should remove orphaned tags on delete', () => {
const mockFile = createMockFile('file.md');
// Create file with unique tag
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#unique' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Delete file
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
// #unique should be removed from cache
expect(fileEventHandler).toBeDefined();
});
it('should not remove shared tags on delete', () => {
const mockFile1 = createMockFile('file1.md');
const mockFile2 = createMockFile('file2.md');
// Create two files with shared tag
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#shared' }]
});
fileEventHandler(FileEvent.Create, mockFile1, { oldPath: '' });
fileEventHandler(FileEvent.Create, mockFile2, { oldPath: '' });
// Delete one file
fileEventHandler(FileEvent.Delete, mockFile1, { oldPath: mockFile1.path });
// #shared should still exist because file2 has it
expect(fileEventHandler).toBeDefined();
});
});
describe('complex scenarios', () => {
it('should handle multiple files with overlapping tags', () => {
const file1 = createMockFile('file1.md');
const file2 = createMockFile('file2.md');
const file3 = createMockFile('file3.md');
// file1: tag1, tag2
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag1' }, { tag: '#tag2' }]
});
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
// file2: tag2, tag3
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag2' }, { tag: '#tag3' }]
});
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
// file3: tag3, tag4
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag3' }, { tag: '#tag4' }]
});
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
// Delete file2
fileEventHandler(FileEvent.Delete, file2, { oldPath: file2.path });
// tag2 should still exist (file1), tag3 should still exist (file3)
expect(fileEventHandler).toBeDefined();
});
it('should handle file lifecycle: create, modify, rename, delete', () => {
let mockFile = createMockFile('initial.md');
// Create
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#v1' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#v2' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// Rename
const renamedFile = createMockFile('renamed.md');
fileEventHandler(FileEvent.Rename, renamedFile, { oldPath: mockFile.path });
// Delete
fileEventHandler(FileEvent.Delete, renamedFile, { oldPath: renamedFile.path });
// Should complete without errors
expect(fileEventHandler).toBeDefined();
});
it('should handle files in different folders', () => {
const file1 = createMockFile('folder1/note.md');
const file2 = createMockFile('folder2/note.md');
const file3 = createMockFile('note.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
fileEventHandler(FileEvent.Create, file1, { oldPath: '' });
fileEventHandler(FileEvent.Create, file2, { oldPath: '' });
fileEventHandler(FileEvent.Create, file3, { oldPath: '' });
// All three files should be tracked separately
expect(fileEventHandler).toBeDefined();
});
it('should handle rapid tag changes', () => {
const mockFile = createMockFile('volatile.md');
// Create with tags
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#a' }, { tag: '#b' }]
});
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
// Modify multiple times
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#b' }, { tag: '#c' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#c' }, { tag: '#d' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#d' }, { tag: '#e' }]
});
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
// Should handle gracefully
expect(fileEventHandler).toBeDefined();
});
});
describe('edge cases', () => {
it('should handle files with special characters in path', () => {
const mockFile = createMockFile('folder/file with spaces & (special).md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle tags with special characters', () => {
const mockFile = createMockFile('file.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#tag-with-dash' }, { tag: '#tag_with_underscore' }, { tag: '#tag123' }]
});
expect(() => {
fileEventHandler(FileEvent.Create, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle deleting non-existent file', () => {
const mockFile = createMockFile('never-created.md');
expect(() => {
fileEventHandler(FileEvent.Delete, mockFile, { oldPath: mockFile.path });
}).not.toThrow();
});
it('should handle modifying non-existent file', () => {
const mockFile = createMockFile('never-created.md');
mockMetadataCache.getCache.mockReturnValue({
tags: [{ tag: '#test' }]
});
expect(() => {
fileEventHandler(FileEvent.Modify, mockFile, { oldPath: '' });
}).not.toThrow();
});
it('should handle renaming non-existent file', () => {
const mockFile = createMockFile('new.md');
expect(() => {
fileEventHandler(FileEvent.Rename, mockFile, { oldPath: 'old.md' });
}).not.toThrow();
});
});
});