Skip to main content
Glama
ResourceManager.test.js34.2 kB
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; // Mock MCP SDK vi.mock('@modelcontextprotocol/sdk/server/index.js', () => ({ Resource: class MockResource { constructor(config) { this.name = config.name; this.description = config.description; this.schema = config.schema; this.fetch = config.fetch; } }, })); // Mock errors vi.mock('../../errors/index.js', () => ({ NotFoundError: class NotFoundError extends Error { constructor(resource, identifier) { super(`${resource} not found: ${identifier}`); this.name = 'NotFoundError'; this.resource = resource; this.identifier = identifier; } }, ValidationError: class ValidationError extends Error { constructor(message) { super(message); this.name = 'ValidationError'; } }, })); import { ResourceManager } from '../ResourceManager.js'; // Helper to create mock ghost service function createMockGhostService() { return { getPost: vi.fn(), getPosts: vi.fn(), getTag: vi.fn(), getTags: vi.fn(), }; } describe('ResourceManager', () => { let mockGhostService; let resourceManager; beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers(); mockGhostService = createMockGhostService(); resourceManager = new ResourceManager(mockGhostService); }); afterEach(() => { vi.useRealTimers(); }); describe('LRUCache', () => { describe('get', () => { it('should return null for non-existent key', () => { const stats = resourceManager.getCacheStats(); expect(stats.size).toBe(0); }); it('should return cached value for existing key', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); // First fetch - should call service await resourceManager.fetchResource('ghost/post/1'); expect(mockGhostService.getPost).toHaveBeenCalledTimes(1); // Second fetch - should use cache await resourceManager.fetchResource('ghost/post/1'); expect(mockGhostService.getPost).toHaveBeenCalledTimes(1); }); it('should return null for expired items', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.fetchResource('ghost/post/1'); expect(mockGhostService.getPost).toHaveBeenCalledTimes(1); // Advance time beyond TTL (5 minutes) vi.advanceTimersByTime(301000); // Should fetch again because cache expired await resourceManager.fetchResource('ghost/post/1'); expect(mockGhostService.getPost).toHaveBeenCalledTimes(2); }); it('should update access order on get', async () => { const post1 = { id: '1', title: 'Post 1' }; const post2 = { id: '2', title: 'Post 2' }; mockGhostService.getPost.mockImplementation((id) => Promise.resolve(id === '1' ? post1 : post2) ); await resourceManager.fetchResource('ghost/post/1'); await resourceManager.fetchResource('ghost/post/2'); // Access post 1 again to update its access order await resourceManager.fetchResource('ghost/post/1'); const stats = resourceManager.getCacheStats(); expect(stats.size).toBe(2); }); }); describe('set', () => { it('should evict oldest items when at capacity', async () => { // Create a resource manager with small cache const smallCacheManager = new ResourceManager(mockGhostService); // We can't directly test cache eviction without modifying the class, // but we can verify the cache stores items const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); await smallCacheManager.fetchResource('ghost/post/1'); const stats = smallCacheManager.getCacheStats(); expect(stats.size).toBe(1); }); it('should allow custom TTL', async () => { // Collections use shorter TTL (1 minute) const posts = [{ id: '1', title: 'Post 1' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/posts?limit=10'); expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1); // Advance less than collection TTL vi.advanceTimersByTime(30000); // Should still use cache await resourceManager.fetchResource('ghost/posts?limit=10'); expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1); // Advance past collection TTL (1 minute) vi.advanceTimersByTime(35000); // Should fetch again await resourceManager.fetchResource('ghost/posts?limit=10'); expect(mockGhostService.getPosts).toHaveBeenCalledTimes(2); }); }); describe('invalidate', () => { it('should clear all cache when no pattern provided', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.fetchResource('ghost/post/1'); expect(resourceManager.getCacheStats().size).toBe(1); resourceManager.invalidateCache(); expect(resourceManager.getCacheStats().size).toBe(0); }); it('should invalidate entries matching pattern', async () => { const post = { id: '1', title: 'Test Post' }; const tag = { id: '1', name: 'Test Tag' }; mockGhostService.getPost.mockResolvedValue(post); mockGhostService.getTag.mockResolvedValue(tag); await resourceManager.fetchResource('ghost/post/1'); await resourceManager.fetchResource('ghost/tag/1'); expect(resourceManager.getCacheStats().size).toBe(2); resourceManager.invalidateCache('post'); // Only tag should remain expect(resourceManager.getCacheStats().size).toBe(1); }); }); describe('getStats', () => { it('should return cache statistics', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.fetchResource('ghost/post/1'); const stats = resourceManager.getCacheStats(); expect(stats).toHaveProperty('size'); expect(stats).toHaveProperty('maxSize'); expect(stats).toHaveProperty('ttl'); expect(stats).toHaveProperty('keys'); expect(stats.size).toBe(1); expect(stats.maxSize).toBe(100); expect(stats.ttl).toBe(300000); }); }); }); describe('ResourceURIParser', () => { describe('parse', () => { it('should parse simple resource URI', async () => { const post = { id: '123', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.fetchResource('ghost/post/123'); expect(mockGhostService.getPost).toHaveBeenCalledWith('123', { include: 'tags,authors', }); }); it('should parse URI with slug identifier', async () => { const posts = [{ id: '1', slug: 'my-post-slug', title: 'Test Post' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/post/slug:my-post-slug'); expect(mockGhostService.getPosts).toHaveBeenCalledWith({ filter: 'slug:my-post-slug', include: 'tags,authors', limit: 1, }); }); it('should parse URI with uuid identifier', async () => { const posts = [{ id: '1', uuid: '550e8400-e29b-41d4-a716-446655440000' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/post/uuid:550e8400-e29b-41d4-a716-446655440000'); expect(mockGhostService.getPosts).toHaveBeenCalledWith({ filter: 'uuid:550e8400-e29b-41d4-a716-446655440000', include: 'tags,authors', limit: 1, }); }); it('should parse collection URI with query parameters', async () => { const posts = [{ id: '1', title: 'Post 1' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/posts?status=published&limit=10&page=2'); expect(mockGhostService.getPosts).toHaveBeenCalledWith( expect.objectContaining({ limit: 10, page: 2, }) ); }); it('should throw ValidationError for invalid URI format', async () => { await expect(resourceManager.fetchResource('invalid')).rejects.toThrow( 'Invalid resource URI format' ); }); it('should throw ValidationError for unknown resource type', async () => { await expect(resourceManager.fetchResource('ghost/unknown/123')).rejects.toThrow( 'Unknown resource type: unknown' ); }); }); describe('build', () => { it('should build simple URI', async () => { // Test indirectly through fetcher behavior const post = { id: '1', title: 'Test' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.fetchResource('ghost/post/1'); expect(mockGhostService.getPost).toHaveBeenCalled(); }); }); }); describe('ResourceFetcher', () => { describe('fetchPost', () => { it('should fetch single post by id', async () => { const post = { id: '1', title: 'Test Post', tags: [], authors: [] }; mockGhostService.getPost.mockResolvedValue(post); const result = await resourceManager.fetchResource('ghost/post/1'); expect(result).toEqual(post); expect(mockGhostService.getPost).toHaveBeenCalledWith('1', { include: 'tags,authors', }); }); it('should fetch single post by slug', async () => { const posts = [{ id: '1', slug: 'test-slug', title: 'Test Post' }]; mockGhostService.getPosts.mockResolvedValue(posts); const result = await resourceManager.fetchResource('ghost/post/slug:test-slug'); expect(result).toEqual(posts[0]); expect(mockGhostService.getPosts).toHaveBeenCalledWith({ filter: 'slug:test-slug', include: 'tags,authors', limit: 1, }); }); it('should fetch single post by uuid', async () => { const posts = [{ id: '1', uuid: 'test-uuid', title: 'Test Post' }]; mockGhostService.getPosts.mockResolvedValue(posts); const result = await resourceManager.fetchResource('ghost/post/uuid:test-uuid'); expect(result).toEqual(posts[0]); expect(mockGhostService.getPosts).toHaveBeenCalledWith({ filter: 'uuid:test-uuid', include: 'tags,authors', limit: 1, }); }); it('should throw NotFoundError when post not found', async () => { mockGhostService.getPost.mockResolvedValue(null); await expect(resourceManager.fetchResource('ghost/post/nonexistent')).rejects.toThrow( 'Post not found' ); }); it('should throw ValidationError for unknown identifier type', async () => { await expect(resourceManager.fetchResource('ghost/post/unknown:value')).rejects.toThrow( 'Unknown identifier type: unknown' ); }); it('should use cache for repeated fetches', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.fetchResource('ghost/post/1'); await resourceManager.fetchResource('ghost/post/1'); expect(mockGhostService.getPost).toHaveBeenCalledTimes(1); }); }); describe('fetchPosts', () => { it('should fetch posts collection with default options', async () => { const posts = [ { id: '1', title: 'Post 1' }, { id: '2', title: 'Post 2' }, ]; mockGhostService.getPosts.mockResolvedValue(posts); const result = await resourceManager.fetchResource('ghost/posts'); expect(result).toHaveProperty('data'); expect(result).toHaveProperty('meta'); expect(result.meta.pagination).toBeDefined(); }); it('should fetch posts with status filter', async () => { const posts = [{ id: '1', title: 'Post 1', status: 'published' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/posts?status=published'); expect(mockGhostService.getPosts).toHaveBeenCalledWith( expect.objectContaining({ filter: 'status:published', }) ); }); it('should append status to existing filter', async () => { const posts = [{ id: '1', title: 'Post 1' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/posts?filter=featured:true&status=published'); expect(mockGhostService.getPosts).toHaveBeenCalledWith( expect.objectContaining({ filter: 'featured:true+status:published', }) ); }); it('should handle pagination parameters', async () => { const posts = [{ id: '1', title: 'Post 1' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/posts?limit=5&page=3'); expect(mockGhostService.getPosts).toHaveBeenCalledWith( expect.objectContaining({ limit: 5, page: 3, }) ); }); it('should include pagination metadata in response', async () => { const posts = [{ id: '1' }]; posts.meta = { pagination: { total: 100, next: 2, prev: null } }; mockGhostService.getPosts.mockResolvedValue(posts); const result = await resourceManager.fetchResource('ghost/posts?limit=10&page=1'); expect(result.meta.pagination).toBeDefined(); expect(result.meta.pagination.page).toBe(1); expect(result.meta.pagination.limit).toBe(10); }); it('should cache posts with shorter TTL', async () => { const posts = [{ id: '1', title: 'Post 1' }]; mockGhostService.getPosts.mockResolvedValue(posts); await resourceManager.fetchResource('ghost/posts'); expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1); // Still cached at 30 seconds vi.advanceTimersByTime(30000); await resourceManager.fetchResource('ghost/posts'); expect(mockGhostService.getPosts).toHaveBeenCalledTimes(1); // Expired at 65 seconds vi.advanceTimersByTime(35000); await resourceManager.fetchResource('ghost/posts'); expect(mockGhostService.getPosts).toHaveBeenCalledTimes(2); }); }); describe('fetchTag', () => { it('should fetch single tag by id', async () => { const tag = { id: '1', name: 'Test Tag', slug: 'test-tag' }; mockGhostService.getTag.mockResolvedValue(tag); const result = await resourceManager.fetchResource('ghost/tag/1'); expect(result).toEqual(tag); expect(mockGhostService.getTag).toHaveBeenCalledWith('1'); }); it('should fetch single tag by slug', async () => { const tags = [ { id: '1', slug: 'test-slug', name: 'Test Tag' }, { id: '2', slug: 'other', name: 'Other' }, ]; mockGhostService.getTags.mockResolvedValue(tags); const result = await resourceManager.fetchResource('ghost/tag/slug:test-slug'); expect(result).toEqual(tags[0]); }); it('should fetch single tag by name', async () => { // Reset mock to clear any previous calls mockGhostService.getTags.mockReset(); const tags = [{ id: '1', name: 'Technology', slug: 'technology' }]; mockGhostService.getTags.mockResolvedValue(tags); const result = await resourceManager.fetchResource('ghost/tag/name:Technology'); expect(result).toEqual(tags[0]); expect(mockGhostService.getTags).toHaveBeenCalledWith('Technology'); }); it('should use id by default when no identifier type specified', async () => { // When no type is specified (e.g., ghost/tag/tech), identifierType defaults to 'id' const tag = { id: 'tech', slug: 'tech', name: 'Technology' }; mockGhostService.getTag.mockResolvedValue(tag); const result = await resourceManager.fetchResource('ghost/tag/tech'); expect(result).toEqual(tag); expect(mockGhostService.getTag).toHaveBeenCalledWith('tech'); }); it('should throw NotFoundError when tag not found by id', async () => { mockGhostService.getTag.mockResolvedValue(null); await expect(resourceManager.fetchResource('ghost/tag/nonexistent')).rejects.toThrow( 'Tag not found' ); }); it('should throw NotFoundError when tag not found by slug', async () => { mockGhostService.getTags.mockResolvedValue([]); await expect(resourceManager.fetchResource('ghost/tag/slug:nonexistent')).rejects.toThrow( 'Tag not found' ); }); }); describe('fetchTags', () => { it('should fetch tags collection', async () => { const tags = [ { id: '1', name: 'Tag 1' }, { id: '2', name: 'Tag 2' }, ]; mockGhostService.getTags.mockResolvedValue(tags); const result = await resourceManager.fetchResource('ghost/tags'); expect(result).toHaveProperty('data'); expect(result).toHaveProperty('meta'); expect(result.data).toEqual(tags); }); it('should filter tags by name', async () => { const tags = [{ id: '1', name: 'Tech' }]; mockGhostService.getTags.mockResolvedValue(tags); await resourceManager.fetchResource('ghost/tags?name=Tech'); expect(mockGhostService.getTags).toHaveBeenCalledWith('Tech'); }); it('should apply client-side filtering', async () => { const tags = [ { id: '1', name: 'Tech', slug: 'tech' }, { id: '2', name: 'News', slug: 'news' }, ]; mockGhostService.getTags.mockResolvedValue(tags); const result = await resourceManager.fetchResource('ghost/tags?filter=name:Tech'); expect(result.data).toHaveLength(1); expect(result.data[0].name).toBe('Tech'); }); it('should apply pagination to tags', async () => { const tags = Array.from({ length: 100 }, (_, i) => ({ id: String(i + 1), name: `Tag ${i + 1}`, })); mockGhostService.getTags.mockResolvedValue(tags); const result = await resourceManager.fetchResource('ghost/tags?limit=10&page=2'); expect(result.data).toHaveLength(10); expect(result.data[0].name).toBe('Tag 11'); expect(result.meta.pagination.page).toBe(2); expect(result.meta.pagination.pages).toBe(10); expect(result.meta.pagination.total).toBe(100); }); }); }); describe('ResourceSubscriptionManager', () => { describe('subscribe', () => { it('should create subscription and return id', () => { const callback = vi.fn(); const subscriptionId = resourceManager.subscribe('ghost/post/1', callback); expect(subscriptionId).toMatch(/^sub_\d+_[a-z0-9]+$/); }); it('should start polling when enablePolling is true', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback, { enablePolling: true, pollingInterval: 1000, }); // Wait for initial poll await vi.advanceTimersByTimeAsync(100); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ type: 'update', uri: 'ghost/post/1', data: post, }) ); }); }); describe('unsubscribe', () => { it('should remove subscription', () => { const callback = vi.fn(); const subscriptionId = resourceManager.subscribe('ghost/post/1', callback); resourceManager.unsubscribe(subscriptionId); // Should not throw when unsubscribing expect(() => resourceManager.unsubscribe(subscriptionId)).toThrow('Subscription not found'); }); it('should throw NotFoundError for non-existent subscription', () => { expect(() => resourceManager.unsubscribe('invalid_id')).toThrow('Subscription not found'); }); it('should stop polling when unsubscribed', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); const callback = vi.fn(); const subscriptionId = resourceManager.subscribe('ghost/post/1', callback, { enablePolling: true, pollingInterval: 1000, }); await vi.advanceTimersByTimeAsync(100); const initialCallCount = callback.mock.calls.length; resourceManager.unsubscribe(subscriptionId); // Advance time - callback should not be called again await vi.advanceTimersByTimeAsync(2000); expect(callback.mock.calls.length).toBe(initialCallCount); }); }); describe('polling', () => { it('should call callback when value changes', async () => { const post1 = { id: '1', title: 'Original Title' }; const post2 = { id: '1', title: 'Updated Title' }; // Reset mock for clean state mockGhostService.getPost.mockReset(); mockGhostService.getPost.mockResolvedValueOnce(post1).mockResolvedValueOnce(post2); const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback, { enablePolling: true, pollingInterval: 1000, }); // Initial poll - need to let async operations complete await vi.advanceTimersByTimeAsync(50); expect(callback).toHaveBeenCalledTimes(1); // Invalidate cache so the next fetch goes to service resourceManager.invalidateCache(); // Next poll with changed value await vi.advanceTimersByTimeAsync(1000); expect(callback).toHaveBeenCalledTimes(2); }); it('should not call callback when value unchanged', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback, { enablePolling: true, pollingInterval: 1000, }); // Initial poll await vi.advanceTimersByTimeAsync(100); expect(callback).toHaveBeenCalledTimes(1); // Next poll with same value await vi.advanceTimersByTimeAsync(1000); expect(callback).toHaveBeenCalledTimes(1); }); it('should handle errors in polling', async () => { mockGhostService.getPost.mockRejectedValue(new Error('Network error')); const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback, { enablePolling: true, pollingInterval: 1000, }); await vi.advanceTimersByTimeAsync(100); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ type: 'error', uri: 'ghost/post/1', error: 'Network error', }) ); }); }); describe('notifySubscribers', () => { it('should notify matching subscribers', async () => { const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback); const updatedPost = { id: '1', title: 'Updated' }; resourceManager.notifyChange('ghost/post/1', updatedPost, 'update'); expect(callback).toHaveBeenCalledWith( expect.objectContaining({ type: 'update', uri: 'ghost/post/1', data: updatedPost, }) ); }); it('should notify subscribers with matching prefix', async () => { const callback = vi.fn(); resourceManager.subscribe('ghost/post', callback); const updatedPost = { id: '1', title: 'Updated' }; resourceManager.notifyChange('ghost/post/1', updatedPost, 'update'); expect(callback).toHaveBeenCalled(); }); }); describe('matchesSubscription', () => { it('should match exact URIs', async () => { const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback); resourceManager.notifyChange('ghost/post/1', {}, 'update'); expect(callback).toHaveBeenCalled(); }); it('should match when subscription is prefix of event', async () => { const callback = vi.fn(); resourceManager.subscribe('ghost/post', callback); resourceManager.notifyChange('ghost/post/123', {}, 'update'); expect(callback).toHaveBeenCalled(); }); it('should match when event is prefix of subscription', async () => { const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback); resourceManager.notifyChange('ghost/post', {}, 'update'); expect(callback).toHaveBeenCalled(); }); }); }); describe('ResourceManager', () => { describe('registerResource', () => { it('should register a resource', () => { const schema = { type: 'object' }; const resource = resourceManager.registerResource('test/resource', schema, { description: 'Test resource', }); expect(resource).toBeDefined(); expect(resource.name).toBe('test/resource'); }); it('should add resource to internal map', () => { const schema = { type: 'object' }; resourceManager.registerResource('test/resource', schema); const resources = resourceManager.listResources(); expect(resources).toHaveLength(1); expect(resources[0].uri).toBe('test/resource'); }); }); describe('fetchResource', () => { it('should fetch posts', async () => { const post = { id: '1', title: 'Test Post' }; mockGhostService.getPost.mockResolvedValue(post); const result = await resourceManager.fetchResource('ghost/post/1'); expect(result).toEqual(post); }); it('should fetch tags', async () => { const tag = { id: '1', name: 'Test Tag' }; mockGhostService.getTag.mockResolvedValue(tag); const result = await resourceManager.fetchResource('ghost/tag/1'); expect(result).toEqual(tag); }); it('should log errors', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); mockGhostService.getPost.mockRejectedValue(new Error('API Error')); await expect(resourceManager.fetchResource('ghost/post/1')).rejects.toThrow('API Error'); expect(consoleSpy).toHaveBeenCalled(); consoleSpy.mockRestore(); }); }); describe('listResources', () => { it('should return empty array when no resources registered', () => { const resources = resourceManager.listResources(); expect(resources).toEqual([]); }); it('should return all registered resources', () => { resourceManager.registerResource('ghost/posts', {}); resourceManager.registerResource('ghost/tags', {}); const resources = resourceManager.listResources(); expect(resources).toHaveLength(2); }); it('should filter resources by namespace', () => { resourceManager.registerResource('ghost/posts', {}); resourceManager.registerResource('other/items', {}); const resources = resourceManager.listResources({ namespace: 'ghost' }); expect(resources).toHaveLength(1); expect(resources[0].uri).toBe('ghost/posts'); }); }); describe('invalidateCache', () => { it('should invalidate all cache', async () => { const post = { id: '1', title: 'Test' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.fetchResource('ghost/post/1'); expect(resourceManager.getCacheStats().size).toBe(1); resourceManager.invalidateCache(); expect(resourceManager.getCacheStats().size).toBe(0); }); it('should invalidate by pattern', async () => { const post = { id: '1', title: 'Test' }; const tag = { id: '1', name: 'Test' }; mockGhostService.getPost.mockResolvedValue(post); mockGhostService.getTag.mockResolvedValue(tag); await resourceManager.fetchResource('ghost/post/1'); await resourceManager.fetchResource('ghost/tag/1'); expect(resourceManager.getCacheStats().size).toBe(2); resourceManager.invalidateCache('post'); expect(resourceManager.getCacheStats().size).toBe(1); }); it('should log invalidation', async () => { const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); resourceManager.invalidateCache(); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Cache invalidated')); resourceManager.invalidateCache('test'); expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('pattern: test')); consoleSpy.mockRestore(); }); }); describe('notifyChange', () => { it('should invalidate cache and notify subscribers', async () => { const post = { id: '1', title: 'Test' }; mockGhostService.getPost.mockResolvedValue(post); // Cache the resource await resourceManager.fetchResource('ghost/post/1'); expect(resourceManager.getCacheStats().size).toBe(1); const callback = vi.fn(); resourceManager.subscribe('ghost/post/1', callback); // Notify change resourceManager.notifyChange('ghost/post/1', { id: '1', title: 'Updated' }, 'update'); // Cache should be invalidated for matching pattern expect(callback).toHaveBeenCalled(); }); }); describe('getCacheStats', () => { it('should return cache statistics', () => { const stats = resourceManager.getCacheStats(); expect(stats).toHaveProperty('size'); expect(stats).toHaveProperty('maxSize'); expect(stats).toHaveProperty('ttl'); expect(stats).toHaveProperty('keys'); }); }); describe('batchFetch', () => { it('should fetch multiple resources', async () => { const post = { id: '1', title: 'Post' }; const tag = { id: '1', name: 'Tag' }; mockGhostService.getPost.mockResolvedValue(post); mockGhostService.getTag.mockResolvedValue(tag); const { results, errors } = await resourceManager.batchFetch([ 'ghost/post/1', 'ghost/tag/1', ]); expect(results['ghost/post/1']).toEqual(post); expect(results['ghost/tag/1']).toEqual(tag); expect(Object.keys(errors)).toHaveLength(0); }); it('should collect errors for failed fetches', async () => { const post = { id: '1', title: 'Post' }; mockGhostService.getPost.mockResolvedValue(post); mockGhostService.getTag.mockRejectedValue(new Error('Tag not found')); const { results, errors } = await resourceManager.batchFetch([ 'ghost/post/1', 'ghost/tag/1', ]); expect(results['ghost/post/1']).toEqual(post); expect(errors['ghost/tag/1']).toHaveProperty('message', 'Tag not found'); }); it('should fetch all resources in parallel', async () => { const post = { id: '1', title: 'Post' }; const tag = { id: '1', name: 'Tag' }; mockGhostService.getPost.mockResolvedValue(post); mockGhostService.getTag.mockResolvedValue(tag); await resourceManager.batchFetch(['ghost/post/1', 'ghost/tag/1']); expect(mockGhostService.getPost).toHaveBeenCalled(); expect(mockGhostService.getTag).toHaveBeenCalled(); }); }); describe('prefetch', () => { it('should prefetch resources and return status', async () => { const post = { id: '1', title: 'Post' }; mockGhostService.getPost.mockResolvedValue(post); const prefetched = await resourceManager.prefetch(['ghost/post/1']); expect(prefetched).toHaveLength(1); expect(prefetched[0]).toEqual({ pattern: 'ghost/post/1', status: 'success', }); }); it('should handle prefetch errors', async () => { mockGhostService.getPost.mockRejectedValue(new Error('Not found')); const prefetched = await resourceManager.prefetch(['ghost/post/1']); expect(prefetched).toHaveLength(1); expect(prefetched[0]).toEqual({ pattern: 'ghost/post/1', status: 'error', error: 'Not found', }); }); it('should prefetch multiple patterns', async () => { const post = { id: '1', title: 'Post' }; const tag = { id: '1', name: 'Tag' }; mockGhostService.getPost.mockResolvedValue(post); mockGhostService.getTag.mockResolvedValue(tag); const prefetched = await resourceManager.prefetch(['ghost/post/1', 'ghost/tag/1']); expect(prefetched).toHaveLength(2); expect(prefetched.every((p) => p.status === 'success')).toBe(true); }); it('should warm cache', async () => { const post = { id: '1', title: 'Post' }; mockGhostService.getPost.mockResolvedValue(post); await resourceManager.prefetch(['ghost/post/1']); // Cache should be warm await resourceManager.fetchResource('ghost/post/1'); expect(mockGhostService.getPost).toHaveBeenCalledTimes(1); }); }); }); });

Latest Blog Posts

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/jgardner04/Ghost-MCP-Server'

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