Skip to main content
Glama
2389-research

MCP Agent Social Media Server

hooks.test.ts22.6 kB
// ABOUTME: Unit tests for the hooks system // ABOUTME: Tests request/response/error hook processing with various scenarios import { jest } from '@jest/globals'; // Mock logger jest.mock('../src/logger.js', () => ({ logger: { info: jest.fn(), debug: jest.fn(), error: jest.fn(), warn: jest.fn(), }, })); // Mock error classes jest.mock('../src/middleware/error-handler.js', () => ({ McpRateLimitError: class McpRateLimitError extends Error { constructor( message: string, public retryAfter: number, ) { super(message); this.name = 'McpRateLimitError'; } }, })); import { HooksManager } from '../src/hooks/index.js'; import type { ErrorHook, Hook, HookContext, RequestHook, ResponseHook, } from '../src/hooks/types.js'; describe('HooksManager', () => { let hooksManager: HooksManager; let mockContext: HookContext; beforeEach(() => { jest.clearAllMocks(); hooksManager = new HooksManager(); mockContext = { sessionId: 'test-session-123', startTime: Date.now(), metadata: { test: 'value' }, }; }); describe('constructor', () => { it('should initialize with default hooks', () => { const hooks = hooksManager.getAllHooks(); expect(hooks.length).toBeGreaterThan(0); expect(hooks.some((h) => h.name === 'request-logger')).toBe(true); expect(hooks.some((h) => h.name === 'response-enricher')).toBe(true); expect(hooks.some((h) => h.name === 'error-enricher')).toBe(true); expect(hooks.some((h) => h.name === 'rate-limiter')).toBe(true); }); it('should sort hooks by priority', () => { const requestHooks = hooksManager.getAllHooks().filter((h) => h.type === 'request'); for (let i = 1; i < requestHooks.length; i++) { expect(requestHooks[i - 1].priority).toBeLessThanOrEqual(requestHooks[i].priority); } }); }); describe('registerHook', () => { it('should register a request hook', () => { const testHook: RequestHook = { name: 'test-request-hook', type: 'request', priority: 50, execute: jest.fn().mockResolvedValue(undefined), }; const initialCount = hooksManager.getAllHooks().filter((h) => h.type === 'request').length; hooksManager.registerHook(testHook); const finalCount = hooksManager.getAllHooks().filter((h) => h.type === 'request').length; expect(finalCount).toBe(initialCount + 1); expect(hooksManager.getAllHooks()).toContain(testHook); }); it('should register a response hook', () => { const testHook: ResponseHook = { name: 'test-response-hook', type: 'response', priority: 50, execute: jest.fn().mockResolvedValue(undefined), }; const initialCount = hooksManager.getAllHooks().filter((h) => h.type === 'response').length; hooksManager.registerHook(testHook); const finalCount = hooksManager.getAllHooks().filter((h) => h.type === 'response').length; expect(finalCount).toBe(initialCount + 1); expect(hooksManager.getAllHooks()).toContain(testHook); }); it('should register an error hook', () => { const testHook: ErrorHook = { name: 'test-error-hook', type: 'error', priority: 50, execute: jest.fn().mockResolvedValue(undefined), }; const initialCount = hooksManager.getAllHooks().filter((h) => h.type === 'error').length; hooksManager.registerHook(testHook); const finalCount = hooksManager.getAllHooks().filter((h) => h.type === 'error').length; expect(finalCount).toBe(initialCount + 1); expect(hooksManager.getAllHooks()).toContain(testHook); }); it('should maintain priority order after registration', () => { const hook1: RequestHook = { name: 'test-hook-1', type: 'request', priority: 30, execute: jest.fn(), }; const hook2: RequestHook = { name: 'test-hook-2', type: 'request', priority: 20, execute: jest.fn(), }; hooksManager.registerHook(hook1); hooksManager.registerHook(hook2); const requestHooks = hooksManager.getAllHooks().filter((h) => h.type === 'request'); const hook1Index = requestHooks.findIndex((h) => h.name === 'test-hook-1'); const hook2Index = requestHooks.findIndex((h) => h.name === 'test-hook-2'); expect(hook2Index).toBeLessThan(hook1Index); // Lower priority executes first }); }); describe('processRequest', () => { it('should process request through all request hooks', async () => { const mockExecute1 = jest.fn().mockResolvedValue({ modified: 1 }); const mockExecute2 = jest.fn().mockResolvedValue({ modified: 2 }); const hook1: RequestHook = { name: 'test-hook-1', type: 'request', priority: 10, execute: mockExecute1, }; const hook2: RequestHook = { name: 'test-hook-2', type: 'request', priority: 20, execute: mockExecute2, }; hooksManager.registerHook(hook1); hooksManager.registerHook(hook2); const request = { method: 'test' }; const result = await hooksManager.processRequest(request, mockContext); expect(mockExecute1).toHaveBeenCalledWith(request, mockContext); expect(mockExecute2).toHaveBeenCalledWith({ modified: 1 }, mockContext); expect(result).toEqual({ modified: 2 }); }); it('should skip hooks with false conditions', async () => { const mockExecute = jest.fn(); const mockCondition = jest.fn().mockReturnValue(false); const hook: RequestHook = { name: 'test-hook', type: 'request', priority: 50, condition: mockCondition, execute: mockExecute, }; hooksManager.registerHook(hook); const request = { method: 'test' }; await hooksManager.processRequest(request, mockContext); expect(mockCondition).toHaveBeenCalledWith(request, mockContext); expect(mockExecute).not.toHaveBeenCalled(); }); it('should execute hooks with true conditions', async () => { const mockExecute = jest.fn().mockResolvedValue(undefined); const mockCondition = jest.fn().mockReturnValue(true); const hook: RequestHook = { name: 'test-hook', type: 'request', priority: 50, condition: mockCondition, execute: mockExecute, }; hooksManager.registerHook(hook); const request = { method: 'test' }; await hooksManager.processRequest(request, mockContext); expect(mockCondition).toHaveBeenCalledWith(request, mockContext); expect(mockExecute).toHaveBeenCalledWith(request, mockContext); }); it('should handle non-critical hook failures gracefully', async () => { const mockExecute = jest.fn().mockRejectedValue(new Error('Hook failed')); const hook: RequestHook = { name: 'test-hook', type: 'request', priority: 50, critical: false, execute: mockExecute, }; hooksManager.registerHook(hook); const request = { method: 'test' }; const result = await hooksManager.processRequest(request, mockContext); expect(result).toEqual(request); // Original request returned }); it('should propagate critical hook failures', async () => { const hookError = new Error('Critical hook failed'); const mockExecute = jest.fn().mockRejectedValue(hookError); const hook: RequestHook = { name: 'test-hook', type: 'request', priority: 50, critical: true, execute: mockExecute, }; hooksManager.registerHook(hook); const request = { method: 'test' }; await expect(hooksManager.processRequest(request, mockContext)).rejects.toThrow( 'Critical hook failed', ); }); it('should preserve original request when hook returns undefined', async () => { const mockExecute = jest.fn().mockResolvedValue(undefined); const hook: RequestHook = { name: 'test-hook', type: 'request', priority: 50, execute: mockExecute, }; hooksManager.registerHook(hook); const request = { method: 'test' }; const result = await hooksManager.processRequest(request, mockContext); expect(result).toEqual(request); }); }); describe('processResponse', () => { it('should process response through all response hooks', async () => { const mockExecute1 = jest.fn().mockResolvedValue({ modified: 1 }); const mockExecute2 = jest.fn().mockResolvedValue({ modified: 2 }); const hook1: ResponseHook = { name: 'test-hook-1', type: 'response', priority: 10, execute: mockExecute1, }; const hook2: ResponseHook = { name: 'test-hook-2', type: 'response', priority: 20, execute: mockExecute2, }; hooksManager.registerHook(hook1); hooksManager.registerHook(hook2); const response = { data: 'test' }; const request = { method: 'test' }; const result = await hooksManager.processResponse(response, request, mockContext); expect(mockExecute1).toHaveBeenCalledWith(response, request, mockContext); expect(mockExecute2).toHaveBeenCalledWith({ modified: 1 }, request, mockContext); // The response enricher hook adds metadata, so we need to account for that expect(result).toEqual(expect.objectContaining({ modified: 2 })); }); it('should skip response hooks with false conditions', async () => { const mockExecute = jest.fn(); const mockCondition = jest.fn().mockReturnValue(false); const hook: ResponseHook = { name: 'test-hook', type: 'response', priority: 50, condition: mockCondition, execute: mockExecute, }; hooksManager.registerHook(hook); const response = { data: 'test' }; const request = { method: 'test' }; await hooksManager.processResponse(response, request, mockContext); expect(mockCondition).toHaveBeenCalledWith(response, request, mockContext); expect(mockExecute).not.toHaveBeenCalled(); }); it('should handle non-critical response hook failures', async () => { const mockExecute = jest.fn().mockRejectedValue(new Error('Hook failed')); const hook: ResponseHook = { name: 'test-hook', type: 'response', priority: 50, critical: false, execute: mockExecute, }; hooksManager.registerHook(hook); const response = { data: 'test' }; const request = { method: 'test' }; const result = await hooksManager.processResponse(response, request, mockContext); // Default response enricher adds metadata expect(result).toEqual(expect.objectContaining({ data: 'test' })); }); it('should propagate critical response hook failures', async () => { const hookError = new Error('Critical hook failed'); const mockExecute = jest.fn().mockRejectedValue(hookError); const hook: ResponseHook = { name: 'test-hook', type: 'response', priority: 50, critical: true, execute: mockExecute, }; hooksManager.registerHook(hook); const response = { data: 'test' }; const request = { method: 'test' }; await expect(hooksManager.processResponse(response, request, mockContext)).rejects.toThrow( 'Critical hook failed', ); }); }); describe('processError', () => { it('should process error through all error hooks', async () => { const originalError = new Error('Original error'); const enrichedError1 = new Error('Enriched error 1'); const enrichedError2 = new Error('Enriched error 2'); const mockExecute1 = jest.fn().mockResolvedValue(enrichedError1); const mockExecute2 = jest.fn().mockResolvedValue(enrichedError2); const hook1: ErrorHook = { name: 'test-hook-1', type: 'error', priority: 10, execute: mockExecute1, }; const hook2: ErrorHook = { name: 'test-hook-2', type: 'error', priority: 20, execute: mockExecute2, }; hooksManager.registerHook(hook1); hooksManager.registerHook(hook2); const request = { method: 'test' }; const result = await hooksManager.processError(originalError, request, mockContext); expect(mockExecute1).toHaveBeenCalledWith(originalError, request, mockContext); expect(mockExecute2).toHaveBeenCalledWith(enrichedError1, request, mockContext); // Check the final error has the expected message expect(result.message).toBe('Enriched error 2'); }); it('should skip error hooks with false conditions', async () => { const mockExecute = jest.fn(); const mockCondition = jest.fn().mockReturnValue(false); const hook: ErrorHook = { name: 'test-hook', type: 'error', priority: 50, condition: mockCondition, execute: mockExecute, }; hooksManager.registerHook(hook); const error = new Error('Test error'); const request = { method: 'test' }; await hooksManager.processError(error, request, mockContext); expect(mockCondition).toHaveBeenCalledWith(error, request, mockContext); expect(mockExecute).not.toHaveBeenCalled(); }); it('should handle error hook failures gracefully', async () => { const originalError = new Error('Original error'); const mockExecute = jest.fn().mockRejectedValue(new Error('Hook failed')); const hook: ErrorHook = { name: 'test-hook', type: 'error', priority: 50, execute: mockExecute, }; hooksManager.registerHook(hook); const request = { method: 'test' }; const result = await hooksManager.processError(originalError, request, mockContext); // Default error enricher hook modifies the error, so check message expect(result.message).toBe('Original error'); }); it('should preserve original error when hook returns undefined', async () => { const originalError = new Error('Original error'); const mockExecute = jest.fn().mockResolvedValue(undefined); const hook: ErrorHook = { name: 'test-hook', type: 'error', priority: 50, execute: mockExecute, }; hooksManager.registerHook(hook); const request = { method: 'test' }; const result = await hooksManager.processError(originalError, request, mockContext); // Default error enricher hook modifies the error, so check message expect(result.message).toBe('Original error'); }); }); describe('removeHook', () => { it('should remove a hook by name', () => { const hook: RequestHook = { name: 'removable-hook', type: 'request', priority: 50, execute: jest.fn(), }; hooksManager.registerHook(hook); expect(hooksManager.getAllHooks()).toContain(hook); const removed = hooksManager.removeHook('removable-hook'); expect(removed).toBe(true); expect(hooksManager.getAllHooks()).not.toContain(hook); }); it('should return false when removing non-existent hook', () => { const removed = hooksManager.removeHook('non-existent-hook'); expect(removed).toBe(false); }); }); describe('default hooks', () => { describe('request-logger hook', () => { it('should log incoming requests', async () => { const request = { method: 'test-method' }; await hooksManager.processRequest(request, mockContext); // The logger is mocked at module level, so just verify the hook exists and runs const hooks = hooksManager.getAllHooks(); const loggerHook = hooks.find((h) => h.name === 'request-logger'); expect(loggerHook).toBeDefined(); expect(loggerHook?.type).toBe('request'); }); }); describe('response-enricher hook', () => { it('should add metadata to responses', async () => { const response = { data: 'test' }; const request = { method: 'test-method' }; const result = await hooksManager.processResponse(response, request, mockContext); expect(result).toMatchObject({ data: 'test', _metadata: { processedAt: expect.any(String), sessionId: 'test-session-123', requestMethod: 'test-method', }, }); }); }); describe('error-enricher hook', () => { it('should enrich errors with context', async () => { const originalError = new Error('Test error'); const request = { method: 'test-method' }; const result = await hooksManager.processError(originalError, request, mockContext); expect(result.message).toBe('Test error'); expect((result as unknown as { context: unknown }).context).toMatchObject({ method: 'test-method', sessionId: 'test-session-123', timestamp: expect.any(String), }); }); }); describe('rate-limiter hook', () => { beforeEach(() => { // Mock Date.now to control time jest.spyOn(Date, 'now').mockReturnValue(1000000); }); afterEach(() => { jest.restoreAllMocks(); }); it('should allow requests within rate limit', async () => { const request = { method: 'test-method' }; // Process multiple requests within limit for (let i = 0; i < 5; i++) { const result = await hooksManager.processRequest(request, mockContext); expect(result).toEqual(request); } }); it('should throw rate limit error when exceeded', async () => { const request = { method: 'test-method' }; const testContext = { sessionId: 'rate-test-session-unique', startTime: Date.now() }; // Create a fresh hooks manager to avoid interference from other tests const freshHooksManager = new HooksManager(); // Fill up the rate limit (30 requests is the limit) let successCount = 0; for (let i = 0; i < 30; i++) { try { await freshHooksManager.processRequest(request, testContext); successCount++; } catch (e) { console.log(`Unexpected error at request ${i}:`, e); throw e; } } expect(successCount).toBe(30); // 31st request should fail with rate limit error await expect(freshHooksManager.processRequest(request, testContext)).rejects.toThrow( 'Rate limit exceeded', ); }); it('should reset rate limit after window expires', async () => { const request = { method: 'test-method' }; // Fill up the rate limit for (let i = 0; i < 30; i++) { await hooksManager.processRequest(request, mockContext); } // Advance time beyond the window (60 seconds) jest.spyOn(Date, 'now').mockReturnValue(1000000 + 61000); // Should be able to make request again const result = await hooksManager.processRequest(request, mockContext); expect(result).toEqual(request); }); it('should track rate limits per session and method', async () => { const request1 = { method: 'method1' }; const request2 = { method: 'method2' }; const context2 = { ...mockContext, sessionId: 'different-session' }; // Fill up rate limit for method1 in session 1 for (let i = 0; i < 30; i++) { await hooksManager.processRequest(request1, mockContext); } // Should still be able to use method2 in session 1 const result1 = await hooksManager.processRequest(request2, mockContext); expect(result1).toEqual(request2); // Should still be able to use method1 in session 2 const result2 = await hooksManager.processRequest(request1, context2); expect(result2).toEqual(request1); }); }); }); describe('hook execution order', () => { it('should execute hooks in priority order', async () => { const executionOrder: string[] = []; const hook1: RequestHook = { name: 'priority-30', type: 'request', priority: 30, execute: jest.fn().mockImplementation(async () => { executionOrder.push('priority-30'); return undefined; }), }; const hook2: RequestHook = { name: 'priority-10', type: 'request', priority: 10, execute: jest.fn().mockImplementation(async () => { executionOrder.push('priority-10'); return undefined; }), }; const hook3: RequestHook = { name: 'priority-20', type: 'request', priority: 20, execute: jest.fn().mockImplementation(async () => { executionOrder.push('priority-20'); return undefined; }), }; hooksManager.registerHook(hook1); hooksManager.registerHook(hook2); hooksManager.registerHook(hook3); const request = { method: 'test' }; await hooksManager.processRequest(request, mockContext); expect(executionOrder).toEqual(['priority-10', 'priority-20', 'priority-30']); }); }); describe('hook chaining', () => { it('should pass modified request through hook chain', async () => { const hook1: RequestHook = { name: 'add-field-1', type: 'request', priority: 10, execute: jest.fn().mockImplementation(async (req) => ({ ...req, field1: 'added', })), }; const hook2: RequestHook = { name: 'add-field-2', type: 'request', priority: 20, execute: jest.fn().mockImplementation(async (req) => ({ ...req, field2: 'also-added', })), }; hooksManager.registerHook(hook1); hooksManager.registerHook(hook2); const request = { method: 'test' }; const result = await hooksManager.processRequest(request, mockContext); expect(result).toEqual({ method: 'test', field1: 'added', field2: 'also-added', }); expect(hook1.execute).toHaveBeenCalledWith(request, mockContext); expect(hook2.execute).toHaveBeenCalledWith({ method: 'test', field1: 'added' }, mockContext); }); }); });

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/2389-research/mcp-socialmedia'

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