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>
438 lines
15 KiB
Python
438 lines
15 KiB
Python
"""F.5a.8.5 — unified upload+metadata+attach+featured via companion.
|
|
|
|
Tests the routing layer in ``_media_core.wp_raw_upload``:
|
|
|
|
* When the cached capability probe advertises ``upload_and_attach`` AND
|
|
the caller supplies metadata/attach intent, we POST to
|
|
``/airano-mcp/v1/upload-and-attach`` and ``_apply_metadata_and_attach``
|
|
becomes a no-op.
|
|
* When the probe doesn't advertise the route, we fall through to the
|
|
legacy 3-step path (upload + PATCH metadata + PATCH featured).
|
|
* When the companion call errors, we fall back to the legacy path and
|
|
metadata gets reapplied via the REST calls.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
|
|
from plugins.wordpress.client import WordPressClient
|
|
from plugins.wordpress.handlers._media_core import (
|
|
_companion_has_upload_and_attach,
|
|
_companion_upload_and_attach,
|
|
wp_raw_upload,
|
|
)
|
|
from plugins.wordpress.handlers._media_security import UploadError
|
|
from plugins.wordpress.handlers.media import _apply_metadata_and_attach
|
|
|
|
_PNG_1x1 = bytes.fromhex(
|
|
"89504e470d0a1a0a0000000d4948445200000001000000010806000000"
|
|
"1f15c4890000000a49444154789c6300010000000500010a2db4b70000"
|
|
"000049454e44ae426082"
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def client():
|
|
return WordPressClient(site_url="https://wp.example.com", username="admin", app_password="xxxx")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Route advertisement check
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestCompanionAdvertiseCheck:
|
|
@pytest.mark.asyncio
|
|
async def test_returns_true_when_route_advertised(self, client):
|
|
with patch(
|
|
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
|
|
AsyncMock(
|
|
return_value={
|
|
"companion_available": True,
|
|
"routes": {"upload_and_attach": True, "upload_chunk": True},
|
|
}
|
|
),
|
|
):
|
|
assert await _companion_has_upload_and_attach(client) is True
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_false_when_route_absent(self, client):
|
|
with patch(
|
|
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
|
|
AsyncMock(
|
|
return_value={
|
|
"companion_available": True,
|
|
"routes": {"upload_chunk": True}, # no upload_and_attach
|
|
}
|
|
),
|
|
):
|
|
assert await _companion_has_upload_and_attach(client) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_false_when_companion_missing(self, client):
|
|
with patch(
|
|
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
|
|
AsyncMock(return_value={"companion_available": False}),
|
|
):
|
|
assert await _companion_has_upload_and_attach(client) is False
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_returns_false_on_cold_cache(self, client):
|
|
with patch(
|
|
"plugins.wordpress.handlers.capabilities.get_cached_capabilities",
|
|
AsyncMock(return_value=None),
|
|
):
|
|
assert await _companion_has_upload_and_attach(client) is False
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# wp_raw_upload routing: unified path when advertised + metadata supplied
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWpRawUploadRouting:
|
|
@pytest.mark.asyncio
|
|
async def test_uses_unified_path_when_advertised_and_metadata_supplied(self, client):
|
|
captured: dict = {}
|
|
|
|
async def _fake_unified(
|
|
_client,
|
|
data,
|
|
*,
|
|
sniffed,
|
|
disposition,
|
|
attach_to_post,
|
|
set_featured,
|
|
title,
|
|
alt_text,
|
|
caption,
|
|
description,
|
|
idempotency_key=None,
|
|
):
|
|
captured["params"] = {
|
|
"attach_to_post": attach_to_post,
|
|
"set_featured": set_featured,
|
|
"title": title,
|
|
"alt_text": alt_text,
|
|
"caption": caption,
|
|
"description": description,
|
|
}
|
|
captured["called"] = True
|
|
return {
|
|
"id": 123,
|
|
"source_url": "https://wp.example.com/out.png",
|
|
"mime_type": "image/png",
|
|
"_upload_and_attach": {
|
|
"attach_to_post": attach_to_post,
|
|
"set_featured": set_featured,
|
|
},
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
|
|
_fake_unified,
|
|
),
|
|
):
|
|
media = await wp_raw_upload(
|
|
client,
|
|
_PNG_1x1,
|
|
filename="hero.png",
|
|
mime_hint="image/png",
|
|
attach_to_post=42,
|
|
set_featured=True,
|
|
title="Hero",
|
|
alt_text="A hero image",
|
|
caption="caption here",
|
|
)
|
|
|
|
assert captured.get("called") is True
|
|
assert captured["params"]["attach_to_post"] == 42
|
|
assert captured["params"]["set_featured"] is True
|
|
assert captured["params"]["title"] == "Hero"
|
|
assert media["_upload_route"] == "companion_unified"
|
|
assert media["id"] == 123
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_unified_when_no_metadata_intent(self, client):
|
|
"""Upload with zero metadata → no reason to use unified route
|
|
even if advertised; fall through to legacy path."""
|
|
unified_called: dict = {"hit": False}
|
|
|
|
async def _fake_unified(*_a, **_kw):
|
|
unified_called["hit"] = True
|
|
return {}
|
|
|
|
legacy_mock = AsyncMock(return_value={"id": 1, "source_url": "x", "mime_type": "image/png"})
|
|
with (
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
|
|
_fake_unified,
|
|
),
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._should_use_companion",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
|
|
):
|
|
# Bare /wp/v2/media session mock.
|
|
resp = AsyncMock()
|
|
resp.status = 201
|
|
resp.text = AsyncMock(return_value="{}")
|
|
resp.json = AsyncMock(
|
|
return_value={"id": 1, "source_url": "x", "mime_type": "image/png"}
|
|
)
|
|
sess = AsyncMock()
|
|
sess.post = lambda *a, **kw: AsyncMock(
|
|
__aenter__=AsyncMock(return_value=resp),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
sess_cls.return_value = AsyncMock(
|
|
__aenter__=AsyncMock(return_value=sess),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
_ = legacy_mock
|
|
|
|
media = await wp_raw_upload(client, _PNG_1x1, filename="x.png", mime_hint="image/png")
|
|
|
|
assert unified_called["hit"] is False
|
|
assert media["_upload_route"] == "rest"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_falls_back_when_unified_errors(self, client):
|
|
"""Unified call raising UploadError → fall through to legacy
|
|
path, and the caller's metadata gets applied via the REST calls."""
|
|
|
|
async def _boom(*_a, **_kw):
|
|
raise UploadError("COMPANION_500", "simulated")
|
|
|
|
with (
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._companion_has_upload_and_attach",
|
|
AsyncMock(return_value=True),
|
|
),
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._companion_upload_and_attach",
|
|
_boom,
|
|
),
|
|
patch(
|
|
"plugins.wordpress.handlers._media_core._should_use_companion",
|
|
AsyncMock(return_value=False),
|
|
),
|
|
patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as sess_cls,
|
|
):
|
|
resp = AsyncMock()
|
|
resp.status = 201
|
|
resp.text = AsyncMock(return_value="{}")
|
|
resp.json = AsyncMock(
|
|
return_value={"id": 2, "source_url": "x", "mime_type": "image/png"}
|
|
)
|
|
sess = AsyncMock()
|
|
sess.post = lambda *a, **kw: AsyncMock(
|
|
__aenter__=AsyncMock(return_value=resp),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
sess_cls.return_value = AsyncMock(
|
|
__aenter__=AsyncMock(return_value=sess),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
media = await wp_raw_upload(
|
|
client,
|
|
_PNG_1x1,
|
|
filename="x.png",
|
|
mime_hint="image/png",
|
|
attach_to_post=7,
|
|
)
|
|
|
|
# Legacy path marker — metadata will be applied via separate calls.
|
|
assert media["_upload_route"] == "rest"
|
|
assert "_upload_and_attach" not in media
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _apply_metadata_and_attach: skip when unified route marker present
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApplyMetadataSkip:
|
|
@pytest.mark.asyncio
|
|
async def test_unified_marker_short_circuits(self, client):
|
|
# Media response carries the unified marker → no REST calls.
|
|
media = {"id": 55, "_upload_route": "companion_unified"}
|
|
update_mock = AsyncMock()
|
|
featured_mock = AsyncMock()
|
|
with (
|
|
patch(
|
|
"plugins.wordpress.handlers.media.wp_update_media_metadata",
|
|
update_mock,
|
|
),
|
|
patch(
|
|
"plugins.wordpress.handlers.media.wp_set_featured_media",
|
|
featured_mock,
|
|
),
|
|
):
|
|
await _apply_metadata_and_attach(
|
|
client,
|
|
media,
|
|
title="t",
|
|
alt_text="a",
|
|
caption="c",
|
|
attach_to_post=7,
|
|
set_featured=True,
|
|
)
|
|
update_mock.assert_not_called()
|
|
featured_mock.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_legacy_marker_runs_metadata_calls(self, client):
|
|
media = {"id": 55, "_upload_route": "rest"}
|
|
update_mock = AsyncMock()
|
|
featured_mock = AsyncMock()
|
|
with (
|
|
patch(
|
|
"plugins.wordpress.handlers.media.wp_update_media_metadata",
|
|
update_mock,
|
|
),
|
|
patch(
|
|
"plugins.wordpress.handlers.media.wp_set_featured_media",
|
|
featured_mock,
|
|
),
|
|
):
|
|
await _apply_metadata_and_attach(
|
|
client,
|
|
media,
|
|
title="t",
|
|
alt_text="a",
|
|
caption="c",
|
|
attach_to_post=7,
|
|
set_featured=True,
|
|
)
|
|
update_mock.assert_awaited_once()
|
|
featured_mock.assert_awaited_once()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _companion_upload_and_attach: query params + error mapping
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _build_fake_session(status: int, body_text: str):
|
|
resp = AsyncMock()
|
|
resp.status = status
|
|
resp.text = AsyncMock(return_value=body_text)
|
|
try:
|
|
_parsed = json.loads(body_text) if body_text else {}
|
|
except json.JSONDecodeError:
|
|
_parsed = {}
|
|
resp.json = AsyncMock(return_value=_parsed)
|
|
captured: dict = {}
|
|
|
|
def _post(url, **kwargs):
|
|
captured["url"] = url
|
|
captured["params"] = kwargs.get("params")
|
|
captured["headers"] = kwargs.get("headers")
|
|
return AsyncMock(
|
|
__aenter__=AsyncMock(return_value=resp),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
|
|
sess = AsyncMock()
|
|
sess.post = _post
|
|
sess_cm = AsyncMock(
|
|
__aenter__=AsyncMock(return_value=sess),
|
|
__aexit__=AsyncMock(return_value=False),
|
|
)
|
|
return sess_cm, captured
|
|
|
|
|
|
class TestCompanionUploadAndAttachClient:
|
|
@pytest.mark.asyncio
|
|
async def test_query_params_encoded_from_metadata(self, client):
|
|
cm, captured = _build_fake_session(
|
|
200,
|
|
json.dumps({"id": 7, "source_url": "x", "mime_type": "image/png"}),
|
|
)
|
|
with patch(
|
|
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
|
|
return_value=cm,
|
|
):
|
|
out = await _companion_upload_and_attach(
|
|
client,
|
|
b"irrelevant",
|
|
sniffed="image/png",
|
|
disposition='attachment; filename="x.png"',
|
|
attach_to_post=42,
|
|
set_featured=True,
|
|
title="T",
|
|
alt_text="A",
|
|
caption="C",
|
|
description="D",
|
|
)
|
|
assert out["id"] == 7
|
|
p = captured["params"]
|
|
assert p["attach_to_post"] == "42"
|
|
assert p["set_featured"] == "true"
|
|
assert p["title"] == "T"
|
|
assert p["alt_text"] == "A"
|
|
assert p["caption"] == "C"
|
|
assert p["description"] == "D"
|
|
assert "airano-mcp/v1/upload-and-attach" in captured["url"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_set_featured_ignored_without_attach_target(self, client):
|
|
cm, captured = _build_fake_session(
|
|
200, json.dumps({"id": 1, "source_url": "x", "mime_type": "image/png"})
|
|
)
|
|
with patch(
|
|
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
|
|
return_value=cm,
|
|
):
|
|
await _companion_upload_and_attach(
|
|
client,
|
|
b"x",
|
|
sniffed="image/png",
|
|
disposition="attachment",
|
|
attach_to_post=None,
|
|
set_featured=True, # ignored when no attach_to_post
|
|
title=None,
|
|
alt_text=None,
|
|
caption=None,
|
|
description=None,
|
|
)
|
|
assert "set_featured" not in captured["params"]
|
|
assert "attach_to_post" not in captured["params"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_500_maps_to_companion_status_error(self, client):
|
|
cm, _ = _build_fake_session(500, "internal error")
|
|
with patch(
|
|
"plugins.wordpress.handlers._media_core.aiohttp.ClientSession",
|
|
return_value=cm,
|
|
):
|
|
with pytest.raises(UploadError) as e:
|
|
await _companion_upload_and_attach(
|
|
client,
|
|
b"x",
|
|
sniffed="image/png",
|
|
disposition="attachment",
|
|
attach_to_post=1,
|
|
set_featured=False,
|
|
title=None,
|
|
alt_text=None,
|
|
caption=None,
|
|
description=None,
|
|
)
|
|
assert e.value.code == "COMPANION_500"
|