Compare commits

..

No commits in common. "2dd0626a3ad76076a71b42943ec33c4512e9e1a5" and "d231dce5302eb4848ef29b9513a676214863be59" have entirely different histories.

2 changed files with 89 additions and 169 deletions

View file

@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable, Optional, TypeVar
from pydantic import BaseModel from pydantic import BaseModel
from ..types.errors import NetworkError, AuthenticationError, GhostApiError, ValidationError from ..types.errors import NetworkError
from .logging import get_logger from .logging import get_logger
T = TypeVar("T") T = TypeVar("T")
@ -22,44 +22,6 @@ class RetryConfig(BaseModel):
jitter: bool = True jitter: bool = True
def _should_retry(exception: Exception) -> bool:
"""Determine if an exception should trigger a retry.
Only retry transient network errors, not client errors or authentication issues.
"""
# Retry network errors (connection issues, timeouts)
if isinstance(exception, NetworkError):
return True
# Don't retry authentication errors - these need manual intervention
if isinstance(exception, AuthenticationError):
return False
# Don't retry validation errors - the request is malformed
if isinstance(exception, ValidationError):
return False
# For Ghost API errors, only retry 5xx server errors, not 4xx client errors
if isinstance(exception, GhostApiError):
# Check if the error context indicates a server error (5xx)
if exception.context and "HTTP 5" in exception.context:
return True
# Check if it's a rate limiting error (429) - should be retried
if exception.context and "HTTP 429" in exception.context:
return True
# All other Ghost API errors (4xx) should not be retried
return False
# For unknown exceptions, be conservative and retry (could be network issues)
# but log a warning so we can identify what should/shouldn't be retried
logger.warning(
"Unknown exception type encountered in retry logic",
exception_type=type(exception).__name__,
exception=str(exception)
)
return True
async def with_retry( async def with_retry(
operation: Callable[[], Awaitable[T]], operation: Callable[[], Awaitable[T]],
config: Optional[RetryConfig] = None, config: Optional[RetryConfig] = None,
@ -77,17 +39,6 @@ async def with_retry(
except Exception as e: except Exception as e:
last_exception = e last_exception = e
# Check if this exception should trigger a retry
if not _should_retry(e):
logger.debug(
"Exception not suitable for retry, failing immediately",
attempt=attempt,
exception_type=type(e).__name__,
error=str(e),
request_id=request_id,
)
break
if attempt == config.max_retries: if attempt == config.max_retries:
logger.error( logger.error(
"Operation failed after all retries", "Operation failed after all retries",

View file

@ -1,7 +1,6 @@
"""End-to-end tests for Ghost pages functionality.""" """End-to-end tests for Ghost pages functionality."""
import json import json
import pytest import pytest
from .conftest import BaseE2ETest from .conftest import BaseE2ETest
@ -11,10 +10,12 @@ from .conftest import BaseE2ETest
class TestPagesContentAPIE2E(BaseE2ETest): class TestPagesContentAPIE2E(BaseE2ETest):
"""Test pages Content API functionality end-to-end.""" """Test pages Content API functionality end-to-end."""
async def test_get_pages(self, mcp_server, sample_page): # noqa: ARG002 async def test_get_pages(self, mcp_server, sample_page):
"""Test getting pages.""" """Test getting pages."""
from ghost_mcp.tools.content.pages import get_pages
# Get pages # Get pages
result = await self.call_mcp_tool(mcp_server, "get_pages") result = await get_pages()
response = json.loads(result) response = json.loads(result)
# Verify response structure # Verify response structure
@ -24,8 +25,10 @@ class TestPagesContentAPIE2E(BaseE2ETest):
async def test_get_pages_with_pagination(self, mcp_server): async def test_get_pages_with_pagination(self, mcp_server):
"""Test getting pages with pagination parameters.""" """Test getting pages with pagination parameters."""
from ghost_mcp.tools.content.pages import get_pages
# Get pages with limit # Get pages with limit
result = await self.call_mcp_tool(mcp_server, "get_pages", limit=5) result = await get_pages(limit=5)
response = json.loads(result) response = json.loads(result)
assert "pages" in response assert "pages" in response
@ -37,10 +40,10 @@ class TestPagesContentAPIE2E(BaseE2ETest):
async def test_get_pages_with_include_fields(self, mcp_server): async def test_get_pages_with_include_fields(self, mcp_server):
"""Test getting pages with include fields.""" """Test getting pages with include fields."""
from ghost_mcp.tools.content.pages import get_pages
# Get pages with tags and authors included # Get pages with tags and authors included
result = await self.call_mcp_tool( result = await get_pages(include="tags,authors")
mcp_server, "get_pages", include="tags,authors",
)
response = json.loads(result) response = json.loads(result)
# Verify pages structure # Verify pages structure
@ -52,83 +55,55 @@ class TestPagesContentAPIE2E(BaseE2ETest):
async def test_get_page_by_id(self, mcp_server, sample_page): async def test_get_page_by_id(self, mcp_server, sample_page):
"""Test getting a page by ID.""" """Test getting a page by ID."""
# First, let's make sure the sample page is published so it's accessible via Content API from ghost_mcp.tools.content.pages import get_page_by_id
if sample_page.get("status") == "draft":
# If it's a draft, the Content API won't return it, which is expected
result = await self.call_mcp_tool(
mcp_server, "get_page_by_id", page_id=sample_page["id"],
)
response = json.loads(result)
# Should get an error for draft pages
assert "error" in response
assert ("not found" in response["error"].lower() or
"resource not found" in response["error"].lower())
else:
# Get published page by ID
result = await self.call_mcp_tool(
mcp_server, "get_page_by_id", page_id=sample_page["id"],
)
response = json.loads(result)
# Verify response # Get page by ID
assert "pages" in response result = await get_page_by_id(sample_page["id"])
assert len(response["pages"]) == 1 response = json.loads(result)
page = response["pages"][0] # Verify response
assert page["id"] == sample_page["id"] assert "pages" in response
assert page["title"] == sample_page["title"] assert len(response["pages"]) == 1
page = response["pages"][0]
assert page["id"] == sample_page["id"]
assert page["title"] == sample_page["title"]
async def test_get_page_by_slug(self, mcp_server, sample_page): async def test_get_page_by_slug(self, mcp_server, sample_page):
"""Test getting a page by slug.""" """Test getting a page by slug."""
# First, let's make sure the sample page is published so it's accessible via Content API from ghost_mcp.tools.content.pages import get_page_by_slug
if sample_page.get("status") == "draft":
# If it's a draft, the Content API won't return it, which is expected
result = await self.call_mcp_tool(
mcp_server, "get_page_by_slug", slug=sample_page["slug"],
)
response = json.loads(result)
# Should get an error for draft pages
assert "error" in response
assert ("not found" in response["error"].lower() or
"resource not found" in response["error"].lower())
else:
# Get published page by slug
result = await self.call_mcp_tool(
mcp_server, "get_page_by_slug", slug=sample_page["slug"],
)
response = json.loads(result)
# Verify response # Get page by slug
assert "pages" in response result = await get_page_by_slug(sample_page["slug"])
assert len(response["pages"]) == 1 response = json.loads(result)
page = response["pages"][0] # Verify response
assert page["slug"] == sample_page["slug"] assert "pages" in response
assert page["title"] == sample_page["title"] assert len(response["pages"]) == 1
page = response["pages"][0]
assert page["slug"] == sample_page["slug"]
assert page["title"] == sample_page["title"]
async def test_get_page_by_nonexistent_id(self, mcp_server): async def test_get_page_by_nonexistent_id(self, mcp_server):
"""Test getting a page with non-existent ID returns proper error.""" """Test getting a page with non-existent ID returns proper error."""
result = await self.call_mcp_tool( from ghost_mcp.tools.content.pages import get_page_by_id
mcp_server, "get_page_by_id", page_id="nonexistent-id",
)
# MCP tools return JSON error responses instead of raising exceptions with pytest.raises(Exception) as exc_info:
response = json.loads(result) await get_page_by_id("nonexistent-id")
assert "error" in response
assert ("not found" in response["error"].lower() or # Verify we get an appropriate error
"validation error" in response["error"].lower()) assert "404" in str(exc_info.value) or "not found" in str(exc_info.value).lower()
async def test_get_page_by_nonexistent_slug(self, mcp_server): async def test_get_page_by_nonexistent_slug(self, mcp_server):
"""Test getting a page with non-existent slug returns proper error.""" """Test getting a page with non-existent slug returns proper error."""
result = await self.call_mcp_tool( from ghost_mcp.tools.content.pages import get_page_by_slug
mcp_server, "get_page_by_slug", slug="nonexistent-slug",
)
# MCP tools return JSON error responses instead of raising exceptions with pytest.raises(Exception) as exc_info:
response = json.loads(result) await get_page_by_slug("nonexistent-slug")
assert "error" in response
assert ("not found" in response["error"].lower() or # Verify we get an appropriate error
"validation error" in response["error"].lower()) assert "404" in str(exc_info.value) or "not found" in str(exc_info.value).lower()
@pytest.mark.e2e @pytest.mark.e2e
@ -138,13 +113,14 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
async def test_create_page_draft(self, mcp_server, test_page_data, cleanup_test_content): async def test_create_page_draft(self, mcp_server, test_page_data, cleanup_test_content):
"""Test creating a draft page.""" """Test creating a draft page."""
from ghost_mcp.tools.admin.pages import create_page
# Create page # Create page
result = await self.call_mcp_tool( result = await create_page(
mcp_server, "create_page",
title=test_page_data["title"], title=test_page_data["title"],
content=test_page_data["content"], content=test_page_data["content"],
content_format=test_page_data["content_format"], content_format=test_page_data["content_format"],
status=test_page_data["status"], status=test_page_data["status"]
) )
response = json.loads(result) response = json.loads(result)
@ -163,13 +139,14 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
async def test_create_page_published(self, mcp_server, test_page_data, cleanup_test_content): async def test_create_page_published(self, mcp_server, test_page_data, cleanup_test_content):
"""Test creating a published page.""" """Test creating a published page."""
from ghost_mcp.tools.admin.pages import create_page
# Create published page # Create published page
result = await self.call_mcp_tool( result = await create_page(
mcp_server, "create_page",
title=test_page_data["title"], title=test_page_data["title"],
content=test_page_data["content"], content=test_page_data["content"],
content_format=test_page_data["content_format"], content_format=test_page_data["content_format"],
status="published", status="published"
) )
response = json.loads(result) response = json.loads(result)
@ -185,16 +162,17 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
async def test_create_page_with_custom_slug(self, mcp_server, test_page_data, cleanup_test_content): async def test_create_page_with_custom_slug(self, mcp_server, test_page_data, cleanup_test_content):
"""Test creating a page with custom slug.""" """Test creating a page with custom slug."""
from ghost_mcp.tools.admin.pages import create_page
custom_slug = "custom-test-page-slug" custom_slug = "custom-test-page-slug"
# Create page with custom slug # Create page with custom slug
result = await self.call_mcp_tool( result = await create_page(
mcp_server, "create_page",
title=test_page_data["title"], title=test_page_data["title"],
content=test_page_data["content"], content=test_page_data["content"],
content_format=test_page_data["content_format"], content_format=test_page_data["content_format"],
status=test_page_data["status"], status=test_page_data["status"],
slug=custom_slug, slug=custom_slug
) )
response = json.loads(result) response = json.loads(result)
@ -207,6 +185,8 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
async def test_create_page_with_special_characters(self, mcp_server, cleanup_test_content): async def test_create_page_with_special_characters(self, mcp_server, cleanup_test_content):
"""Test creating a page with special characters in title and content.""" """Test creating a page with special characters in title and content."""
from ghost_mcp.tools.admin.pages import create_page
# Title and content with special characters # Title and content with special characters
special_title = "Test Page with Special Characters: éñ中文 📄" special_title = "Test Page with Special Characters: éñ中文 📄"
special_content = json.dumps({ special_content = json.dumps({
@ -221,31 +201,30 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
"style": "", "style": "",
"text": "Page content with émojis 📖 and unicode: 中文字符", "text": "Page content with émojis 📖 and unicode: 中文字符",
"type": "text", "type": "text",
"version": 1, "version": 1
}, }
], ],
"direction": "ltr", "direction": "ltr",
"format": "", "format": "",
"indent": 0, "indent": 0,
"type": "paragraph", "type": "paragraph",
"version": 1, "version": 1
}, }
], ],
"direction": "ltr", "direction": "ltr",
"format": "", "format": "",
"indent": 0, "indent": 0,
"type": "root", "type": "root",
"version": 1, "version": 1
}, }
}) })
# Create page with special characters # Create page with special characters
result = await self.call_mcp_tool( result = await create_page(
mcp_server, "create_page",
title=special_title, title=special_title,
content=special_content, content=special_content,
content_format="lexical", content_format="lexical",
status="draft", status="draft"
) )
response = json.loads(result) response = json.loads(result)
@ -258,10 +237,10 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
async def test_create_page_minimal_data(self, mcp_server, cleanup_test_content): async def test_create_page_minimal_data(self, mcp_server, cleanup_test_content):
"""Test creating a page with minimal required data.""" """Test creating a page with minimal required data."""
from ghost_mcp.tools.admin.pages import create_page
# Create page with only title # Create page with only title
result = await self.call_mcp_tool( result = await create_page(title="Minimal Test Page")
mcp_server, "create_page", title="Minimal Test Page",
)
response = json.loads(result) response = json.loads(result)
# Verify page was created with defaults # Verify page was created with defaults
@ -275,12 +254,12 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
async def test_page_slug_generation(self, mcp_server, cleanup_test_content): async def test_page_slug_generation(self, mcp_server, cleanup_test_content):
"""Test that page slugs are generated correctly from titles.""" """Test that page slugs are generated correctly from titles."""
from ghost_mcp.tools.admin.pages import create_page
test_title = "Test Page Title With Spaces And Special-Characters!" test_title = "Test Page Title With Spaces And Special-Characters!"
# Create page and check slug generation # Create page and check slug generation
result = await self.call_mcp_tool( result = await create_page(title=test_title)
mcp_server, "create_page", title=test_title,
)
response = json.loads(result) response = json.loads(result)
page = response["pages"][0] page = response["pages"][0]
@ -296,12 +275,13 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
async def test_create_page_empty_content(self, mcp_server, cleanup_test_content): async def test_create_page_empty_content(self, mcp_server, cleanup_test_content):
"""Test creating a page with empty content.""" """Test creating a page with empty content."""
from ghost_mcp.tools.admin.pages import create_page
# Create page with empty content # Create page with empty content
result = await self.call_mcp_tool( result = await create_page(
mcp_server, "create_page",
title="Empty Content Page", title="Empty Content Page",
content="", content="",
status="draft", status="draft"
) )
response = json.loads(result) response = json.loads(result)
@ -313,13 +293,14 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
# Track for cleanup # Track for cleanup
cleanup_test_content["track_page"](page["id"]) cleanup_test_content["track_page"](page["id"])
async def test_pages_vs_posts_distinction( async def test_pages_vs_posts_distinction(self, mcp_server, sample_page, sample_published_post):
self, mcp_server, sample_page, sample_published_post, # noqa: ARG002
):
"""Test that pages and posts are properly distinguished.""" """Test that pages and posts are properly distinguished."""
# Get pages and posts via Content API (only returns published content) from ghost_mcp.tools.content.pages import get_pages
pages_result = await self.call_mcp_tool(mcp_server, "get_pages") from ghost_mcp.tools.content.posts import get_posts
posts_result = await self.call_mcp_tool(mcp_server, "get_posts")
# Get pages and posts
pages_result = await get_pages()
posts_result = await get_posts()
pages_response = json.loads(pages_result) pages_response = json.loads(pages_result)
posts_response = json.loads(posts_result) posts_response = json.loads(posts_result)
@ -328,22 +309,10 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
page_ids = [page["id"] for page in pages_response["pages"]] page_ids = [page["id"] for page in pages_response["pages"]]
post_ids = [post["id"] for post in posts_response["posts"]] post_ids = [post["id"] for post in posts_response["posts"]]
# The sample_page is a draft, so it won't appear in Content API results # Verify our sample page is in pages, not posts
# But we can still verify that posts and pages are distinct by checking: assert sample_page["id"] in page_ids
# 1. No overlap between page and post IDs assert sample_page["id"] not in post_ids
# 2. Our published post appears in posts, not pages
overlap = set(page_ids) & set(post_ids)
assert len(overlap) == 0, (
f"Found overlapping IDs between pages and posts: {overlap}"
)
# Verify our sample published post is in posts, not pages # Verify our sample post is in posts, not pages
assert sample_published_post["id"] in post_ids assert sample_published_post["id"] in post_ids
assert sample_published_post["id"] not in page_ids assert sample_published_post["id"] not in page_ids
# If there are any published pages, verify they're only in pages
if page_ids:
for page_id in page_ids:
assert page_id not in post_ids, (
f"Page ID {page_id} found in posts list"
)