Files
leocrm/tests/test_marketplace.py
T

831 lines
28 KiB
Python
Raw Normal View History

"""Tests for the Marketplace plugin — service and route layers.
Uses AsyncMock for all DB operations, httpx, and PluginSignature.
No real DB or HTTP connections required.
"""
from __future__ import annotations
import io
import uuid
import zipfile
from datetime import UTC, datetime
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from app.plugins.builtins.marketplace.models import MarketplaceListing
from app.plugins.builtins.marketplace.routes import router as marketplace_router
from app.plugins.builtins.marketplace.services import (
download_plugin,
fetch_listings,
get_categories,
get_listing_by_name,
install_plugin,
verify_plugin,
)
# Override conftest DB fixtures — these tests use mocks, no real DB needed
@pytest.fixture(autouse=True, scope="session")
def db_setup():
"""No-op override of conftest db_setup."""
yield
@pytest.fixture(autouse=True)
def clean_tables(db_setup):
"""No-op override of conftest clean_tables."""
yield
# ─── Helpers ───
def _make_listing(
*,
name: str = "test_plugin",
display_name: str = "Test Plugin",
version: str = "1.0.0",
download_url: str = "https://example.com/plugin.zip",
tags: list[str] | None = None,
is_verified: bool = True,
signature_public_key: str = "",
download_count: int = 0,
) -> MarketplaceListing:
"""Create a MarketplaceListing instance with defaults."""
return MarketplaceListing(
id=uuid.uuid4(),
name=name,
display_name=display_name,
description="A test plugin",
version=version,
author="Test Author",
homepage="https://example.com",
download_url=download_url,
signature_public_key=signature_public_key,
icon="",
screenshots=[],
tags=tags or ["productivity"],
price=0.0,
is_verified=is_verified,
download_count=download_count,
min_app_version="0.0.0",
license="MIT",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
def _mock_session() -> AsyncMock:
"""Create a mock AsyncSession."""
session = AsyncMock()
session.add = MagicMock()
session.flush = AsyncMock()
session.execute = AsyncMock()
session.commit = AsyncMock()
session.rollback = AsyncMock()
return session
def _create_valid_zip() -> bytes:
"""Create a valid ZIP file in memory."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
zf.writestr("plugin.py", "# test plugin\n")
zf.writestr("manifest.json", '{"name": "test_plugin"}')
return buf.getvalue()
# ─── Service-Layer Tests ───
class TestFetchListings:
"""Tests for fetch_listings() service function."""
@pytest.mark.asyncio
async def test_fetch_listings_basic(self):
"""fetch_listings returns paginated listings."""
db = _mock_session()
listing = _make_listing()
# Mock count query
count_result = MagicMock()
count_result.scalar.return_value = 1
# Mock paginated query
list_result = MagicMock()
list_result.scalars.return_value.all.return_value = [listing]
db.execute.side_effect = [count_result, list_result]
result = await fetch_listings(db, page=1, page_size=20)
assert result["total"] == 1
assert len(result["listings"]) == 1
assert result["listings"][0]["name"] == "test_plugin"
assert result["page"] == 1
assert result["page_size"] == 20
@pytest.mark.asyncio
async def test_fetch_listings_with_search(self):
"""fetch_listings filters by search term."""
db = _mock_session()
listing = _make_listing(name="my_cool_plugin", display_name="My Cool Plugin")
count_result = MagicMock()
count_result.scalar.return_value = 1
list_result = MagicMock()
list_result.scalars.return_value.all.return_value = [listing]
db.execute.side_effect = [count_result, list_result]
result = await fetch_listings(db, search="cool")
assert result["total"] == 1
assert result["listings"][0]["display_name"] == "My Cool Plugin"
@pytest.mark.asyncio
async def test_fetch_listings_with_tag_filter(self):
"""fetch_listings filters by tags."""
db = _mock_session()
listing = _make_listing(tags=["productivity", "automation"])
count_result = MagicMock()
count_result.scalar.return_value = 1
list_result = MagicMock()
list_result.scalars.return_value.all.return_value = [listing]
db.execute.side_effect = [count_result, list_result]
result = await fetch_listings(db, tags=["productivity"])
assert result["total"] == 1
assert "productivity" in result["listings"][0]["tags"]
@pytest.mark.asyncio
async def test_fetch_listings_empty(self):
"""fetch_listings returns empty list when no listings exist."""
db = _mock_session()
count_result = MagicMock()
count_result.scalar.return_value = 0
list_result = MagicMock()
list_result.scalars.return_value.all.return_value = []
db.execute.side_effect = [count_result, list_result]
result = await fetch_listings(db)
assert result["total"] == 0
assert len(result["listings"]) == 0
@pytest.mark.asyncio
async def test_fetch_listings_pagination(self):
"""fetch_listings respects page and page_size."""
db = _mock_session()
count_result = MagicMock()
count_result.scalar.return_value = 50
list_result = MagicMock()
list_result.scalars.return_value.all.return_value = []
db.execute.side_effect = [count_result, list_result]
result = await fetch_listings(db, page=3, page_size=10)
assert result["page"] == 3
assert result["page_size"] == 10
assert result["total"] == 50
class TestGetListingByName:
"""Tests for get_listing_by_name() service function."""
@pytest.mark.asyncio
async def test_get_listing_by_name_found(self):
"""get_listing_by_name returns listing when found."""
db = _mock_session()
listing = _make_listing(name="my_plugin")
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = listing
db.execute.return_value = mock_result
result = await get_listing_by_name(db, "my_plugin")
assert result is not None
assert result.name == "my_plugin"
@pytest.mark.asyncio
async def test_get_listing_by_name_not_found(self):
"""get_listing_by_name returns None when not found."""
db = _mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
result = await get_listing_by_name(db, "nonexistent")
assert result is None
class TestDownloadPlugin:
"""Tests for download_plugin() service function."""
@pytest.mark.asyncio
async def test_download_plugin_success(self):
"""download_plugin downloads and returns a valid ZIP path."""
zip_content = _create_valid_zip()
mock_response = MagicMock()
mock_response.content = zip_content
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(return_value=mock_response)
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
zip_path = await download_plugin("test_plugin", "https://example.com/plugin.zip")
assert zip_path.exists()
assert zipfile.is_zipfile(zip_path)
# Cleanup
import shutil
shutil.rmtree(zip_path.parent, ignore_errors=True)
@pytest.mark.asyncio
async def test_download_plugin_invalid_zip(self):
"""download_plugin raises ValueError for invalid ZIP."""
mock_response = MagicMock()
mock_response.content = b"not a zip file"
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(return_value=mock_response)
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(ValueError, match="not a valid ZIP"):
await download_plugin("test_plugin", "https://example.com/plugin.zip")
@pytest.mark.asyncio
async def test_download_plugin_size_limit(self):
"""download_plugin raises ValueError when ZIP exceeds size limit."""
# Create content larger than MARKETPLACE_MAX_ZIP_SIZE
from app.plugins.builtins.marketplace.config import MARKETPLACE_MAX_ZIP_SIZE
large_content = b"x" * (MARKETPLACE_MAX_ZIP_SIZE + 1)
mock_response = MagicMock()
mock_response.content = large_content
mock_response.raise_for_status = MagicMock()
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(return_value=mock_response)
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(ValueError, match="too large"):
await download_plugin("test_plugin", "https://example.com/plugin.zip")
@pytest.mark.asyncio
async def test_download_plugin_empty_url(self):
"""download_plugin raises ValueError for empty download URL."""
with pytest.raises(ValueError, match="No download URL"):
await download_plugin("test_plugin", "")
@pytest.mark.asyncio
async def test_download_plugin_http_error(self):
"""download_plugin raises ValueError on HTTP error."""
import httpx
mock_client = AsyncMock()
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client.get = AsyncMock(side_effect=httpx.HTTPError("Connection failed"))
with patch("app.plugins.builtins.marketplace.services.httpx.AsyncClient", return_value=mock_client):
with pytest.raises(ValueError, match="Failed to download"):
await download_plugin("test_plugin", "https://example.com/plugin.zip")
class TestVerifyPlugin:
"""Tests for verify_plugin() service function."""
@pytest.mark.asyncio
async def test_verify_plugin_missing_signature(self):
"""verify_plugin returns False when signature is missing."""
zip_path = Path("/tmp/fake.zip")
result = await verify_plugin(zip_path, signature=None, public_key=b"some_key")
assert result is False
@pytest.mark.asyncio
async def test_verify_plugin_missing_public_key(self):
"""verify_plugin returns False when public_key is missing."""
zip_path = Path("/tmp/fake.zip")
result = await verify_plugin(zip_path, signature=b"some_sig", public_key=None)
assert result is False
@pytest.mark.asyncio
async def test_verify_plugin_both_missing(self):
"""verify_plugin returns False when both signature and public_key are missing."""
zip_path = Path("/tmp/fake.zip")
result = await verify_plugin(zip_path, signature=None, public_key=None)
assert result is False
@pytest.mark.asyncio
async def test_verify_plugin_valid_signature(self):
"""verify_plugin returns True when PluginSignature.verify_signature succeeds."""
zip_path = Path("/tmp/fake.zip")
with patch(
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
return_value=True,
) as mock_verify:
result = await verify_plugin(
zip_path=zip_path,
signature=b"valid_sig",
public_key=b"valid_key",
)
assert result is True
mock_verify.assert_called_once_with(
zip_path=zip_path,
signature=b"valid_sig",
public_key=b"valid_key",
)
@pytest.mark.asyncio
async def test_verify_plugin_invalid_signature(self):
"""verify_plugin returns False when PluginSignature.verify_signature fails."""
zip_path = Path("/tmp/fake.zip")
with patch(
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
return_value=False,
):
result = await verify_plugin(
zip_path=zip_path,
signature=b"invalid_sig",
public_key=b"valid_key",
)
assert result is False
@pytest.mark.asyncio
async def test_verify_plugin_exception_returns_false(self):
"""verify_plugin returns False when verification raises an exception."""
zip_path = Path("/tmp/fake.zip")
with patch(
"app.plugins.builtins.marketplace.services.PluginSignature.verify_signature",
side_effect=Exception("Verification error"),
):
result = await verify_plugin(
zip_path=zip_path,
signature=b"sig",
public_key=b"key",
)
assert result is False
class TestInstallPlugin:
"""Tests for install_plugin() service function."""
@pytest.mark.asyncio
async def test_install_plugin_not_found(self):
"""install_plugin raises ValueError when plugin not in marketplace."""
db = _mock_session()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
with pytest.raises(ValueError, match="not found in marketplace"):
await install_plugin(db, "nonexistent_plugin")
@pytest.mark.asyncio
async def test_install_plugin_success(self):
"""install_plugin downloads, verifies, and installs a plugin."""
db = _mock_session()
listing = _make_listing(name="test_plugin", signature_public_key="")
# get_listing_by_name returns listing
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = listing
db.execute.return_value = mock_result
zip_content = _create_valid_zip()
# Mock download_plugin
temp_dir = Path("/tmp/marketplace_test_install")
temp_dir.mkdir(parents=True, exist_ok=True)
zip_path = temp_dir / "test_plugin.zip"
zip_path.write_bytes(zip_content)
# Mock plugin service
mock_service = MagicMock()
mock_service.install_plugin_from_zip = AsyncMock(return_value={"success": True})
mock_service.activate_plugin = AsyncMock()
with (
patch("app.plugins.builtins.marketplace.services.download_plugin", AsyncMock(return_value=zip_path)),
patch("app.services.plugin_service.get_plugin_service", return_value=mock_service),
patch("app.plugins.builtins.marketplace.services.PluginSignature.compute_hash", return_value="fake_hash"),
):
result = await install_plugin(db, "test_plugin", activate=False)
assert result["success"] is True
assert result["name"] == "test_plugin"
assert result["installed"] is True
assert result["activated"] is False
mock_service.install_plugin_from_zip.assert_awaited_once()
# Cleanup
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
async def test_install_plugin_with_activate(self):
"""install_plugin activates plugin when activate=True."""
db = _mock_session()
listing = _make_listing(name="test_plugin")
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = listing
db.execute.return_value = mock_result
zip_content = _create_valid_zip()
temp_dir = Path("/tmp/marketplace_test_activate")
temp_dir.mkdir(parents=True, exist_ok=True)
zip_path = temp_dir / "test_plugin.zip"
zip_path.write_bytes(zip_content)
mock_service = MagicMock()
mock_service.install_plugin_from_zip = AsyncMock(return_value={"success": True})
mock_service.activate_plugin = AsyncMock()
with (
patch("app.plugins.builtins.marketplace.services.download_plugin", AsyncMock(return_value=zip_path)),
patch("app.services.plugin_service.get_plugin_service", return_value=mock_service),
patch("app.plugins.builtins.marketplace.services.PluginSignature.compute_hash", return_value="fake_hash"),
):
result = await install_plugin(db, "test_plugin", activate=True)
assert result["activated"] is True
mock_service.activate_plugin.assert_awaited_once()
import shutil
shutil.rmtree(temp_dir, ignore_errors=True)
@pytest.mark.asyncio
async def test_install_plugin_download_failure(self):
"""install_plugin raises ValueError when download fails."""
db = _mock_session()
listing = _make_listing(name="test_plugin")
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = listing
db.execute.return_value = mock_result
with patch(
"app.plugins.builtins.marketplace.services.download_plugin",
AsyncMock(side_effect=ValueError("Download failed")),
):
with pytest.raises(ValueError, match="Download failed"):
await install_plugin(db, "test_plugin")
class TestGetCategories:
"""Tests for get_categories() service function."""
@pytest.mark.asyncio
async def test_get_categories_returns_unique_tags(self):
"""get_categories returns sorted unique tags from all listings."""
db = _mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [
["productivity", "automation"],
["communication"],
["productivity", "ai"],
]
db.execute.return_value = mock_result
result = await get_categories(db)
assert result == ["ai", "automation", "communication", "productivity"]
@pytest.mark.asyncio
async def test_get_categories_empty(self):
"""get_categories returns empty list when no listings exist."""
db = _mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = []
db.execute.return_value = mock_result
result = await get_categories(db)
assert result == []
@pytest.mark.asyncio
async def test_get_categories_with_none_tags(self):
"""get_categories handles None tag values gracefully."""
db = _mock_session()
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [
["productivity"],
None,
[],
]
db.execute.return_value = mock_result
result = await get_categories(db)
assert result == ["productivity"]
# ─── Model Tests ───
class TestMarketplaceListingModel:
"""Tests for the MarketplaceListing model."""
def test_model_has_required_fields(self):
"""MarketplaceListing has name, display_name, version, download_url."""
listing = MarketplaceListing(
name="test_plugin",
display_name="Test Plugin",
version="1.0.0",
download_url="https://example.com/plugin.zip",
)
assert listing.name == "test_plugin"
assert listing.display_name == "Test Plugin"
assert listing.version == "1.0.0"
assert listing.download_url == "https://example.com/plugin.zip"
def test_model_defaults(self):
"""MarketplaceListing has correct column defaults."""
cols = MarketplaceListing.__table__.c
assert cols.price.default.arg == 0.0
assert cols.is_verified.default.arg is False
assert cols.download_count.default.arg == 0
assert cols.license.default.arg == "MIT"
assert cols.min_app_version.default.arg == "0.0.0"
def test_model_table_name(self):
"""MarketplaceListing uses correct table name."""
assert MarketplaceListing.__tablename__ == "marketplace_listings"
def test_model_is_global_not_tenant_scoped(self):
"""MarketplaceListing does NOT have tenant_id (global table)."""
# MarketplaceListing should not inherit TenantMixin
assert not hasattr(MarketplaceListing, "tenant_id")
# ─── Route-Layer Tests ───
def _create_marketplace_app() -> FastAPI:
"""Create a minimal FastAPI app with marketplace router and mocked dependencies."""
app = FastAPI()
app.include_router(marketplace_router)
async def _mock_get_db():
db = _mock_session()
yield db
async def _mock_get_current_user():
return {
"user_id": str(uuid.uuid4()),
"tenant_id": str(uuid.uuid4()),
"is_system_admin": True,
}
async def _mock_require_admin():
return {
"user_id": str(uuid.uuid4()),
"tenant_id": str(uuid.uuid4()),
"is_system_admin": True,
}
from app.deps import get_current_user, require_admin
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
app.dependency_overrides[get_current_user] = _mock_get_current_user
app.dependency_overrides[require_admin] = _mock_require_admin
return app
class TestMarketplaceRoutes:
"""Tests for marketplace API routes."""
@pytest.mark.asyncio
async def test_list_listings_route(self):
"""GET /api/v1/marketplace/listings returns listings."""
app = _create_marketplace_app()
db = _mock_session()
async def _mock_get_db():
yield db
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
listing = _make_listing()
count_result = MagicMock()
count_result.scalar.return_value = 1
list_result = MagicMock()
list_result.scalars.return_value.all.return_value = [listing]
db.execute.side_effect = [count_result, list_result]
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/v1/marketplace/listings")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 1
assert len(data["listings"]) == 1
assert data["listings"][0]["name"] == "test_plugin"
@pytest.mark.asyncio
async def test_get_listing_by_name_route(self):
"""GET /api/v1/marketplace/listings/{name} returns listing details."""
app = _create_marketplace_app()
db = _mock_session()
async def _mock_get_db():
yield db
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
listing = _make_listing(name="my_plugin", display_name="My Plugin")
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = listing
db.execute.return_value = mock_result
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/v1/marketplace/listings/my_plugin")
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "my_plugin"
assert data["display_name"] == "My Plugin"
@pytest.mark.asyncio
async def test_get_listing_by_name_not_found_route(self):
"""GET /api/v1/marketplace/listings/{name} returns 404 when not found."""
app = _create_marketplace_app()
db = _mock_session()
async def _mock_get_db():
yield db
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/v1/marketplace/listings/nonexistent")
assert resp.status_code == 404
assert resp.json()["detail"]["code"] == "not_found"
@pytest.mark.asyncio
async def test_categories_route(self):
"""GET /api/v1/marketplace/categories returns categories."""
app = _create_marketplace_app()
db = _mock_session()
async def _mock_get_db():
yield db
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
mock_result = MagicMock()
mock_result.scalars.return_value.all.return_value = [
["productivity", "ai"],
["communication"],
]
db.execute.return_value = mock_result
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/api/v1/marketplace/categories")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 3
assert "productivity" in data["categories"]
assert "ai" in data["categories"]
assert "communication" in data["categories"]
@pytest.mark.asyncio
async def test_verify_plugin_route_no_signature(self):
"""POST /api/v1/marketplace/verify/{name} returns signature_valid=False when no signature provided."""
app = _create_marketplace_app()
db = _mock_session()
async def _mock_get_db():
yield db
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
listing = _make_listing(name="test_plugin", signature_public_key="")
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = listing
db.execute.return_value = mock_result
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/marketplace/verify/test_plugin",
json={"name": "test_plugin"},
)
assert resp.status_code == 200
data = resp.json()
assert data["signature_valid"] is False
assert data["name"] == "test_plugin"
@pytest.mark.asyncio
async def test_verify_plugin_route_not_found(self):
"""POST /api/v1/marketplace/verify/{name} returns 404 when plugin not found."""
app = _create_marketplace_app()
db = _mock_session()
async def _mock_get_db():
yield db
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/marketplace/verify/nonexistent",
json={"name": "nonexistent"},
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_install_plugin_route_not_found(self):
"""POST /api/v1/marketplace/install/{name} returns 400 when plugin not found."""
app = _create_marketplace_app()
db = _mock_session()
async def _mock_get_db():
yield db
from app.core.db import get_db
app.dependency_overrides[get_db] = _mock_get_db
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
db.execute.return_value = mock_result
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.post(
"/api/v1/marketplace/install/nonexistent",
json={"name": "nonexistent"},
)
assert resp.status_code == 400
assert resp.json()["detail"]["code"] == "install_error"