feat(v3.12.0): media pipeline, AI image generation, capability probe, companion v2.9.0
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

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:
2026-04-25 16:25:58 +02:00
parent 788439e377
commit f203ca88de
140 changed files with 23802 additions and 2253 deletions

View File

@@ -0,0 +1,264 @@
"""Tests for F.5a.5 chunked media upload (session store + WP handler)."""
from __future__ import annotations
import asyncio
import base64 as _b64
import hashlib
import json
from datetime import UTC, datetime, timedelta
from pathlib import Path
from unittest.mock import AsyncMock, patch
import pytest
from core.database import Database
from core.upload_sessions import (
CleanupTask,
UploadSessionError,
UploadSessionStore,
make_session_id,
set_upload_session_store,
)
from plugins.wordpress.client import WordPressClient
from plugins.wordpress.handlers.media_chunked import MediaChunkedHandler
_PNG_1x1 = _b64.b64decode(
b"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
def _client() -> WordPressClient:
return WordPressClient(site_url="https://wp.example.com", username="u", app_password="p")
@pytest.fixture
async def db(tmp_path: Path):
d = Database(str(tmp_path / "test.db"))
await d.initialize()
try:
yield d
finally:
await d.close()
@pytest.fixture
async def store(tmp_path: Path, db: Database) -> UploadSessionStore:
s = UploadSessionStore(db=db, spill_dir=tmp_path / "spill")
set_upload_session_store(s)
try:
yield s
finally:
set_upload_session_store(None)
@pytest.fixture
def handler(store: UploadSessionStore) -> MediaChunkedHandler:
return MediaChunkedHandler(_client(), user_id="alice", store=store)
def _wp_response_mock(wp_response: dict):
mock_resp = AsyncMock()
mock_resp.status = 201
mock_resp.text = AsyncMock(return_value=json.dumps(wp_response))
mock_resp.json = AsyncMock(return_value=wp_response)
mock_sess = AsyncMock()
mock_sess.post = lambda *a, **kw: AsyncMock(
__aenter__=AsyncMock(return_value=mock_resp),
__aexit__=AsyncMock(return_value=False),
)
cls_mock = AsyncMock(
__aenter__=AsyncMock(return_value=mock_sess),
__aexit__=AsyncMock(return_value=False),
)
return cls_mock
class TestSessionStoreBasics:
@pytest.mark.asyncio
async def test_deterministic_session_id(self):
a = make_session_id("u1", "a.bin", 100, None, None)
b = make_session_id("u1", "a.bin", 100, None, None)
c = make_session_id("u2", "a.bin", 100, None, None)
assert a == b
assert a != c
@pytest.mark.asyncio
async def test_start_creates_row_and_spill(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
assert sess.status == "open"
assert sess.spill_path.exists()
assert sess.received_bytes == 0
@pytest.mark.asyncio
async def test_start_is_idempotent_for_same_tuple(self, store: UploadSessionStore):
s1 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
s2 = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
assert s1.id == s2.id
@pytest.mark.asyncio
async def test_hard_cap_rejects(self, store: UploadSessionStore):
store.max_session_bytes = 1024
with pytest.raises(UploadSessionError) as e:
await store.start(user_id="alice", filename="big.bin", total_bytes=2048)
assert e.value.code == "SESSION_TOO_LARGE"
class TestAppendAndFinalize:
@pytest.mark.asyncio
async def test_round_trip_two_chunks(self, store: UploadSessionStore):
payload = b"abcdefghij" # 10 bytes
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
await store.append_chunk(sess.id, 0, payload[:6])
await store.append_chunk(sess.id, 1, payload[6:])
sess2, data = await store.finalize(sess.id)
assert data == payload
assert not sess2.spill_path.exists()
@pytest.mark.asyncio
async def test_chunk_out_of_order(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
await store.append_chunk(sess.id, 0, b"abcde")
with pytest.raises(UploadSessionError) as e:
await store.append_chunk(sess.id, 2, b"fgh")
assert e.value.code == "CHUNK_ORDER"
@pytest.mark.asyncio
async def test_chunk_overflow(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=5)
with pytest.raises(UploadSessionError) as e:
await store.append_chunk(sess.id, 0, b"toolongdata")
assert e.value.code == "CHUNK_OVERFLOW"
@pytest.mark.asyncio
async def test_finalize_sha_mismatch_keeps_session(self, store: UploadSessionStore):
payload = b"hello world"
wrong = hashlib.sha256(b"other").hexdigest()
sess = await store.start(
user_id="alice",
filename="f.bin",
total_bytes=len(payload),
sha256=wrong,
)
await store.append_chunk(sess.id, 0, payload)
with pytest.raises(UploadSessionError) as e:
await store.finalize(sess.id)
assert e.value.code == "CHECKSUM_MISMATCH"
# Session still present — spill file still on disk
again = await store.get(sess.id)
assert again is not None
assert again.spill_path.exists()
@pytest.mark.asyncio
async def test_finalize_sha_match_passes(self, store: UploadSessionStore):
payload = b"hello world"
digest = hashlib.sha256(payload).hexdigest()
sess = await store.start(
user_id="alice",
filename="f.bin",
total_bytes=len(payload),
sha256=digest,
)
await store.append_chunk(sess.id, 0, payload)
_, data = await store.finalize(sess.id)
assert data == payload
@pytest.mark.asyncio
async def test_abort_removes_spill(self, store: UploadSessionStore):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
spill = sess.spill_path
assert spill.exists()
assert await store.abort(sess.id) is True
assert not spill.exists()
assert await store.get(sess.id) is None
class TestQuotaAndCleanup:
@pytest.mark.asyncio
async def test_quota_rejects_11th_session(self, store: UploadSessionStore):
store.max_concurrent_per_user = 10
ids = set()
for i in range(10):
s = await store.start(user_id="bob", filename=f"f{i}.bin", total_bytes=10)
ids.add(s.id)
assert len(ids) == 10
with pytest.raises(UploadSessionError) as e:
await store.start(user_id="bob", filename="f10.bin", total_bytes=10)
assert e.value.code == "QUOTA_EXCEEDED"
@pytest.mark.asyncio
async def test_cleanup_reaps_expired(self, store: UploadSessionStore, db: Database):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
spill = sess.spill_path
# Force-expire via DB update
past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
reaped = await store.cleanup_expired()
assert reaped == 1
assert not spill.exists()
assert await store.get(sess.id) is None
@pytest.mark.asyncio
async def test_cleanup_task_runs(self, store: UploadSessionStore, db: Database):
sess = await store.start(user_id="alice", filename="f.bin", total_bytes=10)
past = (datetime.now(UTC) - timedelta(hours=2)).isoformat()
await db.execute("UPDATE upload_sessions SET expires_at = ? WHERE id = ?", (past, sess.id))
task = CleanupTask(store=store, interval_seconds=60)
await task.start()
# Give the loop a tick to run its first iteration.
for _ in range(20):
if await store.get(sess.id) is None:
break
await asyncio.sleep(0.05)
await task.stop()
assert await store.get(sess.id) is None
class TestHandlerIntegration:
@pytest.mark.asyncio
async def test_handler_round_trip_calls_wp_once(self, handler: MediaChunkedHandler):
payload = _PNG_1x1
# Start
start_out = json.loads(
await handler.upload_media_chunked_start(filename="photo.png", total_bytes=len(payload))
)
sid = start_out["session_id"]
half = len(payload) // 2
c0 = _b64.b64encode(payload[:half]).decode()
c1 = _b64.b64encode(payload[half:]).decode()
await handler.upload_media_chunked_chunk(sid, 0, c0)
await handler.upload_media_chunked_chunk(sid, 1, c1)
wp_response = {
"id": 999,
"title": {"rendered": "photo"},
"source_url": "https://wp.example.com/photo.png",
"mime_type": "image/png",
"media_type": "image",
}
with patch("plugins.wordpress.handlers._media_core.aiohttp.ClientSession") as mock_cls:
mock_cls.return_value = _wp_response_mock(wp_response)
out = json.loads(await handler.upload_media_chunked_finish(session_id=sid))
# No metadata/attach args → only the raw-upload ClientSession() call
assert mock_cls.call_count == 1
assert out["id"] == 999
assert out["source"] == "chunked"
@pytest.mark.asyncio
async def test_handler_abort(self, handler: MediaChunkedHandler):
start_out = json.loads(
await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
)
sid = start_out["session_id"]
out = json.loads(await handler.upload_media_chunked_abort(sid))
assert out["aborted"] is True
@pytest.mark.asyncio
async def test_handler_chunk_order_returns_error_json(self, handler: MediaChunkedHandler):
start_out = json.loads(
await handler.upload_media_chunked_start(filename="f.bin", total_bytes=10)
)
sid = start_out["session_id"]
chunk_b64 = _b64.b64encode(b"abc").decode()
out = json.loads(await handler.upload_media_chunked_chunk(sid, 5, chunk_b64))
assert out["error_code"] == "CHUNK_ORDER"