diff --git a/src/late/mcp/server.py b/src/late/mcp/server.py index 8f535f3..796cab8 100644 --- a/src/late/mcp/server.py +++ b/src/late/mcp/server.py @@ -509,6 +509,36 @@ def profiles_delete(profile_id: str) -> str: # ============================================================================ +def _platform_errors(post: Any) -> list[str]: + """One "Error (platform): message" line per platform that did not publish. + + A post holds one entry in post.platforms[] per target platform, and each + entry tracks its own status and errorMessage. The API never populates + post.metadata["error"], so that is where the real text lives. + + An entry that published can still carry an errorMessage left over from an + earlier attempt, so the entry's own status decides whether it counts, not + whether it has a message. + + Two statuses count, the two that mean "this platform is done and did not + publish": failed and cancelled. Cancelled matters because + account-disconnect cleanup writes the only actionable reason onto that + entry ('Account "X" was disconnected'), so a failed post targeting one + platform that got cancelled would otherwise read "Unknown error". + + The in-progress statuses (pending, processing, uploading) are excluded: + every reset path clears errorMessage, so anything still on one is stale. + """ + errors = [] + for target in post.platforms or []: + if target.status not in ("failed", "cancelled"): + continue + message = (target.errorMessage or "").strip() + if message: + errors.append(f"Error ({target.platform or '?'}): {message}") + return errors + + @tool_def("posts_list") def posts_list(status: str = "", limit: int = 10) -> str: client = _get_client() @@ -532,6 +562,7 @@ def posts_list(status: str = "", limit: int = 10) -> str: status = post.status.value if post.status else "unknown" lines.append(f"- [{status}] {content_preview}") lines.append(f" Platforms: {platforms} | ID: {post.field_id}") + lines.extend(f" {error}" for error in _platform_errors(post)) return "\n".join(lines) @@ -562,8 +593,7 @@ def posts_get(post_id: str) -> str: if hasattr(post, "publishedAt") and post.publishedAt: lines.append(f"Published at: {post.publishedAt}") - if post.metadata and post.metadata.get("error"): - lines.append(f"Error: {post.metadata['error']}") + lines.extend(_platform_errors(post)) return "\n".join(lines) @@ -829,10 +859,12 @@ def posts_list_failed(limit: int = 10) -> str: content = post.content or "" content_preview = content[:50] + "..." if len(content) > 50 else content platforms = ", ".join(t.platform or "?" for t in (post.platforms or [])) - error = post.metadata.get("error", "Unknown error") if post.metadata else "Unknown error" lines.append(f"- {content_preview}") lines.append(f" Platforms: {platforms} | ID: {post.field_id}") - lines.append(f" Error: {error}") + # This view exists to show why posts failed, so it always carries an + # error line even when no platform recorded a message. + errors = _platform_errors(post) or ["Error: Unknown error"] + lines.extend(f" {error}" for error in errors) lines.append("") return "\n".join(lines) diff --git a/tests/test_mcp_post_error_surfacing.py b/tests/test_mcp_post_error_surfacing.py new file mode 100644 index 0000000..61526a3 --- /dev/null +++ b/tests/test_mcp_post_error_surfacing.py @@ -0,0 +1,356 @@ +""" +Regression tests for the MCP post-reading tools (Crisp session_6e4c63b7). + +An integrator reported two defects in the same ticket: + +1. The failed-post views read the error from ``post.metadata["error"]``, a key + the API never populates, and fell back to the literal ``"Unknown error"``. + The real text lives per platform in ``PlatformTarget.errorMessage``. + ``posts_list`` did not render an error at all, not even the fallback. +2. ``posts_get``/``posts_list`` raised ``1 validation error for + PostGetResponse`` on TikTok posts that published fine, because the API + emits ``platformPostUrl: ""`` when TikTok confirms a publish without a + numeric video id, and the spec declared the field as a URI. Fixed in the + OpenAPI spec, so these tests guard the regenerated model against a + regression in it. + +The payloads below are the real shapes from the reporting account +(userId 6a4fe17d8adb6036cd5c37c6). + +A post holds one entry in ``platforms[]`` per target platform. The entries +below that already published but still carry a stale ``errorMessage`` are +deliberate: production posts do carry that combination, and a platform that +published must never be reported as an error. + +Cancelled platforms are covered too. Account-disconnect cleanup writes the +only actionable reason onto that entry, so a failed post targeting a single +platform that got cancelled used to read "Unknown error" while the document +held the answer. +""" + +from __future__ import annotations + +import json +from typing import Any + +import httpx +import pytest + +import late.client.base as client_base +from late import Late +from late.mcp import server as mcp_server + +QUOTA_ERROR = "Daily active user quota reached." + +# Failed TikTok post. metadata carries billing bookkeeping and no "error" key, +# exactly as the API returns it. +FAILED_POST: dict[str, Any] = { + "_id": "6a8392cdc6abe639aadbcc21", + "content": "Free Content DNA report - crezio.ai/report", + "status": "failed", + "metadata": {"usageCounted": True, "usageRefunded": True}, + "platforms": [ + { + "platform": "tiktok", + "accountId": "6a7e062a77555aae017c9617", + "status": "failed", + "errorMessage": QUOTA_ERROR, + "errorCategory": "user_abuse", + "errorSource": "platform", + } + ], +} + +# Healthy published post: nothing here may produce an error line. +PUBLISHED_POST: dict[str, Any] = { + "_id": "6a841714e25e28de95dd35d3", + "content": "Nothing goes viral. Somebody built it to.", + "status": "published", + "platforms": [ + { + "platform": "tiktok", + "accountId": "6a7e062a77555aae017c9617", + "status": "published", + "platformPostId": "7675444697107614990", + "platformPostUrl": "https://www.tiktok.com/@crezio.ai/video/7675444697107614990", + } + ], +} + +# Published TikTok post whose permalink could not be built: TikTok returned a +# publish id instead of a numeric video id, so the API sends an empty string. +EMPTY_URL_POST: dict[str, Any] = { + "_id": "6a7e23169598cfb3119bb578", + "content": "Nothing goes viral. Somebody built it to.", + "status": "published", + "platforms": [ + { + "platform": "tiktok", + "accountId": "6a7e062a77555aae017c9617", + "status": "published", + "platformPostId": "p_pub_url~v2.7673609262345750542", + "platformPostUrl": "", + } + ], +} + +# Partial failure: one platform published (carrying an errorMessage left over +# from an earlier attempt) and one platform genuinely failed. +MIXED_POST: dict[str, Any] = { + "_id": "691a85709ed078ff2f35bd16", + "content": "Cross-posted launch announcement", + "status": "failed", + "platforms": [ + { + "platform": "instagram", + "accountId": "a1", + "status": "published", + "errorMessage": "Publishing failed due to timeout or max retries reached", + "platformPostUrl": "https://www.instagram.com/reel/ABC123xyz/", + }, + { + "platform": "linkedin", + "accountId": "a2", + "status": "failed", + "errorMessage": "Publishing failed due to timeout or max retries reached", + }, + ], +} + +# Failed post whose only platform was cancelled by account-disconnect cleanup. +# That entry carries the one actionable reason, so skipping it leaves the whole +# post reading "Unknown error". +CANCELLED_POST: dict[str, Any] = { + "_id": "6a74056fa22ac1afb451b7da", + "content": "Weekly roundup", + "status": "failed", + "platforms": [ + { + "platform": "linkedin", + "accountId": "a3", + "status": "cancelled", + "errorMessage": 'Account "Edward Hollis" was disconnected', + "errorCategory": "account_issue", + "errorSource": "user", + } + ], +} + +# A cancelled platform next to failed ones: the disconnect is the actionable +# line, the timeouts are the generic ones. Neither may displace the other. +MIXED_CANCELLED_POST: dict[str, Any] = { + "_id": "698f117bdc985b51e808e3e4", + "content": "Campaign launch", + "status": "failed", + "platforms": [ + { + "platform": "instagram", + "accountId": "a4", + "status": "failed", + "errorMessage": "Publishing failed due to timeout or max retries reached", + }, + { + "platform": "facebook", + "accountId": "a5", + "status": "cancelled", + "errorMessage": 'Account "The clam Qalb" was disconnected', + }, + ], +} + +# A draft still holding a cancelled platform: retry never resets a cancelled +# entry, so unfiltered posts_list renders this one too. Deliberate, pinned here. +DRAFT_WITH_CANCELLED_PLATFORM: dict[str, Any] = { + "_id": "69047ddcbb4db2cc1794478d", + "content": "Unfinished draft", + "status": "draft", + "platforms": [ + {"platform": "youtube", "accountId": "a6", "status": "pending"}, + { + "platform": "instagram", + "accountId": "a7", + "status": "cancelled", + "errorMessage": 'Account "Cody bailey" was disconnected', + }, + ], +} + +POSTS_BY_ID = { + p["_id"]: p + for p in ( + FAILED_POST, + PUBLISHED_POST, + EMPTY_URL_POST, + MIXED_POST, + CANCELLED_POST, + MIXED_CANCELLED_POST, + DRAFT_WITH_CANCELLED_PLATFORM, + ) +} + + +class FakeZernioAPI: + """In-memory Zernio API serving the payloads above.""" + + def handler(self, request: httpx.Request) -> httpx.Response: + path = request.url.path + + if request.method == "GET" and path == "/api/v1/posts": + status = request.url.params.get("status") + posts = [p for p in POSTS_BY_ID.values() if not status or p["status"] == status] + return self._json({"posts": posts, "pagination": {}}) + + if request.method == "GET" and path.startswith("/api/v1/posts/"): + post = POSTS_BY_ID.get(path.rsplit("/", 1)[-1]) + if post is not None: + return self._json({"post": post}) + + return httpx.Response(404, json={"error": f"unexpected: {path}"}) + + @staticmethod + def _json(body: dict[str, Any]) -> httpx.Response: + return httpx.Response( + 200, + content=json.dumps(body), + headers={"Content-Type": "application/json"}, + ) + + +@pytest.fixture() +def fake_api(monkeypatch: pytest.MonkeyPatch) -> FakeZernioAPI: + """Route all SDK HTTP traffic to the fake API and wire up the MCP client.""" + api = FakeZernioAPI() + real_client = httpx.Client + + def patched_client(**kwargs: Any) -> httpx.Client: + kwargs["transport"] = httpx.MockTransport(api.handler) + return real_client(**kwargs) + + monkeypatch.setattr(client_base.httpx, "Client", patched_client) + monkeypatch.setattr(mcp_server, "_get_client", lambda: Late(api_key="test-key")) + return api + + +@pytest.mark.usefixtures("fake_api") +class TestEmptyPermalinkIsAccepted: + """Defect 2: an empty permalink must not make the post unreadable.""" + + def test_posts_get_returns_the_post(self) -> None: + result = mcp_server.posts_get("6a7e23169598cfb3119bb578") + + assert "validation error" not in result + assert "6a7e23169598cfb3119bb578" in result + assert "published" in result + + def test_posts_list_returns_the_post(self) -> None: + result = mcp_server.posts_list() + + assert "validation error" not in result + assert "6a7e23169598cfb3119bb578" in result + + def test_model_accepts_empty_platform_post_url(self) -> None: + """The generated model itself, so a spec regression fails here first.""" + from late.models import PlatformTarget + + target = PlatformTarget.model_validate( + {"platform": "tiktok", "status": "published", "platformPostUrl": ""} + ) + + assert target.platformPostUrl == "" + + def test_a_real_permalink_comes_back_exactly_as_the_api_sent_it(self) -> None: + """No normalization: AnyUrl used to rewrite the value on the way in. + + A bare-origin URL is the case that shows it. AnyUrl parses + "https://example.com" and renders it back as "https://example.com/", + so a consumer comparing the permalink against the platform's own copy + got a mismatch. Plain str hands back what the API sent. + """ + from late.models import PlatformTarget + + url = "https://example.com" + target = PlatformTarget.model_validate( + {"platform": "tiktok", "status": "published", "platformPostUrl": url} + ) + + assert target.platformPostUrl == url + + +@pytest.mark.usefixtures("fake_api") +class TestPlatformErrorIsSurfaced: + """Defect 1: the real error text lives per platform, not in metadata.""" + + def test_posts_get_shows_the_platform_error(self) -> None: + result = mcp_server.posts_get("6a8392cdc6abe639aadbcc21") + + assert QUOTA_ERROR in result + assert "Unknown error" not in result + + def test_posts_list_failed_shows_the_platform_error(self) -> None: + result = mcp_server.posts_list_failed() + + assert QUOTA_ERROR in result + assert "Unknown error" not in result + + def test_posts_list_shows_the_platform_error(self) -> None: + """posts_list rendered no error at all, not even the fallback.""" + result = mcp_server.posts_list(status="failed") + + assert QUOTA_ERROR in result + + def test_posts_list_is_quiet_for_healthy_posts(self) -> None: + result = mcp_server.posts_list(status="published") + + assert "Error" not in result + + def test_error_is_attributed_to_the_failing_platform(self) -> None: + """A platform that published must not be reported as an error.""" + result = mcp_server.posts_get("691a85709ed078ff2f35bd16") + + error_lines = [line for line in result.splitlines() if "Error" in line] + assert error_lines, "expected the failed platform to be reported" + joined = "\n".join(error_lines) + assert "linkedin" in joined + assert "instagram" not in joined + + +@pytest.mark.usefixtures("fake_api") +class TestCancelledPlatformIsSurfaced: + """Cancelled is the other terminal non-success status. + + Account-disconnect cleanup writes the only actionable reason onto the + cancelled entry, so a post targeting a single platform that got cancelled + used to read "Unknown error" while the document held the answer. + """ + + def test_posts_list_failed_shows_the_cancelled_reason(self) -> None: + result = mcp_server.posts_list_failed() + + assert "Edward Hollis" in result + assert "Unknown error" not in result + + def test_posts_get_shows_the_cancelled_reason(self) -> None: + result = mcp_server.posts_get("6a74056fa22ac1afb451b7da") + + assert "Edward Hollis" in result + assert "linkedin" in result + + def test_cancelled_reason_does_not_displace_the_failed_ones(self) -> None: + result = mcp_server.posts_get("698f117bdc985b51e808e3e4") + + assert "The clam Qalb" in result + assert "timeout or max retries reached" in result + + def test_draft_keeps_rendering_its_cancelled_platform(self) -> None: + """Retry never resets a cancelled entry, so drafts carry them. Declared.""" + result = mcp_server.posts_list(status="draft") + + assert "Cody bailey" in result + + def test_in_progress_platforms_stay_silent(self) -> None: + """The draft's pending youtube entry must not produce an error line.""" + result = mcp_server.posts_get("69047ddcbb4db2cc1794478d") + + error_lines = [line for line in result.splitlines() if "Error" in line] + assert len(error_lines) == 1 + assert "youtube" not in error_lines[0]