"""Tests for Step 10+12: Plugin Marketplace (DB-backed). Covers: - PluginRegistry: catalog management, filtering, sorting, install counts (PostgreSQL) - ReviewQueue: pending queue, review decisions, manifest security checklist - RevenueShare: install event recording, earnings aggregation (PostgreSQL) - Route integration: tier gate, list/get/install/uninstall via TestClient """ from __future__ import annotations import json import uuid import pytest import pytest_asyncio from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.marketplace.plugin_registry import PluginRegistry from app.marketplace.plugin_review import ReviewQueue, validate_manifest from app.marketplace.revenue_share import RevenueShare from app.models import Plugin, PluginReview as PluginReviewModel, RevenueEvent from app.schemas import PluginManifest from tests.conftest import TEST_USER_IDS, auth_header # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _fresh_manifest( plugin_id: str | None = None, category: str = "productivity", price_cents: int = 0, permissions: list[str] | None = None, ) -> PluginManifest: pid = plugin_id or f"plugin-{uuid.uuid4().hex[:8]}" return PluginManifest( id=pid, name=f"Plugin {pid}", description=f"Description for {pid}", version="1.0.0", author="test-author", permissions=permissions or ["read:tasks"], category=category, price_cents=price_cents, ) # --------------------------------------------------------------------------- # PluginRegistry (DB-backed) # --------------------------------------------------------------------------- class TestPluginRegistry: """Each test uses the conftest db_session fixture with a fresh in-memory DB.""" @pytest.fixture def reg(self) -> PluginRegistry: return PluginRegistry() @pytest.mark.asyncio async def test_seed_plugins_are_listed( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: result = await reg.list_plugins(db_session) assert result.total == 3 assert all(p.id.startswith("plugin-") for p in result.plugins) @pytest.mark.asyncio async def test_list_approved_only( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: manifest = _fresh_manifest() await reg.submit_plugin(db_session, manifest, "plugins/key.zip") result = await reg.list_plugins(db_session) ids = [p.id for p in result.plugins] assert manifest.id not in ids # still pending @pytest.mark.asyncio async def test_list_filter_by_category( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: result = await reg.list_plugins(db_session, category="communication") assert result.total == 1 assert result.plugins[0].id == "plugin-slack-notify" @pytest.mark.asyncio async def test_list_filter_by_query( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: result = await reg.list_plugins(db_session, query="time") assert result.total == 1 assert result.plugins[0].id == "plugin-time-tracker" @pytest.mark.asyncio async def test_list_sort_by_installs( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: await reg.record_install(db_session, "plugin-slack-notify") await reg.record_install(db_session, "plugin-slack-notify") result = await reg.list_plugins(db_session, sort="installs") assert result.plugins[0].id == "plugin-slack-notify" @pytest.mark.asyncio async def test_get_plugin_found( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: entry = await reg.get_plugin(db_session, "plugin-github-sync") assert entry is not None assert entry["manifest"].id == "plugin-github-sync" assert "install_count" in entry @pytest.mark.asyncio async def test_get_plugin_not_found( self, reg: PluginRegistry, db_session: AsyncSession ) -> None: entry = await reg.get_plugin(db_session, "no-such-plugin") assert entry is None @pytest.mark.asyncio async def test_submit_sets_pending( self, reg: PluginRegistry, db_session: AsyncSession ) -> None: manifest = _fresh_manifest() plugin_id = await reg.submit_plugin(db_session, manifest, "key.zip") assert plugin_id == manifest.id result = await db_session.execute(select(Plugin).where(Plugin.id == plugin_id)) row = result.scalar_one() assert row.status == "pending_review" @pytest.mark.asyncio async def test_approve_makes_visible( self, reg: PluginRegistry, db_session: AsyncSession ) -> None: manifest = _fresh_manifest() await reg.submit_plugin(db_session, manifest, "key.zip") await reg.approve_plugin(db_session, manifest.id) result = await reg.list_plugins(db_session) assert manifest.id in [p.id for p in result.plugins] @pytest.mark.asyncio async def test_reject_stores_reason( self, reg: PluginRegistry, db_session: AsyncSession ) -> None: manifest = _fresh_manifest() await reg.submit_plugin(db_session, manifest, "key.zip") await reg.reject_plugin(db_session, manifest.id, reason="Unsafe permissions") result = await db_session.execute(select(Plugin).where(Plugin.id == manifest.id)) row = result.scalar_one() assert row.status == "rejected" assert row.rejection_reason == "Unsafe permissions" listed = await reg.list_plugins(db_session) assert manifest.id not in [p.id for p in listed.plugins] @pytest.mark.asyncio async def test_approve_unknown_raises_key_error( self, reg: PluginRegistry, db_session: AsyncSession ) -> None: with pytest.raises(KeyError): await reg.approve_plugin(db_session, "ghost-plugin") @pytest.mark.asyncio async def test_record_install_increments_count( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: await reg.record_install(db_session, "plugin-github-sync") entry = await reg.get_plugin(db_session, "plugin-github-sync") assert entry is not None assert entry["install_count"] == 1 @pytest.mark.asyncio async def test_record_uninstall_decrements_count( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: await reg.record_install(db_session, "plugin-github-sync") await reg.record_install(db_session, "plugin-github-sync") await reg.record_uninstall(db_session, "plugin-github-sync") entry = await reg.get_plugin(db_session, "plugin-github-sync") assert entry is not None assert entry["install_count"] == 1 @pytest.mark.asyncio async def test_record_uninstall_floors_at_zero( self, reg: PluginRegistry, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: await reg.record_uninstall(db_session, "plugin-github-sync") entry = await reg.get_plugin(db_session, "plugin-github-sync") assert entry is not None assert entry["install_count"] == 0 # --------------------------------------------------------------------------- # ReviewQueue (DB-backed) # --------------------------------------------------------------------------- class TestReviewQueue: @pytest.fixture def reg(self) -> PluginRegistry: return PluginRegistry() @pytest.fixture def queue(self) -> ReviewQueue: return ReviewQueue() @pytest.mark.asyncio async def test_get_pending_returns_submitted_plugins( self, reg: PluginRegistry, queue: ReviewQueue, db_session: AsyncSession ) -> None: manifest = _fresh_manifest() await reg.submit_plugin(db_session, manifest, "key.zip") pending = await queue.get_pending(db_session) assert any(p["plugin_id"] == manifest.id for p in pending) @pytest.mark.asyncio async def test_submit_review_approved( self, reg: PluginRegistry, queue: ReviewQueue, db_session: AsyncSession ) -> None: manifest = _fresh_manifest() await reg.submit_plugin(db_session, manifest, "key.zip") await queue.submit_review(db_session, manifest.id, TEST_USER_IDS["power"], "approved", "Looks good") result = await db_session.execute(select(Plugin).where(Plugin.id == manifest.id)) row = result.scalar_one() assert row.status == "approved" # Check review row was persisted review_result = await db_session.execute( select(PluginReviewModel).where(PluginReviewModel.plugin_id == manifest.id) ) review = review_result.scalar_one() assert review.decision == "approved" @pytest.mark.asyncio async def test_submit_review_rejected( self, reg: PluginRegistry, queue: ReviewQueue, db_session: AsyncSession ) -> None: manifest = _fresh_manifest() await reg.submit_plugin(db_session, manifest, "key.zip") await queue.submit_review( db_session, manifest.id, TEST_USER_IDS["power"], "rejected", "Bad permissions" ) result = await db_session.execute(select(Plugin).where(Plugin.id == manifest.id)) row = result.scalar_one() assert row.status == "rejected" def test_validate_manifest_ok(self) -> None: manifest = _fresh_manifest(permissions=["read:tasks", "write:notes"]) validate_manifest(manifest) # should not raise def test_validate_manifest_unknown_permission(self) -> None: manifest = _fresh_manifest(permissions=["read:tasks", "read:secrets"]) with pytest.raises(ValueError, match="Unknown permission"): validate_manifest(manifest) def test_validate_manifest_invalid_id_format(self) -> None: manifest = _fresh_manifest(plugin_id="Plugin_ID_Invalid") with pytest.raises(ValueError, match="Invalid plugin id format"): validate_manifest(manifest) def test_validate_manifest_id_with_uppercase(self) -> None: manifest = _fresh_manifest(plugin_id="UpperCase") with pytest.raises(ValueError, match="Invalid plugin id format"): validate_manifest(manifest) # --------------------------------------------------------------------------- # RevenueShare (DB-backed) # --------------------------------------------------------------------------- class TestRevenueShare: @pytest.fixture def rs(self) -> RevenueShare: return RevenueShare() @pytest.mark.asyncio async def test_record_install_free_plugin( self, rs: RevenueShare, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: await rs.record_install(db_session, "plugin-github-sync", TEST_USER_IDS["power"], amount_cents=0) result = await db_session.execute( select(RevenueEvent).where(RevenueEvent.plugin_id == "plugin-github-sync") ) event = result.scalar_one() assert event.developer_share_cents == 0 @pytest.mark.asyncio async def test_record_install_paid_plugin_no_stripe( self, rs: RevenueShare, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: await rs.record_install( db_session, "plugin-slack-notify", TEST_USER_IDS["pro"], amount_cents=499 ) result = await db_session.execute( select(RevenueEvent).where(RevenueEvent.plugin_id == "plugin-slack-notify") ) event = result.scalar_one() assert event.amount_cents == 499 assert event.developer_share_cents == int(499 * 0.70) @pytest.mark.asyncio async def test_record_install_increments_registry_count( self, rs: RevenueShare, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: reg = PluginRegistry() await rs.record_install(db_session, "plugin-github-sync", TEST_USER_IDS["power"], amount_cents=0) entry = await reg.get_plugin(db_session, "plugin-github-sync") assert entry is not None assert entry["install_count"] == 1 @pytest.mark.asyncio async def test_get_earnings_empty( self, rs: RevenueShare, db_session: AsyncSession ) -> None: result = await rs.get_earnings(db_session, "unknown-dev") assert result["total_installs"] == 0 assert result["total_revenue_cents"] == 0 assert result["developer_share_cents"] == 0 @pytest.mark.asyncio async def test_get_earnings_aggregates( self, rs: RevenueShare, db_session: AsyncSession, seed_plugins: list[Plugin] ) -> None: await rs.record_install(db_session, "plugin-slack-notify", TEST_USER_IDS["power"], amount_cents=499) await rs.record_install(db_session, "plugin-slack-notify", TEST_USER_IDS["pro"], amount_cents=499) result = await rs.get_earnings(db_session, "Adiuva") assert result["total_installs"] == 2 assert result["total_revenue_cents"] == 998 assert result["developer_share_cents"] == int(499 * 0.70) * 2 # --------------------------------------------------------------------------- # Route integration tests # --------------------------------------------------------------------------- class TestPluginRoutes: def test_list_plugins_requires_power_tier(self, client, seed_plugins) -> None: resp = client.get("/api/v1/plugins", headers=auth_header("free")) assert resp.status_code == 403 def test_list_plugins_pro_tier_blocked(self, client, seed_plugins) -> None: resp = client.get("/api/v1/plugins", headers=auth_header("pro")) assert resp.status_code == 403 def test_list_plugins_power_tier_ok(self, client, seed_plugins) -> None: resp = client.get("/api/v1/plugins", headers=auth_header("power")) assert resp.status_code == 200 data = resp.json() assert "plugins" in data assert data["total"] == 3 def test_list_plugins_team_tier_ok(self, client, seed_plugins) -> None: resp = client.get("/api/v1/plugins", headers=auth_header("team")) assert resp.status_code == 200 def test_get_plugin_found(self, client, seed_plugins) -> None: resp = client.get("/api/v1/plugins/plugin-github-sync", headers=auth_header()) assert resp.status_code == 200 data = resp.json() assert data["plugin"]["id"] == "plugin-github-sync" assert "install_count" in data def test_get_plugin_not_found(self, client, seed_plugins) -> None: resp = client.get("/api/v1/plugins/no-such-plugin", headers=auth_header()) assert resp.status_code == 404 def test_install_plugin_free(self, client, seed_plugins) -> None: resp = client.post( "/api/v1/plugins/plugin-github-sync/install", json={"plugin_id": "plugin-github-sync"}, headers=auth_header(), ) assert resp.status_code == 200 data = resp.json() assert data["ok"] is True assert "download_url" in data def test_install_plugin_not_found(self, client, seed_plugins) -> None: resp = client.post( "/api/v1/plugins/ghost/install", json={"plugin_id": "ghost"}, headers=auth_header(), ) assert resp.status_code == 404 def test_uninstall_plugin_ok(self, client, seed_plugins) -> None: resp = client.delete( "/api/v1/plugins/plugin-github-sync/install", headers=auth_header(), ) assert resp.status_code == 200 assert resp.json()["ok"] is True def test_install_requires_power_tier(self, client, seed_plugins) -> None: resp = client.post( "/api/v1/plugins/plugin-github-sync/install", json={"plugin_id": "plugin-github-sync"}, headers=auth_header("free"), ) assert resp.status_code == 403