Skip to main content
Glama
ghostServiceImproved.members.test.js14.1 kB
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { createMockContextLogger } from '../../__tests__/helpers/mockLogger.js'; import { mockDotenv } from '../../__tests__/helpers/testUtils.js'; // Mock the Ghost Admin API with members support vi.mock('@tryghost/admin-api', () => ({ default: vi.fn(function () { return { posts: { add: vi.fn(), browse: vi.fn(), read: vi.fn(), edit: vi.fn(), delete: vi.fn(), }, pages: { add: vi.fn(), browse: vi.fn(), read: vi.fn(), edit: vi.fn(), delete: vi.fn(), }, tags: { add: vi.fn(), browse: vi.fn(), read: vi.fn(), edit: vi.fn(), delete: vi.fn(), }, members: { add: vi.fn(), browse: vi.fn(), read: vi.fn(), edit: vi.fn(), delete: vi.fn(), }, site: { read: vi.fn(), }, images: { upload: vi.fn(), }, }; }), })); // Mock dotenv vi.mock('dotenv', () => mockDotenv()); // Mock logger vi.mock('../../utils/logger.js', () => ({ createContextLogger: createMockContextLogger(), })); // Mock fs for validateImagePath vi.mock('fs/promises', () => ({ default: { access: vi.fn(), }, })); // Import after setting up mocks import { createMember, updateMember, deleteMember, getMembers, getMember, searchMembers, api, } from '../ghostServiceImproved.js'; describe('ghostServiceImproved - Members', () => { beforeEach(() => { // Reset all mocks before each test vi.clearAllMocks(); }); describe('createMember', () => { it('should create a member with required email', async () => { const memberData = { email: 'test@example.com', }; const mockCreatedMember = { id: 'member-1', email: 'test@example.com', status: 'free', }; api.members.add.mockResolvedValue(mockCreatedMember); const result = await createMember(memberData); expect(api.members.add).toHaveBeenCalledWith( expect.objectContaining({ email: 'test@example.com', }), expect.any(Object) ); expect(result).toEqual(mockCreatedMember); }); it('should create a member with optional fields', async () => { const memberData = { email: 'test@example.com', name: 'John Doe', note: 'Test member', labels: ['premium', 'newsletter'], newsletters: [{ id: 'newsletter-1' }], subscribed: true, }; const mockCreatedMember = { id: 'member-1', ...memberData, status: 'free', }; api.members.add.mockResolvedValue(mockCreatedMember); const result = await createMember(memberData); expect(api.members.add).toHaveBeenCalledWith( expect.objectContaining(memberData), expect.any(Object) ); expect(result).toEqual(mockCreatedMember); }); // NOTE: Input validation tests (missing email, invalid email, invalid labels) // have been moved to MCP layer tests. The service layer now relies on // Zod schema validation at the MCP tool layer. it('should handle Ghost API errors', async () => { const memberData = { email: 'test@example.com', }; api.members.add.mockRejectedValue(new Error('Ghost API Error')); await expect(createMember(memberData)).rejects.toThrow(); }); }); describe('updateMember', () => { it('should update a member with valid ID and data', async () => { const memberId = 'member-1'; const updateData = { name: 'Jane Doe', note: 'Updated note', }; const mockExistingMember = { id: memberId, email: 'test@example.com', name: 'John Doe', updated_at: '2023-01-01T00:00:00.000Z', }; const mockUpdatedMember = { ...mockExistingMember, ...updateData, }; api.members.read.mockResolvedValue(mockExistingMember); api.members.edit.mockResolvedValue(mockUpdatedMember); const result = await updateMember(memberId, updateData); expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: memberId }); expect(api.members.edit).toHaveBeenCalledWith( expect.objectContaining({ ...mockExistingMember, ...updateData, }), expect.objectContaining({ id: memberId }) ); expect(result).toEqual(mockUpdatedMember); }); it('should update member email if provided', async () => { const memberId = 'member-1'; const updateData = { email: 'newemail@example.com', }; const mockExistingMember = { id: memberId, email: 'test@example.com', updated_at: '2023-01-01T00:00:00.000Z', }; const mockUpdatedMember = { ...mockExistingMember, email: 'newemail@example.com', }; api.members.read.mockResolvedValue(mockExistingMember); api.members.edit.mockResolvedValue(mockUpdatedMember); const result = await updateMember(memberId, updateData); expect(result.email).toBe('newemail@example.com'); }); it('should throw validation error for missing member ID', async () => { await expect(updateMember(null, { name: 'Test' })).rejects.toThrow( 'Member ID is required for update' ); }); // NOTE: Input validation tests (invalid email in update) have been moved to // MCP layer tests. The service layer now relies on Zod schema validation. it('should throw not found error if member does not exist', async () => { api.members.read.mockRejectedValue({ response: { status: 404 }, message: 'Member not found', }); await expect(updateMember('non-existent', { name: 'Test' })).rejects.toThrow(); }); }); describe('deleteMember', () => { it('should delete a member with valid ID', async () => { const memberId = 'member-1'; api.members.delete.mockResolvedValue({ deleted: true }); const result = await deleteMember(memberId); expect(api.members.delete).toHaveBeenCalledWith(memberId, expect.any(Object)); expect(result).toEqual({ deleted: true }); }); it('should throw validation error for missing member ID', async () => { await expect(deleteMember(null)).rejects.toThrow('Member ID is required for deletion'); }); it('should throw not found error if member does not exist', async () => { api.members.delete.mockRejectedValue({ response: { status: 404 }, message: 'Member not found', }); await expect(deleteMember('non-existent')).rejects.toThrow(); }); }); describe('getMembers', () => { it('should return all members with default options', async () => { const mockMembers = [ { id: 'member-1', email: 'test1@example.com', status: 'free' }, { id: 'member-2', email: 'test2@example.com', status: 'paid' }, ]; api.members.browse.mockResolvedValue(mockMembers); const result = await getMembers(); expect(api.members.browse).toHaveBeenCalled(); expect(result).toEqual(mockMembers); }); it('should accept pagination options', async () => { const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }]; api.members.browse.mockResolvedValue(mockMembers); await getMembers({ limit: 50, page: 2 }); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ limit: 50, page: 2, }), expect.any(Object) ); }); it('should accept filter options', async () => { const mockMembers = [{ id: 'member-1', email: 'test1@example.com', status: 'paid' }]; api.members.browse.mockResolvedValue(mockMembers); await getMembers({ filter: 'status:paid' }); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ filter: 'status:paid', }), expect.any(Object) ); }); it('should accept order options', async () => { const mockMembers = [{ id: 'member-1', email: 'test1@example.com' }]; api.members.browse.mockResolvedValue(mockMembers); await getMembers({ order: 'created_at desc' }); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ order: 'created_at desc', }), expect.any(Object) ); }); it('should accept include options', async () => { const mockMembers = [ { id: 'member-1', email: 'test1@example.com', labels: [], newsletters: [] }, ]; api.members.browse.mockResolvedValue(mockMembers); await getMembers({ include: 'labels,newsletters' }); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ include: 'labels,newsletters', }), expect.any(Object) ); }); // NOTE: Input validation tests (invalid limit, invalid page) have been moved to // MCP layer tests. The service layer now relies on Zod schema validation. it('should return empty array when no members found', async () => { api.members.browse.mockResolvedValue([]); const result = await getMembers(); expect(result).toEqual([]); }); it('should handle Ghost API errors', async () => { api.members.browse.mockRejectedValue(new Error('Ghost API Error')); await expect(getMembers()).rejects.toThrow(); }); }); describe('getMember', () => { it('should get member by ID', async () => { const mockMember = { id: 'member-1', email: 'test@example.com', name: 'John Doe', status: 'free', }; api.members.read.mockResolvedValue(mockMember); const result = await getMember({ id: 'member-1' }); expect(api.members.read).toHaveBeenCalledWith(expect.any(Object), { id: 'member-1' }); expect(result).toEqual(mockMember); }); it('should get member by email', async () => { const mockMember = { id: 'member-1', email: 'test@example.com', name: 'John Doe', status: 'free', }; api.members.browse.mockResolvedValue([mockMember]); const result = await getMember({ email: 'test@example.com' }); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ filter: expect.stringContaining('test@example.com'), }), expect.any(Object) ); expect(result).toEqual(mockMember); }); // NOTE: Input validation tests (missing id/email, invalid email format) have been // moved to MCP layer tests. The service layer now relies on Zod schema validation. it('should throw not found error when member not found by ID', async () => { api.members.read.mockRejectedValue({ response: { status: 404 }, message: 'Member not found', }); await expect(getMember({ id: 'non-existent' })).rejects.toThrow(); }); it('should throw not found error when member not found by email', async () => { api.members.browse.mockResolvedValue([]); await expect(getMember({ email: 'notfound@example.com' })).rejects.toThrow( 'Member not found' ); }); it('should prioritize ID over email when both provided', async () => { const mockMember = { id: 'member-1', email: 'test@example.com', status: 'free', }; api.members.read.mockResolvedValue(mockMember); await getMember({ id: 'member-1', email: 'test@example.com' }); // Should use read (ID) instead of browse (email) expect(api.members.read).toHaveBeenCalled(); expect(api.members.browse).not.toHaveBeenCalled(); }); }); describe('searchMembers', () => { it('should search members by query', async () => { const mockMembers = [{ id: 'member-1', email: 'john@example.com', name: 'John Doe' }]; api.members.browse.mockResolvedValue(mockMembers); const result = await searchMembers('john'); expect(api.members.browse).toHaveBeenCalled(); expect(result).toEqual(mockMembers); }); it('should apply default limit of 15', async () => { const mockMembers = []; api.members.browse.mockResolvedValue(mockMembers); await searchMembers('test'); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ limit: 15, }), expect.any(Object) ); }); it('should accept custom limit', async () => { const mockMembers = []; api.members.browse.mockResolvedValue(mockMembers); await searchMembers('test', { limit: 25 }); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ limit: 25, }), expect.any(Object) ); }); // NOTE: Input validation tests (empty query, non-string query, invalid limit) // have been moved to MCP layer tests. The service layer now relies on // Zod schema validation at the MCP tool layer. it('should sanitize query to prevent NQL injection', async () => { const mockMembers = []; api.members.browse.mockResolvedValue(mockMembers); // Query with special NQL characters await searchMembers("test'value"); expect(api.members.browse).toHaveBeenCalledWith( expect.objectContaining({ filter: expect.stringContaining("\\'"), }), expect.any(Object) ); }); it('should return empty array when no matches found', async () => { api.members.browse.mockResolvedValue([]); const result = await searchMembers('nonexistent'); expect(result).toEqual([]); }); it('should handle Ghost API errors', async () => { api.members.browse.mockRejectedValue(new Error('Ghost API Error')); await expect(searchMembers('test')).rejects.toThrow(); }); }); });

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