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>
123 lines
4.4 KiB
Python
123 lines
4.4 KiB
Python
"""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"
|