Skip to main content
Glama
ghostService.test.js11.8 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mockGhostApiModule } from '../../__tests__/helpers/mockGhostApi.js'; import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js'; import { mockDotenv } from '../../__tests__/helpers/testUtils.js'; // Mock the Ghost Admin API vi.mock('@tryghost/admin-api', () => mockGhostApiModule()); // Mock dotenv vi.mock('dotenv', () => mockDotenv()); // Mock logger vi.mock('../../utils/logger.js', () => ({ createContextLogger: createMockContextLogger(), })); // Import after setting up mocks and environment import { createPost, createTag, getTags, getSiteInfo, uploadImage, handleApiRequest, api, } from '../ghostService.js'; describe('ghostService', () => { beforeEach(() => { // Reset all mocks before each test vi.clearAllMocks(); }); describe('handleApiRequest', () => { describe('success paths', () => { it('should handle add action for posts successfully', async () => { const expectedPost = { id: '1', title: 'Test Post', status: 'draft' }; api.posts.add.mockResolvedValue(expectedPost); const result = await handleApiRequest('posts', 'add', { title: 'Test Post' }); expect(result).toEqual(expectedPost); expect(api.posts.add).toHaveBeenCalledWith({ title: 'Test Post' }); }); it('should handle add action for tags successfully', async () => { const expectedTag = { id: '1', name: 'Test Tag', slug: 'test-tag' }; api.tags.add.mockResolvedValue(expectedTag); const result = await handleApiRequest('tags', 'add', { name: 'Test Tag' }); expect(result).toEqual(expectedTag); expect(api.tags.add).toHaveBeenCalledWith({ name: 'Test Tag' }); }); it('should handle upload action for images successfully', async () => { const expectedImage = { url: 'https://example.com/image.jpg' }; api.images.upload.mockResolvedValue(expectedImage); const result = await handleApiRequest('images', 'upload', { file: '/path/to/image.jpg' }); expect(result).toEqual(expectedImage); expect(api.images.upload).toHaveBeenCalledWith({ file: '/path/to/image.jpg' }); }); it('should handle browse action with options successfully', async () => { const expectedTags = [ { id: '1', name: 'Tag1' }, { id: '2', name: 'Tag2' }, ]; api.tags.browse.mockResolvedValue(expectedTags); const result = await handleApiRequest('tags', 'browse', {}, { limit: 'all' }); expect(result).toEqual(expectedTags); expect(api.tags.browse).toHaveBeenCalledWith({ limit: 'all' }, {}); }); it('should handle add action with options successfully', async () => { const postData = { title: 'Test Post', html: '<p>Content</p>' }; const options = { source: 'html' }; const expectedPost = { id: '1', ...postData }; api.posts.add.mockResolvedValue(expectedPost); const result = await handleApiRequest('posts', 'add', postData, options); expect(result).toEqual(expectedPost); expect(api.posts.add).toHaveBeenCalledWith(postData, options); }); }); describe('error handling', () => { it('should throw error for invalid resource', async () => { await expect(handleApiRequest('invalid', 'add', {})).rejects.toThrow( 'Invalid Ghost API resource or action: invalid.add' ); }); it('should throw error for invalid action', async () => { await expect(handleApiRequest('posts', 'invalid', {})).rejects.toThrow( 'Invalid Ghost API resource or action: posts.invalid' ); }); it('should handle 404 error and throw', async () => { const error404 = new Error('Not found'); error404.response = { status: 404 }; api.posts.read.mockRejectedValue(error404); await expect(handleApiRequest('posts', 'read', { id: 'nonexistent' })).rejects.toThrow( 'Not found' ); }); }); describe('retry logic', () => { it('should retry on 429 rate limit error with delay', async () => { const rateLimitError = new Error('Rate limit exceeded'); rateLimitError.response = { status: 429 }; const successResult = { id: '1', name: 'Success' }; api.tags.add.mockRejectedValueOnce(rateLimitError).mockResolvedValueOnce(successResult); const startTime = Date.now(); const result = await handleApiRequest('tags', 'add', { name: 'Test' }); const elapsedTime = Date.now() - startTime; expect(result).toEqual(successResult); expect(api.tags.add).toHaveBeenCalledTimes(2); // Should have delayed at least 5000ms for rate limit expect(elapsedTime).toBeGreaterThanOrEqual(4900); // Allow small margin }, 10000); // 10 second timeout it('should retry on 500 server error with increasing delay', async () => { const serverError = new Error('Internal server error'); serverError.response = { status: 500 }; const successResult = { id: '1', title: 'Success' }; api.posts.add.mockRejectedValueOnce(serverError).mockResolvedValueOnce(successResult); const result = await handleApiRequest('posts', 'add', { title: 'Test' }); expect(result).toEqual(successResult); expect(api.posts.add).toHaveBeenCalledTimes(2); }); it('should retry on ECONNREFUSED network error', async () => { const networkError = new Error('Connection refused'); networkError.code = 'ECONNREFUSED'; const successResult = { id: '1', title: 'Success' }; api.posts.add.mockRejectedValueOnce(networkError).mockResolvedValueOnce(successResult); const result = await handleApiRequest('posts', 'add', { title: 'Test' }); expect(result).toEqual(successResult); expect(api.posts.add).toHaveBeenCalledTimes(2); }); it('should retry on ETIMEDOUT network error', async () => { const timeoutError = new Error('Connection timeout'); timeoutError.code = 'ETIMEDOUT'; const successResult = { id: '1', title: 'Success' }; api.posts.add.mockRejectedValueOnce(timeoutError).mockResolvedValueOnce(successResult); const result = await handleApiRequest('posts', 'add', { title: 'Test' }); expect(result).toEqual(successResult); expect(api.posts.add).toHaveBeenCalledTimes(2); }); it('should throw error after exhausting retries', async () => { const serverError = new Error('Server error'); serverError.response = { status: 500 }; api.posts.add.mockRejectedValue(serverError); // Should retry 3 times (4 attempts total) await expect(handleApiRequest('posts', 'add', { title: 'Test' })).rejects.toThrow( 'Server error' ); expect(api.posts.add).toHaveBeenCalledTimes(4); // Initial + 3 retries }, 10000); // 10 second timeout it('should not retry on non-retryable errors', async () => { const badRequestError = new Error('Bad request'); badRequestError.response = { status: 400 }; api.posts.add.mockRejectedValue(badRequestError); await expect(handleApiRequest('posts', 'add', { title: 'Test' })).rejects.toThrow( 'Bad request' ); expect(api.posts.add).toHaveBeenCalledTimes(1); // No retries }); }); }); describe('getSiteInfo', () => { it('should successfully retrieve site information', async () => { const expectedSite = { title: 'Test Site', url: 'https://example.com' }; api.site.read.mockResolvedValue(expectedSite); const result = await getSiteInfo(); expect(result).toEqual(expectedSite); expect(api.site.read).toHaveBeenCalled(); }); }); describe('uploadImage', () => { it('should throw error when image path is missing', async () => { await expect(uploadImage()).rejects.toThrow('Image path is required for upload'); await expect(uploadImage('')).rejects.toThrow('Image path is required for upload'); }); it('should successfully upload image with valid path', async () => { const imagePath = '/path/to/image.jpg'; const expectedResult = { url: 'https://example.com/uploaded-image.jpg' }; api.images.upload.mockResolvedValue(expectedResult); const result = await uploadImage(imagePath); expect(result).toEqual(expectedResult); expect(api.images.upload).toHaveBeenCalledWith({ file: imagePath }); }); }); describe('createPost', () => { it('should throw error when title is missing', async () => { await expect(createPost({})).rejects.toThrow('Post title is required'); }); it('should set default status to draft when not provided', async () => { const postData = { title: 'Test Post', html: '<p>Content</p>' }; const expectedPost = { id: '1', title: 'Test Post', html: '<p>Content</p>', status: 'draft' }; api.posts.add.mockResolvedValue(expectedPost); const result = await createPost(postData); expect(result).toEqual(expectedPost); expect(api.posts.add).toHaveBeenCalledWith( { status: 'draft', title: 'Test Post', html: '<p>Content</p>' }, { source: 'html' } ); }); it('should successfully create post with valid data', async () => { const postData = { title: 'Test Post', html: '<p>Content</p>', status: 'published' }; const expectedPost = { id: '1', ...postData }; api.posts.add.mockResolvedValue(expectedPost); const result = await createPost(postData); expect(result).toEqual(expectedPost); expect(api.posts.add).toHaveBeenCalled(); }); }); describe('createTag', () => { it('should throw error when tag name is missing', async () => { await expect(createTag({})).rejects.toThrow('Tag name is required'); }); it('should successfully create tag with valid data', async () => { const tagData = { name: 'Test Tag', slug: 'test-tag' }; const expectedTag = { id: '1', ...tagData }; api.tags.add.mockResolvedValue(expectedTag); const result = await createTag(tagData); expect(result).toEqual(expectedTag); expect(api.tags.add).toHaveBeenCalledWith(tagData); }); }); describe('getTags', () => { it('should reject tag names with invalid characters', async () => { await expect(getTags("'; DROP TABLE tags; --")).rejects.toThrow( 'Tag name contains invalid characters' ); }); it('should accept valid tag names', async () => { const validNames = ['Test Tag', 'test-tag', 'test_tag', 'Tag123']; const expectedTags = [{ id: '1', name: 'Tag' }]; api.tags.browse.mockResolvedValue(expectedTags); for (const name of validNames) { const result = await getTags(name); expect(result).toEqual(expectedTags); } }); it('should handle tags without filter when name is not provided', async () => { const expectedTags = [ { id: '1', name: 'Tag1' }, { id: '2', name: 'Tag2' }, ]; api.tags.browse.mockResolvedValue(expectedTags); const result = await getTags(); expect(result).toEqual(expectedTags); expect(api.tags.browse).toHaveBeenCalledWith({ limit: 'all' }, {}); }); it('should properly escape tag names in filter', async () => { const expectedTags = [{ id: '1', name: "Tag's Name" }]; api.tags.browse.mockResolvedValue(expectedTags); // This should work because we properly escape single quotes const result = await getTags('Valid Tag'); expect(result).toEqual(expectedTags); }); }); });

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