Skip to main content
Glama
postService.test.js13.1 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { mockDotenv } from '../../__tests__/helpers/testUtils.js'; import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js'; // Mock dotenv vi.mock('dotenv', () => mockDotenv()); // Mock logger vi.mock('../../utils/logger.js', () => ({ createContextLogger: createMockContextLogger(), })); // Mock ghostService functions - must use factory pattern to avoid hoisting issues vi.mock('../ghostService.js', () => ({ createPost: vi.fn(), getTags: vi.fn(), createTag: vi.fn(), })); // Import after mocks are set up import { createPostService } from '../postService.js'; import { createPost, getTags, createTag } from '../ghostService.js'; describe('postService', () => { beforeEach(() => { vi.clearAllMocks(); }); // NOTE: Input validation tests have been moved to MCP layer tests. // The postService no longer performs Joi validation - input is validated // by Zod schemas at the MCP tool layer (see mcp_server.js). describe('createPostService - basic functionality', () => { it('should accept valid input and create a post', async () => { const validInput = { title: 'Test Post', html: '<p>Test content</p>', }; const expectedPost = { id: '1', title: 'Test Post', status: 'draft' }; createPost.mockResolvedValue(expectedPost); const result = await createPostService(validInput); expect(result).toEqual(expectedPost); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ title: 'Test Post', html: '<p>Test content</p>', status: 'draft', }) ); }); it('should accept valid status values', async () => { const statuses = ['draft', 'published', 'scheduled']; createPost.mockResolvedValue({ id: '1', title: 'Test' }); for (const status of statuses) { const input = { title: 'Test Post', html: '<p>Content</p>', status, }; await createPostService(input); expect(createPost).toHaveBeenCalledWith(expect.objectContaining({ status })); vi.clearAllMocks(); } }); it('should accept valid feature_image URI', async () => { const validInput = { title: 'Test Post', html: '<p>Content</p>', feature_image: 'https://example.com/image.jpg', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(validInput); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ feature_image: 'https://example.com/image.jpg', }) ); }); }); describe('createPostService - tag resolution', () => { it('should find and reuse existing tag', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', tags: ['existing-tag'], }; const existingTag = { id: '1', name: 'existing-tag', slug: 'existing-tag' }; getTags.mockResolvedValue([existingTag]); createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(getTags).toHaveBeenCalledWith('existing-tag'); expect(createTag).not.toHaveBeenCalled(); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ tags: [{ name: 'existing-tag' }], }) ); }); it('should create new tag when not found', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', tags: ['new-tag'], }; getTags.mockResolvedValue([]); // Tag not found const newTag = { id: '2', name: 'new-tag', slug: 'new-tag' }; createTag.mockResolvedValue(newTag); createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(getTags).toHaveBeenCalledWith('new-tag'); expect(createTag).toHaveBeenCalledWith({ name: 'new-tag' }); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ tags: [{ name: 'new-tag' }], }) ); }); it('should handle errors during tag lookup gracefully', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', tags: ['error-tag', 'good-tag'], }; // First tag causes error, second tag exists getTags .mockRejectedValueOnce(new Error('Tag lookup failed')) .mockResolvedValueOnce([{ id: '1', name: 'good-tag' }]); createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); // Should skip error-tag and only include good-tag expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ tags: [{ name: 'good-tag' }], }) ); }); // NOTE: Tag validation tests (non-string values, empty strings) moved to MCP layer it('should trim whitespace from tag names', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', tags: [' trimmed-tag '], }; getTags.mockResolvedValue([{ id: '1', name: 'trimmed-tag' }]); createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(getTags).toHaveBeenCalledWith('trimmed-tag'); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ tags: [{ name: 'trimmed-tag' }], }) ); }); it('should handle mixed existing and new tags', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', tags: ['existing-tag', 'new-tag'], }; getTags .mockResolvedValueOnce([{ id: '1', name: 'existing-tag' }]) // First tag exists .mockResolvedValueOnce([]); // Second tag doesn't exist createTag.mockResolvedValue({ id: '2', name: 'new-tag' }); createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(getTags).toHaveBeenCalledTimes(2); expect(createTag).toHaveBeenCalledTimes(1); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ tags: [{ name: 'existing-tag' }, { name: 'new-tag' }], }) ); }); }); describe('createPostService - metadata defaults', () => { it('should default meta_title to title when not provided', async () => { const input = { title: 'Test Post Title', html: '<p>Content</p>', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ meta_title: 'Test Post Title', }) ); }); it('should use provided meta_title instead of defaulting to title', async () => { const input = { title: 'Test Post Title', html: '<p>Content</p>', meta_title: 'Custom Meta Title', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ meta_title: 'Custom Meta Title', }) ); }); it('should default meta_description to custom_excerpt when provided', async () => { const input = { title: 'Test Post', html: '<p>Long HTML content that would be stripped</p>', custom_excerpt: 'This is the custom excerpt', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ meta_description: 'This is the custom excerpt', }) ); }); it('should generate meta_description from HTML when no excerpt provided', async () => { const input = { title: 'Test Post', html: '<p>This is HTML content with tags stripped</p>', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ meta_description: 'This is HTML content with tags stripped', }) ); }); it('should use provided meta_description over custom_excerpt and HTML', async () => { const input = { title: 'Test Post', html: '<p>HTML content</p>', custom_excerpt: 'Custom excerpt', meta_description: 'Explicit meta description', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ meta_description: 'Explicit meta description', }) ); }); it('should truncate meta_description to 500 characters with ellipsis when generated from long HTML', async () => { const longHtml = '<p>' + 'a'.repeat(600) + '</p>'; const input = { title: 'Test Post', html: longHtml, }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); const calledDescription = createPost.mock.calls[0][0].meta_description; expect(calledDescription).toHaveLength(500); expect(calledDescription.endsWith('...')).toBe(true); expect(calledDescription).toBe('a'.repeat(497) + '...'); }); // NOTE: Empty HTML validation test moved to MCP layer it('should strip HTML tags and truncate when generating meta_description', async () => { const longHtml = '<p>' + 'word '.repeat(200) + '</p>'; const input = { title: 'Test Post', html: longHtml, }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); const calledDescription = createPost.mock.calls[0][0].meta_description; expect(calledDescription).not.toContain('<p>'); expect(calledDescription).not.toContain('</p>'); expect(calledDescription.length).toBeLessThanOrEqual(500); }); }); describe('createPostService - complete post creation', () => { it('should create post with all optional fields', async () => { const input = { title: 'Complete Post', html: '<p>Full content</p>', custom_excerpt: 'Excerpt', status: 'published', published_at: '2025-12-10T12:00:00.000Z', tags: ['tag1', 'tag2'], feature_image: 'https://example.com/image.jpg', feature_image_alt: 'Image alt text', feature_image_caption: 'Image caption', meta_title: 'Custom Meta Title', meta_description: 'Custom meta description', }; getTags.mockResolvedValue([]); createTag .mockResolvedValueOnce({ id: '1', name: 'tag1' }) .mockResolvedValueOnce({ id: '2', name: 'tag2' }); createPost.mockResolvedValue({ id: '1', title: 'Complete Post' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith({ title: 'Complete Post', html: '<p>Full content</p>', custom_excerpt: 'Excerpt', status: 'published', published_at: '2025-12-10T12:00:00.000Z', tags: [{ name: 'tag1' }, { name: 'tag2' }], feature_image: 'https://example.com/image.jpg', feature_image_alt: 'Image alt text', feature_image_caption: 'Image caption', meta_title: 'Custom Meta Title', meta_description: 'Custom meta description', }); }); it('should default status to draft when not provided', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ status: 'draft', }) ); }); it('should handle post creation with no tags', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ tags: [], }) ); expect(getTags).not.toHaveBeenCalled(); expect(createTag).not.toHaveBeenCalled(); }); it('should handle post creation with empty tags array', async () => { const input = { title: 'Test Post', html: '<p>Content</p>', tags: [], }; createPost.mockResolvedValue({ id: '1', title: 'Test' }); await createPostService(input); expect(createPost).toHaveBeenCalledWith( expect.objectContaining({ tags: [], }) ); expect(getTags).not.toHaveBeenCalled(); expect(createTag).not.toHaveBeenCalled(); }); }); });

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