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>
This commit is contained in:
153
tests/plugins/wordpress/test_get_post_fields.py
Normal file
153
tests/plugins/wordpress/test_get_post_fields.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user