Compare commits
2 commits
d231dce530
...
2dd0626a3a
| Author | SHA1 | Date | |
|---|---|---|---|
| 2dd0626a3a | |||
| f5d7a5ab5b |
2 changed files with 169 additions and 89 deletions
|
|
@ -6,7 +6,7 @@ from typing import Any, Awaitable, Callable, Optional, TypeVar
|
|||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..types.errors import NetworkError
|
||||
from ..types.errors import NetworkError, AuthenticationError, GhostApiError, ValidationError
|
||||
from .logging import get_logger
|
||||
|
||||
T = TypeVar("T")
|
||||
|
|
@ -22,6 +22,44 @@ class RetryConfig(BaseModel):
|
|||
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(
|
||||
operation: Callable[[], Awaitable[T]],
|
||||
config: Optional[RetryConfig] = None,
|
||||
|
|
@ -39,6 +77,17 @@ async def with_retry(
|
|||
except Exception as 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:
|
||||
logger.error(
|
||||
"Operation failed after all retries",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"""End-to-end tests for Ghost pages functionality."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from .conftest import BaseE2ETest
|
||||
|
|
@ -10,12 +11,10 @@ from .conftest import BaseE2ETest
|
|||
class TestPagesContentAPIE2E(BaseE2ETest):
|
||||
"""Test pages Content API functionality end-to-end."""
|
||||
|
||||
async def test_get_pages(self, mcp_server, sample_page):
|
||||
async def test_get_pages(self, mcp_server, sample_page): # noqa: ARG002
|
||||
"""Test getting pages."""
|
||||
from ghost_mcp.tools.content.pages import get_pages
|
||||
|
||||
# Get pages
|
||||
result = await get_pages()
|
||||
result = await self.call_mcp_tool(mcp_server, "get_pages")
|
||||
response = json.loads(result)
|
||||
|
||||
# Verify response structure
|
||||
|
|
@ -25,10 +24,8 @@ class TestPagesContentAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_get_pages_with_pagination(self, mcp_server):
|
||||
"""Test getting pages with pagination parameters."""
|
||||
from ghost_mcp.tools.content.pages import get_pages
|
||||
|
||||
# Get pages with limit
|
||||
result = await get_pages(limit=5)
|
||||
result = await self.call_mcp_tool(mcp_server, "get_pages", limit=5)
|
||||
response = json.loads(result)
|
||||
|
||||
assert "pages" in response
|
||||
|
|
@ -40,10 +37,10 @@ class TestPagesContentAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_get_pages_with_include_fields(self, mcp_server):
|
||||
"""Test getting pages with include fields."""
|
||||
from ghost_mcp.tools.content.pages import get_pages
|
||||
|
||||
# Get pages with tags and authors included
|
||||
result = await get_pages(include="tags,authors")
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "get_pages", include="tags,authors",
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
# Verify pages structure
|
||||
|
|
@ -55,55 +52,83 @@ class TestPagesContentAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_get_page_by_id(self, mcp_server, sample_page):
|
||||
"""Test getting a page by ID."""
|
||||
from ghost_mcp.tools.content.pages import get_page_by_id
|
||||
# First, let's make sure the sample page is published so it's accessible via Content API
|
||||
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)
|
||||
|
||||
# Get page by ID
|
||||
result = await get_page_by_id(sample_page["id"])
|
||||
response = json.loads(result)
|
||||
# Verify response
|
||||
assert "pages" in response
|
||||
assert len(response["pages"]) == 1
|
||||
|
||||
# Verify response
|
||||
assert "pages" in response
|
||||
assert len(response["pages"]) == 1
|
||||
|
||||
page = response["pages"][0]
|
||||
assert page["id"] == sample_page["id"]
|
||||
assert page["title"] == sample_page["title"]
|
||||
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):
|
||||
"""Test getting a page by slug."""
|
||||
from ghost_mcp.tools.content.pages import get_page_by_slug
|
||||
# First, let's make sure the sample page is published so it's accessible via Content API
|
||||
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)
|
||||
|
||||
# Get page by slug
|
||||
result = await get_page_by_slug(sample_page["slug"])
|
||||
response = json.loads(result)
|
||||
# Verify response
|
||||
assert "pages" in response
|
||||
assert len(response["pages"]) == 1
|
||||
|
||||
# Verify response
|
||||
assert "pages" in response
|
||||
assert len(response["pages"]) == 1
|
||||
|
||||
page = response["pages"][0]
|
||||
assert page["slug"] == sample_page["slug"]
|
||||
assert page["title"] == sample_page["title"]
|
||||
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):
|
||||
"""Test getting a page with non-existent ID returns proper error."""
|
||||
from ghost_mcp.tools.content.pages import get_page_by_id
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "get_page_by_id", page_id="nonexistent-id",
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await get_page_by_id("nonexistent-id")
|
||||
|
||||
# Verify we get an appropriate error
|
||||
assert "404" in str(exc_info.value) or "not found" in str(exc_info.value).lower()
|
||||
# MCP tools return JSON error responses instead of raising exceptions
|
||||
response = json.loads(result)
|
||||
assert "error" in response
|
||||
assert ("not found" in response["error"].lower() or
|
||||
"validation error" in response["error"].lower())
|
||||
|
||||
async def test_get_page_by_nonexistent_slug(self, mcp_server):
|
||||
"""Test getting a page with non-existent slug returns proper error."""
|
||||
from ghost_mcp.tools.content.pages import get_page_by_slug
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "get_page_by_slug", slug="nonexistent-slug",
|
||||
)
|
||||
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
await get_page_by_slug("nonexistent-slug")
|
||||
|
||||
# Verify we get an appropriate error
|
||||
assert "404" in str(exc_info.value) or "not found" in str(exc_info.value).lower()
|
||||
# MCP tools return JSON error responses instead of raising exceptions
|
||||
response = json.loads(result)
|
||||
assert "error" in response
|
||||
assert ("not found" in response["error"].lower() or
|
||||
"validation error" in response["error"].lower())
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
|
|
@ -113,14 +138,13 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_create_page_draft(self, mcp_server, test_page_data, cleanup_test_content):
|
||||
"""Test creating a draft page."""
|
||||
from ghost_mcp.tools.admin.pages import create_page
|
||||
|
||||
# Create page
|
||||
result = await create_page(
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "create_page",
|
||||
title=test_page_data["title"],
|
||||
content=test_page_data["content"],
|
||||
content_format=test_page_data["content_format"],
|
||||
status=test_page_data["status"]
|
||||
status=test_page_data["status"],
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
|
|
@ -139,14 +163,13 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_create_page_published(self, mcp_server, test_page_data, cleanup_test_content):
|
||||
"""Test creating a published page."""
|
||||
from ghost_mcp.tools.admin.pages import create_page
|
||||
|
||||
# Create published page
|
||||
result = await create_page(
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "create_page",
|
||||
title=test_page_data["title"],
|
||||
content=test_page_data["content"],
|
||||
content_format=test_page_data["content_format"],
|
||||
status="published"
|
||||
status="published",
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
|
|
@ -162,17 +185,16 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_create_page_with_custom_slug(self, mcp_server, test_page_data, cleanup_test_content):
|
||||
"""Test creating a page with custom slug."""
|
||||
from ghost_mcp.tools.admin.pages import create_page
|
||||
|
||||
custom_slug = "custom-test-page-slug"
|
||||
|
||||
# Create page with custom slug
|
||||
result = await create_page(
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "create_page",
|
||||
title=test_page_data["title"],
|
||||
content=test_page_data["content"],
|
||||
content_format=test_page_data["content_format"],
|
||||
status=test_page_data["status"],
|
||||
slug=custom_slug
|
||||
slug=custom_slug,
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
|
|
@ -185,8 +207,6 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
|
||||
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."""
|
||||
from ghost_mcp.tools.admin.pages import create_page
|
||||
|
||||
# Title and content with special characters
|
||||
special_title = "Test Page with Special Characters: éñ中文 📄"
|
||||
special_content = json.dumps({
|
||||
|
|
@ -201,30 +221,31 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
"style": "",
|
||||
"text": "Page content with émojis 📖 and unicode: 中文字符",
|
||||
"type": "text",
|
||||
"version": 1
|
||||
}
|
||||
"version": 1,
|
||||
},
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "paragraph",
|
||||
"version": 1
|
||||
}
|
||||
"version": 1,
|
||||
},
|
||||
],
|
||||
"direction": "ltr",
|
||||
"format": "",
|
||||
"indent": 0,
|
||||
"type": "root",
|
||||
"version": 1
|
||||
}
|
||||
"version": 1,
|
||||
},
|
||||
})
|
||||
|
||||
# Create page with special characters
|
||||
result = await create_page(
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "create_page",
|
||||
title=special_title,
|
||||
content=special_content,
|
||||
content_format="lexical",
|
||||
status="draft"
|
||||
status="draft",
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
|
|
@ -237,10 +258,10 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_create_page_minimal_data(self, mcp_server, cleanup_test_content):
|
||||
"""Test creating a page with minimal required data."""
|
||||
from ghost_mcp.tools.admin.pages import create_page
|
||||
|
||||
# Create page with only title
|
||||
result = await create_page(title="Minimal Test Page")
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "create_page", title="Minimal Test Page",
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
# Verify page was created with defaults
|
||||
|
|
@ -254,12 +275,12 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_page_slug_generation(self, mcp_server, cleanup_test_content):
|
||||
"""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!"
|
||||
|
||||
# Create page and check slug generation
|
||||
result = await create_page(title=test_title)
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "create_page", title=test_title,
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
page = response["pages"][0]
|
||||
|
|
@ -275,13 +296,12 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
|
||||
async def test_create_page_empty_content(self, mcp_server, cleanup_test_content):
|
||||
"""Test creating a page with empty content."""
|
||||
from ghost_mcp.tools.admin.pages import create_page
|
||||
|
||||
# Create page with empty content
|
||||
result = await create_page(
|
||||
result = await self.call_mcp_tool(
|
||||
mcp_server, "create_page",
|
||||
title="Empty Content Page",
|
||||
content="",
|
||||
status="draft"
|
||||
status="draft",
|
||||
)
|
||||
response = json.loads(result)
|
||||
|
||||
|
|
@ -293,14 +313,13 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
# Track for cleanup
|
||||
cleanup_test_content["track_page"](page["id"])
|
||||
|
||||
async def test_pages_vs_posts_distinction(self, mcp_server, sample_page, sample_published_post):
|
||||
async def test_pages_vs_posts_distinction(
|
||||
self, mcp_server, sample_page, sample_published_post, # noqa: ARG002
|
||||
):
|
||||
"""Test that pages and posts are properly distinguished."""
|
||||
from ghost_mcp.tools.content.pages import get_pages
|
||||
from ghost_mcp.tools.content.posts import get_posts
|
||||
|
||||
# Get pages and posts
|
||||
pages_result = await get_pages()
|
||||
posts_result = await get_posts()
|
||||
# Get pages and posts via Content API (only returns published content)
|
||||
pages_result = await self.call_mcp_tool(mcp_server, "get_pages")
|
||||
posts_result = await self.call_mcp_tool(mcp_server, "get_posts")
|
||||
|
||||
pages_response = json.loads(pages_result)
|
||||
posts_response = json.loads(posts_result)
|
||||
|
|
@ -309,10 +328,22 @@ class TestPagesAdminAPIE2E(BaseE2ETest):
|
|||
page_ids = [page["id"] for page in pages_response["pages"]]
|
||||
post_ids = [post["id"] for post in posts_response["posts"]]
|
||||
|
||||
# Verify our sample page is in pages, not posts
|
||||
assert sample_page["id"] in page_ids
|
||||
assert sample_page["id"] not in post_ids
|
||||
# The sample_page is a draft, so it won't appear in Content API results
|
||||
# But we can still verify that posts and pages are distinct by checking:
|
||||
# 1. No overlap between page and 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 post is in posts, not pages
|
||||
# Verify our sample published post is in posts, not pages
|
||||
assert sample_published_post["id"] in post_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"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue