Skip to main content
Glama

DollhouseMCP

by DollhouseMCP
CollectionCache.test.tsβ€’24.1 kB
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { CollectionCache, CollectionItem, CollectionCacheEntry } from '../../../../src/cache/CollectionCache.js'; /** * Comprehensive unit tests for the CollectionCache class. * * NOTE: These tests are well-written but currently excluded in jest.config.cjs:29 * due to complex mocking requirements with ESM modules. The fs/promises module * mocking in ESM Jest setup requires additional configuration that conflicts * with the current test setup. * * To run these tests, they would need to be moved to integration tests or * the Jest configuration would need to be updated to properly handle ESM mocks. * * Tests cover: * - Cache TTL expiry (24-hour TTL) * - File path validation security (prevent path traversal) * - Graceful handling of filesystem errors * - Cache loading and saving * - Search functionality with fuzzy matching * - The normalizeSearchTerm method * - Cache statistics * - Clearing cache * - Invalid cache file scenarios * - Performance and edge cases */ // Mock the modules - this is the complex part that requires ESM Jest configuration jest.mock('fs/promises', () => ({ mkdir: jest.fn(), readFile: jest.fn(), writeFile: jest.fn(), unlink: jest.fn(), stat: jest.fn() })); jest.mock('../../../../src/utils/logger.js', () => ({ logger: { debug: jest.fn(), warn: jest.fn(), error: jest.fn(), info: jest.fn() } })); describe('CollectionCache', () => { let cache: CollectionCache; let testBaseDir: string; let testCacheDir: string; let testCacheFile: string; const mockItems: CollectionItem[] = [ { name: 'test-persona.md', path: 'personas/test-persona.md', sha: 'abc123', content: 'Test persona content', last_modified: '2023-01-01T00:00:00Z' }, { name: 'another-persona.md', path: 'personas/another-persona.md', sha: 'def456', content: 'Another test persona', last_modified: '2023-01-02T00:00:00Z' }, { name: 'skill-example.md', path: 'skills/skill-example.md', sha: 'ghi789', content: 'Test skill content', last_modified: '2023-01-03T00:00:00Z' } ]; const validCacheEntry: CollectionCacheEntry = { items: mockItems, timestamp: Date.now() - 1000, // 1 second ago (not expired) etag: 'test-etag' }; beforeEach(() => { jest.clearAllMocks(); // Setup test directories testBaseDir = path.join(os.tmpdir(), 'test-collection-cache-' + Date.now()); testCacheDir = path.join(testBaseDir, '.dollhousemcp', 'cache'); testCacheFile = path.join(testCacheDir, 'collection-cache.json'); cache = new CollectionCache(testBaseDir); }); afterEach(() => { jest.clearAllMocks(); }); describe('constructor', () => { it('should initialize with default base directory', () => { const defaultCache = new CollectionCache(); expect(defaultCache).toBeInstanceOf(CollectionCache); }); it('should initialize with custom base directory', () => { const customCache = new CollectionCache('/custom/path'); expect(customCache).toBeInstanceOf(CollectionCache); }); }); describe('TTL behavior', () => { it('should use 24-hour TTL', () => { const ttl = (cache as any).CACHE_TTL_MS; expect(ttl).toBe(24 * 60 * 60 * 1000); // 24 hours in milliseconds }); }); describe('normalizeSearchTerm', () => { it('should normalize search terms correctly', () => { // Access private method through type assertion const normalizeMethod = (cache as any).normalizeSearchTerm.bind(cache); expect(normalizeMethod('test-persona')).toBe('test persona'); expect(normalizeMethod('Test_Persona')).toBe('test persona'); expect(normalizeMethod('TEST PERSONA')).toBe('test persona'); expect(normalizeMethod('test-persona.md')).toBe('test persona'); expect(normalizeMethod(' test-persona ')).toBe('test persona'); expect(normalizeMethod('test---persona___name')).toBe('test persona name'); }); it('should handle edge cases', () => { const normalizeMethod = (cache as any).normalizeSearchTerm.bind(cache); expect(normalizeMethod('')).toBe(''); expect(normalizeMethod(' ')).toBe(''); expect(normalizeMethod('a')).toBe('a'); expect(normalizeMethod('a.md')).toBe('a'); }); }); // The following tests would work with proper ESM mocking setup: describe.skip('loadCache (requires ESM mocking)', () => { it('should load valid cache from file', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const result = await cache.loadCache(); expect(result).toEqual(validCacheEntry); }); it('should return null for expired cache', async () => { const expiredCacheEntry: CollectionCacheEntry = { items: mockItems, timestamp: Date.now() - (25 * 60 * 60 * 1000), // 25 hours ago (expired) etag: 'expired-etag' }; const cacheData = JSON.stringify(expiredCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const result = await cache.loadCache(); expect(result).toBeNull(); }); it('should return null for non-existent cache file', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; (fs.readFile as any).mockRejectedValue(error); const result = await cache.loadCache(); expect(result).toBeNull(); }); it('should return null and log error for other file system errors', async () => { const error = new Error('Permission denied') as any; error.code = 'EACCES'; (fs.readFile as any).mockRejectedValue(error); const result = await cache.loadCache(); expect(result).toBeNull(); }); it('should return null for invalid JSON', async () => { (fs.readFile as any).mockResolvedValue('invalid json'); const result = await cache.loadCache(); expect(result).toBeNull(); }); it('should reject cache file paths with path traversal attempts', async () => { const maliciousCache = new CollectionCache('../../../malicious'); const result = await maliciousCache.loadCache(); expect(result).toBeNull(); }); it('should reject cache file paths with null bytes', async () => { (cache as any).cacheFile = '/test/path\x00/cache.json'; const result = await cache.loadCache(); expect(result).toBeNull(); }); it('should validate TTL boundary conditions', async () => { const boundaryTimestamp = Date.now() - (24 * 60 * 60 * 1000); const boundaryCacheEntry: CollectionCacheEntry = { items: mockItems, timestamp: boundaryTimestamp, etag: 'boundary-etag' }; const cacheData = JSON.stringify(boundaryCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const result = await cache.loadCache(); expect(result).toBeNull(); // Should be expired at exactly TTL boundary }); it('should validate just within TTL', async () => { const withinTtlTimestamp = Date.now() - (24 * 60 * 60 * 1000 - 1000); const withinTtlCacheEntry: CollectionCacheEntry = { items: mockItems, timestamp: withinTtlTimestamp, etag: 'within-ttl-etag' }; const cacheData = JSON.stringify(withinTtlCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const result = await cache.loadCache(); expect(result).toEqual(withinTtlCacheEntry); }); }); describe.skip('saveCache (requires ESM mocking)', () => { it('should save cache successfully', async () => { (fs.mkdir as any).mockResolvedValue(undefined); (fs.writeFile as any).mockResolvedValue(undefined); await cache.saveCache(mockItems, 'test-etag'); expect(fs.mkdir).toHaveBeenCalledWith(testCacheDir, { recursive: true }); expect(fs.writeFile).toHaveBeenCalledWith( testCacheFile, expect.stringContaining('"items"'), 'utf8' ); }); it('should save cache without etag', async () => { (fs.mkdir as any).mockResolvedValue(undefined); (fs.writeFile as any).mockResolvedValue(undefined); await cache.saveCache(mockItems); const savedData = JSON.parse((fs.writeFile as any).mock.calls[0][1] as string); expect(savedData.etag).toBeUndefined(); }); it('should include timestamp in saved cache', async () => { (fs.mkdir as any).mockResolvedValue(undefined); (fs.writeFile as any).mockResolvedValue(undefined); const beforeTime = Date.now(); await cache.saveCache(mockItems, 'test-etag'); const afterTime = Date.now(); const savedData = JSON.parse((fs.writeFile as any).mock.calls[0][1] as string); expect(savedData.timestamp).toBeGreaterThanOrEqual(beforeTime); expect(savedData.timestamp).toBeLessThanOrEqual(afterTime); }); it('should handle directory creation errors gracefully', async () => { const mkdirError = new Error('Permission denied'); (fs.mkdir as any).mockRejectedValue(mkdirError); await cache.saveCache(mockItems, 'test-etag'); // Should not throw - errors are handled gracefully }); it('should handle write errors gracefully', async () => { (fs.mkdir as any).mockResolvedValue(undefined); const writeError = new Error('Disk full'); (fs.writeFile as any).mockRejectedValue(writeError); await cache.saveCache(mockItems, 'test-etag'); // Should not throw - errors are handled gracefully }); it('should format JSON with proper indentation', async () => { (fs.mkdir as any).mockResolvedValue(undefined); (fs.writeFile as any).mockResolvedValue(undefined); await cache.saveCache(mockItems, 'test-etag'); const savedData = (fs.writeFile as any).mock.calls[0][1] as string; expect(savedData).toContain(' '); // Check for indentation expect(savedData).toContain('\n'); // Check for newlines }); }); describe.skip('searchCache (requires ESM mocking)', () => { it('should search by filename', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('test-persona'); expect(results).toHaveLength(1); expect(results[0].name).toBe('test-persona.md'); }); it('should search by path', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('personas'); expect(results).toHaveLength(2); expect(results.every(item => item.path.includes('personas'))).toBe(true); }); it('should search by content', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('Another test'); expect(results).toHaveLength(1); expect(results[0].name).toBe('another-persona.md'); }); it('should perform case-insensitive search', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('TEST-PERSONA'); expect(results).toHaveLength(1); expect(results[0].name).toBe('test-persona.md'); }); it('should handle search with normalization', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('test persona'); expect(results).toHaveLength(1); expect(results[0].name).toBe('test-persona.md'); }); it('should return empty array when cache is not available', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; (fs.readFile as any).mockRejectedValue(error); const results = await cache.searchCache('test'); expect(results).toEqual([]); }); it('should return empty array for no matches', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('nonexistent'); expect(results).toEqual([]); }); it('should handle empty search query', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache(''); expect(results).toEqual([]); }); it('should search items without content property', async () => { const itemsWithoutContent = mockItems.map(item => ({ ...item, content: undefined })); const cacheEntryWithoutContent = { ...validCacheEntry, items: itemsWithoutContent }; const cacheData = JSON.stringify(cacheEntryWithoutContent); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('test-persona'); expect(results).toHaveLength(1); expect(results[0].name).toBe('test-persona.md'); }); it('should handle unicode and special characters in search', async () => { const unicodeItems: CollectionItem[] = [{ name: 'Γ©mojis-π“ͺ𝓷𝓭-ΓΌΓ±Γ­Γ§ΓΆdΓ©.md', path: 'special/Γ©mojis-π“ͺ𝓷𝓭-ΓΌΓ±Γ­Γ§ΓΆdΓ©.md', sha: 'unicode-sha', content: 'Content with Γ©mojis πŸŽ‰ and ΓΌΓ±Γ­Γ§ΓΆdΓ© characters', last_modified: '2023-01-01T00:00:00Z' }]; const unicodeCacheEntry: CollectionCacheEntry = { items: unicodeItems, timestamp: Date.now() - 1000, etag: 'unicode-etag' }; const cacheData = JSON.stringify(unicodeCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('Γ©mojis'); expect(results).toHaveLength(1); expect(results[0].name).toBe('Γ©mojis-π“ͺ𝓷𝓭-ΓΌΓ±Γ­Γ§ΓΆdΓ©.md'); }); }); describe.skip('getItemsByPath (requires ESM mocking)', () => { it('should filter items by path prefix', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.getItemsByPath('personas/'); expect(results).toHaveLength(2); expect(results.every(item => item.path.startsWith('personas/'))).toBe(true); }); it('should filter items by exact path prefix', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.getItemsByPath('skills/'); expect(results).toHaveLength(1); expect(results[0].path).toBe('skills/skill-example.md'); }); it('should return empty array for non-matching prefix', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.getItemsByPath('nonexistent/'); expect(results).toEqual([]); }); it('should return empty array when cache is not available', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; (fs.readFile as any).mockRejectedValue(error); const results = await cache.getItemsByPath('personas/'); expect(results).toEqual([]); }); it('should handle empty path prefix', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.getItemsByPath(''); expect(results).toHaveLength(mockItems.length); }); }); describe.skip('isCacheValid (requires ESM mocking)', () => { it('should return true for valid cache', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const isValid = await cache.isCacheValid(); expect(isValid).toBe(true); }); it('should return false for expired cache', async () => { const expiredCacheEntry: CollectionCacheEntry = { items: mockItems, timestamp: Date.now() - (25 * 60 * 60 * 1000), etag: 'expired-etag' }; const cacheData = JSON.stringify(expiredCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const isValid = await cache.isCacheValid(); expect(isValid).toBe(false); }); it('should return false when cache file does not exist', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; (fs.readFile as any).mockRejectedValue(error); const isValid = await cache.isCacheValid(); expect(isValid).toBe(false); }); it('should return false for corrupted cache file', async () => { (fs.readFile as any).mockResolvedValue('invalid json'); const isValid = await cache.isCacheValid(); expect(isValid).toBe(false); }); }); describe.skip('clearCache (requires ESM mocking)', () => { it('should clear cache successfully', async () => { (fs.unlink as any).mockResolvedValue(undefined); await cache.clearCache(); expect(fs.unlink).toHaveBeenCalledWith(testCacheFile); }); it('should handle non-existent file gracefully', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; (fs.unlink as any).mockRejectedValue(error); await cache.clearCache(); // Should not throw }); it('should handle other file system errors', async () => { const error = new Error('Permission denied') as any; error.code = 'EACCES'; (fs.unlink as any).mockRejectedValue(error); await cache.clearCache(); // Should not throw }); }); describe.skip('getCacheStats (requires ESM mocking)', () => { it('should return stats for valid cache', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const stats = await cache.getCacheStats(); expect(stats.itemCount).toBe(mockItems.length); expect(stats.cacheAge).toBeGreaterThan(0); expect(stats.isValid).toBe(true); }); it('should return stats for expired cache', async () => { const expiredCacheEntry: CollectionCacheEntry = { items: mockItems, timestamp: Date.now() - (25 * 60 * 60 * 1000), etag: 'expired-etag' }; const cacheData = JSON.stringify(expiredCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const stats = await cache.getCacheStats(); expect(stats.itemCount).toBe(0); expect(stats.cacheAge).toBe(0); expect(stats.isValid).toBe(false); }); it('should return default stats when cache does not exist', async () => { const error = new Error('File not found') as any; error.code = 'ENOENT'; (fs.readFile as any).mockRejectedValue(error); const stats = await cache.getCacheStats(); expect(stats.itemCount).toBe(0); expect(stats.cacheAge).toBe(0); expect(stats.isValid).toBe(false); }); it('should calculate cache age correctly', async () => { const testTimestamp = Date.now() - 5000; const testCacheEntry: CollectionCacheEntry = { items: mockItems, timestamp: testTimestamp, etag: 'test-etag' }; const cacheData = JSON.stringify(testCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const stats = await cache.getCacheStats(); expect(stats.cacheAge).toBeGreaterThanOrEqual(5000); expect(stats.cacheAge).toBeLessThan(10000); }); }); describe.skip('Error handling and edge cases (requires ESM mocking)', () => { it('should handle extremely large cache files', async () => { const largeItems = Array.from({ length: 10000 }, (_, i) => ({ name: `item-${i}.md`, path: `category/item-${i}.md`, sha: `sha-${i}`, content: `Content for item ${i}`.repeat(100), last_modified: new Date().toISOString() })); const largeCacheEntry: CollectionCacheEntry = { items: largeItems, timestamp: Date.now() - 1000, etag: 'large-cache-etag' }; const cacheData = JSON.stringify(largeCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const results = await cache.searchCache('item-123'); expect(results).toHaveLength(1); expect(results[0].name).toBe('item-123.md'); }); it('should handle malformed cache entries', async () => { const malformedCache = { items: [{ name: 'valid-item.md', path: 'valid/path.md', sha: 'valid-sha' }, { invalidProperty: 'invalid' }], timestamp: Date.now() - 1000 }; const cacheData = JSON.stringify(malformedCache); (fs.readFile as any).mockResolvedValue(cacheData); const result = await cache.loadCache(); expect(result).not.toBeNull(); const searchResults = await cache.searchCache('valid'); expect(searchResults).toHaveLength(1); }); it('should handle concurrent operations', async () => { const cacheData = JSON.stringify(validCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const promises = [ cache.loadCache(), cache.searchCache('test'), cache.getItemsByPath('personas/'), cache.isCacheValid(), cache.getCacheStats() ]; const results = await Promise.all(promises); expect(results[0]).toEqual(validCacheEntry); expect(Array.isArray(results[1])).toBe(true); expect(Array.isArray(results[2])).toBe(true); expect(typeof results[3]).toBe('boolean'); expect(typeof results[4]).toBe('object'); }); }); describe.skip('Performance (requires ESM mocking)', () => { it('should handle search operations efficiently', async () => { const largeItems = Array.from({ length: 1000 }, (_, i) => ({ name: `item-${i}.md`, path: `category${i % 10}/item-${i}.md`, sha: `sha-${i}`, content: `Content for item ${i} with searchable text`, last_modified: new Date().toISOString() })); const largeCacheEntry: CollectionCacheEntry = { items: largeItems, timestamp: Date.now() - 1000, etag: 'perf-test-etag' }; const cacheData = JSON.stringify(largeCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const startTime = Date.now(); const results = await cache.searchCache('item'); const searchTime = Date.now() - startTime; expect(results.length).toBeGreaterThan(0); expect(searchTime).toBeLessThan(100); }); it('should handle path filtering efficiently', async () => { const largeItems = Array.from({ length: 1000 }, (_, i) => ({ name: `item-${i}.md`, path: `category${i % 5}/item-${i}.md`, sha: `sha-${i}`, content: `Content for item ${i}`, last_modified: new Date().toISOString() })); const largeCacheEntry: CollectionCacheEntry = { items: largeItems, timestamp: Date.now() - 1000, etag: 'path-filter-etag' }; const cacheData = JSON.stringify(largeCacheEntry); (fs.readFile as any).mockResolvedValue(cacheData); const startTime = Date.now(); const results = await cache.getItemsByPath('category0/'); const filterTime = Date.now() - startTime; expect(results.length).toBe(200); expect(filterTime).toBeLessThan(50); }); }); });

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/DollhouseMCP/DollhouseMCP'

If you have feedback or need assistance with the MCP directory API, please join our Discord server