Files
mcphub/tests/plugins/wordpress/test_get_post_fields.py
airano-ir f203ca88de
Some checks failed
Release / Test before release (push) Has been cancelled
Release / Publish to PyPI (push) Has been cancelled
Release / Publish to Docker Hub (push) Has been cancelled
Release / Create GitHub Release (push) Has been cancelled
feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
Three-month batch sync from internal repo (~80 commits) covering Tracks F.5a, F.7e, F.8, F.17, F.18, F.X.

WordPress media pipeline
- Pillow-based optimization, AI image generation (OpenAI / Stability / Replicate / Google Nano Banana / OpenRouter), chunked + resumable uploads, bulk delete/reassign, idempotent retries.

Capability discovery (F.7e)
- Per-site credential probe + adapters for WordPress / WooCommerce / Gitea, tier-fit unions granted ∪ roles, capability badge UI with HTMX partial re-check, install hint in every companion-unreachable error.

Companion plugin overhaul
- Renamed wordpress-plugin/airano-mcp-seo-bridge → wordpress-plugin/airano-mcp-bridge.
- Eight new endpoints: /capabilities, /bulk-meta, /export, /cache-purge, /transient-flush, /site-health, /audit-hook, /upload-and-attach.
- wp.org Plugin Check pass: i18n, WP_Filesystem, scheme allowlist on audit-hook URL.

Other
- Gitea ergonomics (F.17): batch files, tree, search, compare, releases, fork.
- Opportunistic bcrypt upgrade for legacy SHA-256 admin keys (F.8).
- n8n refactor: structured errors, capability probe, missing tools backfilled.
- Idempotency-Key dedup for AI media upload retries; WP client fast-fails on unreachable sites.

Docs
- README + CLAUDE.md drop the fixed "633 tools" claim. The total grows with each release; per-plugin approximations + dashboard-surfaced counts replace it.
- Tools/Tests badges removed in favour of "Plugins: 10".

Deployment
- PyPI mirror chain, optional BUILD_HTTP_PROXY, Alpine→Yandex apk mirror, Debian-slim Plan-B Dockerfile, mirror.gcr.io variant.

CI
- Black + Ruff clean on Python 3.12; pytest tests/ green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 16:25:58 +02:00

154 lines
5.3 KiB
Python

"""F.X.fix #4 — get_post default projection + strict ``fields=`` allow-list.
Regression: ``get_post`` dropped ``featured_media``, ``slug``, and
``featured_media_url`` from its response even when callers asked for
them explicitly via ``fields=``. Fix restores the defaults and makes
the ``fields`` parameter a strict allow-list rather than a subset of a
hard-coded projection.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from plugins.wordpress.handlers.posts import PostsHandler
SAMPLE_POST = {
"id": 42,
"slug": "mcp-hub-launch",
"featured_media": 77,
"title": {"rendered": "MCP Hub Launch"},
"content": {"rendered": "<p>Hello world</p>"},
"excerpt": {"rendered": "<p>short</p>"},
"status": "publish",
"date": "2026-04-17T10:00:00",
"modified": "2026-04-17T10:05:00",
"categories": [3],
"tags": [9],
"link": "https://example.com/mcp-hub-launch",
"_embedded": {
"author": [{"name": "Ali"}],
"wp:featuredmedia": [
{
"id": 77,
"source_url": "https://example.com/wp-content/uploads/hero.webp",
}
],
},
}
@pytest.fixture
def handler_with(post_payload):
client = AsyncMock()
client.get = AsyncMock(return_value=post_payload)
return PostsHandler(client), client
class TestDefaultProjection:
@pytest.mark.asyncio
async def test_default_response_includes_featured_media_slug_and_url(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42)
data = json.loads(raw)
assert data["id"] == 42
assert data["slug"] == "mcp-hub-launch"
assert data["featured_media"] == 77
assert data["featured_media_url"] == "https://example.com/wp-content/uploads/hero.webp"
@pytest.mark.asyncio
async def test_featured_media_url_empty_when_no_embedded_media(self):
post = {**SAMPLE_POST, "featured_media": 0, "_embedded": {"author": [{"name": "x"}]}}
client = AsyncMock()
client.get = AsyncMock(return_value=post)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42)
data = json.loads(raw)
assert data["featured_media"] == 0
assert data["featured_media_url"] == ""
@pytest.mark.asyncio
async def test_default_still_embeds_metadata(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
await handler.get_post(post_id=42)
# ``_embed=true`` must remain in query so featured_media_url can
# be derived from _embedded.wp:featuredmedia.
call = client.get.call_args
params = call.kwargs.get("params") or call.args[1]
assert params.get("_embed") == "true"
class TestStrictFieldsAllowList:
@pytest.mark.asyncio
async def test_fields_limits_to_requested_plus_id(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="slug,featured_media")
data = json.loads(raw)
# id is always preserved for identification.
assert set(data.keys()) == {"id", "slug", "featured_media"}
@pytest.mark.asyncio
async def test_fields_title_only_excludes_slug_by_design(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="title")
data = json.loads(raw)
# Previous behaviour implicitly included slug even when NOT
# requested — strict allow-list must drop it.
assert "slug" not in data
assert "featured_media" not in data
assert set(data.keys()) == {"id", "title"}
@pytest.mark.asyncio
async def test_fields_featured_media_url_honoured(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="featured_media_url")
data = json.loads(raw)
assert set(data.keys()) == {"id", "featured_media_url"}
assert data["featured_media_url"].startswith("https://")
@pytest.mark.asyncio
async def test_unknown_field_name_is_ignored(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
raw = await handler.get_post(post_id=42, fields="slug,does_not_exist")
data = json.loads(raw)
# Unknown name silently dropped; requested known names plus id.
assert set(data.keys()) == {"id", "slug"}
@pytest.mark.asyncio
async def test_fields_passes_wp_fields_query_param(self):
client = AsyncMock()
client.get = AsyncMock(return_value=SAMPLE_POST)
handler = PostsHandler(client)
await handler.get_post(post_id=42, fields="featured_media,slug")
call = client.get.call_args
params = call.kwargs.get("params") or call.args[1]
wp_fields = set((params.get("_fields") or "").split(","))
assert "featured_media" in wp_fields
assert "slug" in wp_fields
assert "id" in wp_fields