Skip to main content
Glama

ClinicalTrials.gov MCP Server

index.test.ts•22 kB
/** * @fileoverview Unit tests for pagination utilities. * Tests cursor-based pagination per MCP specification 2025-06-18. * @module tests/utils/pagination/index.test */ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance, } from 'vitest'; import { JsonRpcErrorCode, McpError, } from '../../../src/types-global/errors.js'; import { logger, requestContextService } from '../../../src/utils/index.js'; import { decodeCursor, DEFAULT_PAGINATION_CONFIG, encodeCursor, extractCursor, paginateArray, type PaginationState, } from '../../../src/utils/pagination/index.js'; import type { RequestContext } from '../../../src/utils/index.js'; describe('Pagination Utilities', () => { let context: RequestContext; let warningSpy: MockInstance; beforeEach(() => { context = requestContextService.createRequestContext({ operation: 'test-pagination', }); warningSpy = vi.spyOn(logger, 'warning').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe('encodeCursor()', () => { it('should encode a valid pagination state to base64url', () => { const state: PaginationState = { offset: 10, limit: 50 }; const cursor = encodeCursor(state); expect(cursor).toBeDefined(); expect(typeof cursor).toBe('string'); expect(cursor.length).toBeGreaterThan(0); // Verify it's valid base64url (no +, /, or = characters) expect(cursor).not.toMatch(/[+/=]/); }); it('should encode pagination state with additional custom fields', () => { const state: PaginationState = { offset: 0, limit: 25, sortBy: 'name', sortOrder: 'asc', }; const cursor = encodeCursor(state); expect(cursor).toBeDefined(); // Decode to verify custom fields are preserved const decoded = JSON.parse( Buffer.from(cursor, 'base64url').toString('utf-8'), ); expect(decoded.sortBy).toBe('name'); expect(decoded.sortOrder).toBe('asc'); }); it('should encode zero offset correctly', () => { const state: PaginationState = { offset: 0, limit: 10 }; const cursor = encodeCursor(state); const decoded = JSON.parse( Buffer.from(cursor, 'base64url').toString('utf-8'), ); expect(decoded.offset).toBe(0); expect(decoded.limit).toBe(10); }); it('should handle large offset values', () => { const state: PaginationState = { offset: 999999, limit: 100 }; const cursor = encodeCursor(state); const decoded = JSON.parse( Buffer.from(cursor, 'base64url').toString('utf-8'), ); expect(decoded.offset).toBe(999999); }); it('should throw McpError if encoding fails due to circular reference', () => { const circularState: PaginationState = { offset: 0, limit: 10 }; // Create circular reference (circularState as { self?: PaginationState }).self = circularState; expect(() => encodeCursor(circularState)).toThrow(McpError); try { encodeCursor(circularState); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InternalError); expect(mcpError.message).toContain( 'Failed to encode pagination cursor', ); } }); }); describe('decodeCursor()', () => { it('should decode a valid cursor created by encodeCursor', () => { const originalState: PaginationState = { offset: 50, limit: 100 }; const cursor = encodeCursor(originalState); const decodedState = decodeCursor(cursor, context); expect(decodedState.offset).toBe(50); expect(decodedState.limit).toBe(100); }); it('should decode cursor with additional custom fields', () => { const originalState: PaginationState = { offset: 10, limit: 20, customField: 'test-value', }; const cursor = encodeCursor(originalState); const decodedState = decodeCursor(cursor, context); expect(decodedState.offset).toBe(10); expect(decodedState.limit).toBe(20); expect(decodedState.customField).toBe('test-value'); }); it('should throw McpError with InvalidParams for invalid base64 string', () => { const invalidCursor = 'not-valid-base64!!!'; expect(() => decodeCursor(invalidCursor, context)).toThrow(McpError); try { decodeCursor(invalidCursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); expect(mcpError.message).toContain('Invalid pagination cursor'); expect(warningSpy).toHaveBeenCalledWith( 'Failed to decode pagination cursor', expect.objectContaining({ cursor: invalidCursor }), ); } }); it('should throw McpError with InvalidParams for valid base64 but invalid JSON', () => { const invalidJsonCursor = Buffer.from('not json', 'utf-8').toString( 'base64url', ); expect(() => decodeCursor(invalidJsonCursor, context)).toThrow(McpError); try { decodeCursor(invalidJsonCursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); } }); it('should throw McpError with InvalidParams when offset field is missing', () => { const invalidState = { limit: 50 }; // Missing offset const cursor = Buffer.from( JSON.stringify(invalidState), 'utf-8', ).toString('base64url'); expect(() => decodeCursor(cursor, context)).toThrow(McpError); try { decodeCursor(cursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); expect(mcpError.message).toContain('Invalid pagination cursor'); } }); it('should throw McpError with InvalidParams when limit field is missing', () => { const invalidState = { offset: 10 }; // Missing limit const cursor = Buffer.from( JSON.stringify(invalidState), 'utf-8', ).toString('base64url'); expect(() => decodeCursor(cursor, context)).toThrow(McpError); try { decodeCursor(cursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); } }); it('should throw McpError with InvalidParams for negative offset', () => { const invalidState = { offset: -5, limit: 50 }; const cursor = Buffer.from( JSON.stringify(invalidState), 'utf-8', ).toString('base64url'); expect(() => decodeCursor(cursor, context)).toThrow(McpError); try { decodeCursor(cursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); expect(mcpError.message).toContain('Invalid pagination cursor'); } }); it('should throw McpError with InvalidParams for zero limit', () => { const invalidState = { offset: 0, limit: 0 }; const cursor = Buffer.from( JSON.stringify(invalidState), 'utf-8', ).toString('base64url'); expect(() => decodeCursor(cursor, context)).toThrow(McpError); try { decodeCursor(cursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); } }); it('should throw McpError with InvalidParams for negative limit', () => { const invalidState = { offset: 0, limit: -10 }; const cursor = Buffer.from( JSON.stringify(invalidState), 'utf-8', ).toString('base64url'); expect(() => decodeCursor(cursor, context)).toThrow(McpError); try { decodeCursor(cursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); } }); it('should throw McpError with InvalidParams when offset is not a number', () => { const invalidState = { offset: 'not-a-number', limit: 50 }; const cursor = Buffer.from( JSON.stringify(invalidState), 'utf-8', ).toString('base64url'); expect(() => decodeCursor(cursor, context)).toThrow(McpError); try { decodeCursor(cursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); } }); it('should throw McpError with InvalidParams when limit is not a number', () => { const invalidState = { offset: 10, limit: 'not-a-number' }; const cursor = Buffer.from( JSON.stringify(invalidState), 'utf-8', ).toString('base64url'); expect(() => decodeCursor(cursor, context)).toThrow(McpError); try { decodeCursor(cursor, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); } }); it('should log warning when decoding fails', () => { const invalidCursor = 'corrupted-cursor'; try { decodeCursor(invalidCursor, context); } catch { // Expected to throw } expect(warningSpy).toHaveBeenCalledWith( 'Failed to decode pagination cursor', expect.objectContaining({ cursor: invalidCursor, error: expect.any(String), }), ); }); }); describe('extractCursor()', () => { it('should extract cursor from params.cursor', () => { const params = { cursor: 'test-cursor-123' }; const cursor = extractCursor(params); expect(cursor).toBe('test-cursor-123'); }); it('should extract cursor from params._meta.cursor', () => { const params = { _meta: { cursor: 'meta-cursor-456' } }; const cursor = extractCursor(params); expect(cursor).toBe('meta-cursor-456'); }); it('should prefer params.cursor over params._meta.cursor', () => { const params = { cursor: 'direct-cursor', _meta: { cursor: 'meta-cursor' }, }; const cursor = extractCursor(params); expect(cursor).toBe('direct-cursor'); }); it('should return undefined when params is undefined', () => { const cursor = extractCursor(undefined); expect(cursor).toBeUndefined(); }); it('should return undefined when neither cursor location exists', () => { const params = {}; const cursor = extractCursor(params); expect(cursor).toBeUndefined(); }); it('should return undefined when _meta exists but cursor does not', () => { const params = { _meta: {} }; const cursor = extractCursor(params); expect(cursor).toBeUndefined(); }); it('should handle empty string cursor', () => { const params = { cursor: '' }; const cursor = extractCursor(params); expect(cursor).toBe(''); }); }); describe('paginateArray()', () => { const testItems = Array.from({ length: 100 }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}`, })); it('should return first page when no cursor provided', () => { const result = paginateArray(testItems, undefined, 10, 100, context); expect(result.items).toHaveLength(10); expect(result.items[0]!.id).toBe(1); expect(result.items[9]!.id).toBe(10); expect(result.nextCursor).toBeDefined(); expect(result.totalCount).toBe(100); }); it('should return subsequent page with valid cursor', () => { const firstPageResult = paginateArray( testItems, undefined, 10, 100, context, ); const cursor = firstPageResult.nextCursor!; const secondPageResult = paginateArray( testItems, cursor, 10, 100, context, ); expect(secondPageResult.items).toHaveLength(10); expect(secondPageResult.items[0]!.id).toBe(11); expect(secondPageResult.items[9]!.id).toBe(20); expect(secondPageResult.nextCursor).toBeDefined(); }); it('should not include nextCursor on last page', () => { // Get to the last page (items 91-100) const cursor = encodeCursor({ offset: 90, limit: 10 }); const result = paginateArray(testItems, cursor, 10, 100, context); expect(result.items).toHaveLength(10); expect(result.items[0]!.id).toBe(91); expect(result.items[9]!.id).toBe(100); expect(result.nextCursor).toBeUndefined(); expect(result.totalCount).toBe(100); }); it('should handle empty array', () => { const emptyArray: typeof testItems = []; const result = paginateArray(emptyArray, undefined, 10, 100, context); expect(result.items).toHaveLength(0); expect(result.nextCursor).toBeUndefined(); expect(result.totalCount).toBe(0); }); it('should handle offset beyond array length', () => { const cursor = encodeCursor({ offset: 200, limit: 10 }); const result = paginateArray(testItems, cursor, 10, 100, context); expect(result.items).toHaveLength(0); expect(result.nextCursor).toBeUndefined(); expect(result.totalCount).toBe(100); }); it('should enforce maxPageSize when cursor specifies larger limit', () => { const cursor = encodeCursor({ offset: 0, limit: 500 }); // Request 500 items const result = paginateArray(testItems, cursor, 10, 100, context); // Max 100 expect(result.items).toHaveLength(100); // Should be capped at maxPageSize expect(result.items[0]!.id).toBe(1); expect(result.items[99]!.id).toBe(100); expect(result.nextCursor).toBeUndefined(); // No more items }); it('should handle partial last page', () => { const cursor = encodeCursor({ offset: 95, limit: 10 }); const result = paginateArray(testItems, cursor, 10, 100, context); expect(result.items).toHaveLength(5); // Only 5 items remaining expect(result.items[0]!.id).toBe(96); expect(result.items[4]!.id).toBe(100); expect(result.nextCursor).toBeUndefined(); }); it('should handle single item array', () => { const singleItem = [{ id: 1, name: 'Only Item' }]; const result = paginateArray(singleItem, undefined, 10, 100, context); expect(result.items).toHaveLength(1); expect(result.items[0]!.id).toBe(1); expect(result.nextCursor).toBeUndefined(); expect(result.totalCount).toBe(1); }); it('should handle exact page boundary', () => { const exactItems = Array.from({ length: 20 }, (_, i) => ({ id: i + 1, name: `Item ${i + 1}`, })); // First page of 10 const firstPage = paginateArray(exactItems, undefined, 10, 100, context); expect(firstPage.items).toHaveLength(10); expect(firstPage.nextCursor).toBeDefined(); // Second (last) page of 10 const secondPage = paginateArray( exactItems, firstPage.nextCursor!, 10, 100, context, ); expect(secondPage.items).toHaveLength(10); expect(secondPage.items[0]!.id).toBe(11); expect(secondPage.items[9]!.id).toBe(20); expect(secondPage.nextCursor).toBeUndefined(); // Exactly at boundary }); it('should use defaultPageSize when no cursor provided', () => { const result = paginateArray(testItems, undefined, 25, 100, context); expect(result.items).toHaveLength(25); // Should use defaultPageSize expect(result.items[0]!.id).toBe(1); expect(result.items[24]!.id).toBe(25); }); it('should preserve limit from cursor across pages', () => { const customLimit = 7; const cursor = encodeCursor({ offset: 0, limit: customLimit }); const result = paginateArray(testItems, cursor, 10, 100, context); expect(result.items).toHaveLength(customLimit); // Next page should also use the same limit const nextResult = paginateArray( testItems, result.nextCursor!, 10, 100, context, ); expect(nextResult.items).toHaveLength(customLimit); }); it('should handle cursor with custom fields (ignore non-pagination fields)', () => { const stateWithCustomFields: PaginationState = { offset: 10, limit: 20, sortBy: 'name', customMetadata: 'test', }; const cursor = encodeCursor(stateWithCustomFields); const result = paginateArray(testItems, cursor, 10, 100, context); expect(result.items).toHaveLength(20); expect(result.items[0]!.id).toBe(11); }); it('should throw McpError for invalid cursor', () => { const invalidCursor = 'invalid-cursor-string'; expect(() => paginateArray(testItems, invalidCursor, 10, 100, context), ).toThrow(McpError); try { paginateArray(testItems, invalidCursor, 10, 100, context); } catch (error) { const mcpError = error as McpError; expect(mcpError.code).toBe(JsonRpcErrorCode.InvalidParams); } }); }); describe('Integration Scenarios', () => { it('should successfully navigate through all pages of data', () => { const items = Array.from({ length: 47 }, (_, i) => ({ id: i + 1 })); const pageSize = 10; const collectedItems: typeof items = []; let cursor: string | undefined; let pageCount = 0; let hasMore = true; // Navigate through all pages while (hasMore) { const result = paginateArray(items, cursor, pageSize, 100, context); collectedItems.push(...result.items); pageCount++; if (!result.nextCursor) { hasMore = false; } else { cursor = result.nextCursor; } } expect(collectedItems).toHaveLength(47); expect(pageCount).toBe(5); // 10+10+10+10+7 expect(collectedItems[0]!.id).toBe(1); expect(collectedItems[46]!.id).toBe(47); }); it('should handle round-trip encode/decode preserving all fields', () => { const originalState: PaginationState = { offset: 42, limit: 73, sortBy: 'name', sortOrder: 'desc', filterApplied: true, }; const cursor = encodeCursor(originalState); const decodedState = decodeCursor(cursor, context); expect(decodedState.offset).toBe(originalState.offset); expect(decodedState.limit).toBe(originalState.limit); expect(decodedState.sortBy).toBe(originalState.sortBy); expect(decodedState.sortOrder).toBe(originalState.sortOrder); expect(decodedState.filterApplied).toBe(originalState.filterApplied); }); it('should handle pagination with extractCursor helper', () => { const items = Array.from({ length: 30 }, (_, i) => ({ id: i + 1 })); // First request (no cursor) const params1 = {}; const cursor1 = extractCursor(params1); const result1 = paginateArray(items, cursor1, 10, 100, context); expect(result1.items).toHaveLength(10); expect(result1.nextCursor).toBeDefined(); // Second request (cursor in params) expect(result1.nextCursor).toBeDefined(); const params2 = { cursor: result1.nextCursor! }; const cursor2 = extractCursor(params2); const result2 = paginateArray(items, cursor2, 10, 100, context); expect(result2.items).toHaveLength(10); expect(result2.items[0]!.id).toBe(11); // Third request (cursor in _meta) expect(result2.nextCursor).toBeDefined(); const params3 = { _meta: { cursor: result2.nextCursor! } }; const cursor3 = extractCursor(params3); const result3 = paginateArray(items, cursor3, 10, 100, context); expect(result3.items).toHaveLength(10); expect(result3.items[0]!.id).toBe(21); expect(result3.nextCursor).toBeUndefined(); // Last page }); it('should handle cursor created with different page size', () => { const items = Array.from({ length: 100 }, (_, i) => ({ id: i + 1 })); // First page with pageSize 15 const result1 = paginateArray(items, undefined, 15, 100, context); expect(result1.items).toHaveLength(15); // Continue with same cursor (should preserve limit=15) const result2 = paginateArray( items, result1.nextCursor!, 20, 100, context, ); expect(result2.items).toHaveLength(15); // Uses cursor's limit, not defaultPageSize expect(result2.items[0]!.id).toBe(16); }); it('should enforce maxPageSize when cursor requests larger limit', () => { const items = Array.from({ length: 50 }, (_, i) => ({ id: i + 1 })); // Create cursor requesting 30 items, but maxPageSize is 20 const cursor = encodeCursor({ offset: 0, limit: 30 }); const result = paginateArray(items, cursor, 10, 20, context); expect(result.items).toHaveLength(20); // Should be capped at maxPageSize expect(result.items[0]!.id).toBe(1); expect(result.items[19]!.id).toBe(20); }); }); describe('DEFAULT_PAGINATION_CONFIG', () => { it('should have valid default configuration values', () => { expect(DEFAULT_PAGINATION_CONFIG.DEFAULT_PAGE_SIZE).toBe(50); expect(DEFAULT_PAGINATION_CONFIG.MAX_PAGE_SIZE).toBe(1000); expect(DEFAULT_PAGINATION_CONFIG.MIN_PAGE_SIZE).toBe(1); // Validate relationships expect(DEFAULT_PAGINATION_CONFIG.MIN_PAGE_SIZE).toBeLessThan( DEFAULT_PAGINATION_CONFIG.DEFAULT_PAGE_SIZE, ); expect(DEFAULT_PAGINATION_CONFIG.DEFAULT_PAGE_SIZE).toBeLessThan( DEFAULT_PAGINATION_CONFIG.MAX_PAGE_SIZE, ); }); }); });

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/cyanheads/clinicaltrialsgov-mcp-server'

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