"""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 @pytest.fixture(autouse=True) def _mock_check_permission(): """Patch check_permission to always return True for route tests.""" with patch("app.core.permissions.check_permission", return_value=True): 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"