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:
122
tests/plugins/wordpress/test_seo_roundtrip.py
Normal file
122
tests/plugins/wordpress/test_seo_roundtrip.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""F.X.fix #2 — Rank Math SEO roundtrip: write → read → assert equality.
|
||||
|
||||
Protects against the regression where ``update_post_seo`` wrote
|
||||
``rank_math_seo_title`` (wrong key) while ``get_post_seo`` read back
|
||||
from the same wrong key, so a full write→read cycle looked successful
|
||||
but the WordPress frontend showed no SEO title because Rank Math never
|
||||
saw the value.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.wordpress.handlers.seo import SEOHandler
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_wp_state():
|
||||
"""In-memory meta store shared across fake client get/post calls.
|
||||
|
||||
Rank Math is considered active when at least one canonical
|
||||
rank_math_* key exists in the store, which mirrors the real
|
||||
_check_seo_plugins detection heuristic.
|
||||
"""
|
||||
return {
|
||||
"meta": {
|
||||
# Seeded so _check_seo_plugins detects Rank Math as active.
|
||||
"rank_math_focus_keyword": "",
|
||||
"rank_math_title": "",
|
||||
"rank_math_description": "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_client(fake_wp_state):
|
||||
client = AsyncMock()
|
||||
|
||||
async def _get(endpoint, params=None, use_custom_namespace=False, use_woocommerce=False):
|
||||
if endpoint.startswith("posts/"):
|
||||
return {
|
||||
"id": 42,
|
||||
"title": {"rendered": "Test Post"},
|
||||
"meta": dict(fake_wp_state["meta"]),
|
||||
}
|
||||
if endpoint == "posts":
|
||||
return [
|
||||
{
|
||||
"id": 42,
|
||||
"title": {"rendered": "Test Post"},
|
||||
"meta": dict(fake_wp_state["meta"]),
|
||||
}
|
||||
]
|
||||
raise AssertionError(f"unexpected GET {endpoint}")
|
||||
|
||||
async def _post(endpoint, json_data=None, **_):
|
||||
if endpoint.startswith("posts/") and json_data and "meta" in json_data:
|
||||
fake_wp_state["meta"].update(json_data["meta"])
|
||||
return {"id": 42, "meta": dict(fake_wp_state["meta"])}
|
||||
raise AssertionError(f"unexpected POST {endpoint}")
|
||||
|
||||
client.get = _get
|
||||
client.post = _post
|
||||
return client
|
||||
|
||||
|
||||
class TestRankMathRoundtrip:
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_writes_canonical_rank_math_title(self, fake_client, fake_wp_state):
|
||||
handler = SEOHandler(fake_client)
|
||||
# Force the bridge-status fallback path to detect Rank Math via
|
||||
# meta heuristic (seeded in fixture).
|
||||
raw = await handler.update_post_seo(
|
||||
post_id=42,
|
||||
focus_keyword="mcp hub",
|
||||
seo_title="MCP Hub — unified MCP server",
|
||||
meta_description="Self-hosted MCP platform",
|
||||
)
|
||||
resp = json.loads(raw)
|
||||
assert "rank_math_title" in resp["updated_fields"]
|
||||
# The wrong key must NOT be written — guards against silent
|
||||
# regression of the rank_math_seo_title bug.
|
||||
assert "rank_math_seo_title" not in resp["updated_fields"]
|
||||
assert fake_wp_state["meta"]["rank_math_title"] == "MCP Hub — unified MCP server"
|
||||
assert fake_wp_state["meta"]["rank_math_focus_keyword"] == "mcp hub"
|
||||
assert fake_wp_state["meta"]["rank_math_description"] == "Self-hosted MCP platform"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_reads_canonical_rank_math_title(self, fake_client, fake_wp_state):
|
||||
fake_wp_state["meta"].update(
|
||||
{
|
||||
"rank_math_focus_keyword": "mcp hub",
|
||||
"rank_math_title": "Readback Title",
|
||||
"rank_math_description": "Readback Desc",
|
||||
}
|
||||
)
|
||||
handler = SEOHandler(fake_client)
|
||||
raw = await handler.get_post_seo(post_id=42)
|
||||
data = json.loads(raw)
|
||||
assert data["plugin_detected"] == "rank_math"
|
||||
assert data["seo_title"] == "Readback Title"
|
||||
assert data["focus_keyword"] == "mcp hub"
|
||||
assert data["meta_description"] == "Readback Desc"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_roundtrip_write_then_read_returns_non_empty(self, fake_client, fake_wp_state):
|
||||
handler = SEOHandler(fake_client)
|
||||
await handler.update_post_seo(
|
||||
post_id=42,
|
||||
focus_keyword="kw",
|
||||
seo_title="Title A",
|
||||
meta_description="Desc A",
|
||||
)
|
||||
raw = await handler.get_post_seo(post_id=42)
|
||||
data = json.loads(raw)
|
||||
# Every field we just wrote must come back non-empty.
|
||||
assert data["focus_keyword"] == "kw"
|
||||
assert data["seo_title"] == "Title A"
|
||||
assert data["meta_description"] == "Desc A"
|
||||
Reference in New Issue
Block a user