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>
265 lines
10 KiB
Python
265 lines
10 KiB
Python
"""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"
|