"""Tests for plugin system framework — all 14 acceptance criteria.""" from __future__ import annotations import asyncio import uuid from typing import Any import pytest import pytest_asyncio from httpx import ASGITransport, AsyncClient from sqlalchemy import text, select, inspect from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, AsyncSession from app.core.db import Base, reset_engine_for_testing, close_engine, get_engine from app.core.event_bus import get_event_bus from app.core.service_container import get_container from app.main import create_app from app.models.plugin import Plugin as PluginModel, PluginMigration from app.plugins.base import BasePlugin from app.plugins.manifest import PluginManifest from app.plugins.registry import get_registry, reset_registry_for_testing from app.plugins.migration_runner import MigrationRunner, MigrationValidationError from app.plugins.builtins.test_sample import TestSamplePlugin from app.services.plugin_service import reset_plugin_service_for_testing from tests.conftest import TEST_DB_URL, seed_tenant_and_users, login_client, ORIGIN_HEADER # ─── Bad Plugin for AC11 (migration without tenant_id) ─── class BadMigrationPlugin(BasePlugin): """Plugin with a migration that creates a table WITHOUT tenant_id.""" manifest = PluginManifest( name="bad_migration_plugin", version="1.0.0", display_name="Bad Migration Plugin", description="Plugin with invalid migration (no tenant_id) for validator testing.", dependencies=[], routes=[], events=[], migrations=["0001_bad_migration.sql"], permissions=[], ) # ─── Fixtures ─── @pytest_asyncio.fixture async def plugin_app(engine: AsyncEngine, redis_client): """FastAPI app with plugin registry initialized for testing.""" reset_engine_for_testing(engine) app = create_app() # Reset and initialize registry (lifespan doesn't run with ASGITransport) registry = reset_registry_for_testing() registry.initialize(engine, app) # Reset service container and event bus container = get_container() await container.initialize() # Register test plugins manually (don't auto-discover to avoid side effects) registry.register_plugin(TestSamplePlugin()) registry.register_plugin(BadMigrationPlugin()) # Reset plugin service to use the new registry reset_plugin_service_for_testing(registry) yield app await close_engine() @pytest_asyncio.fixture async def plugin_client(plugin_app) -> AsyncClient: """HTTP test client with plugin registry active.""" transport = ASGITransport(app=plugin_app) async with AsyncClient(transport=transport, base_url="http://test") as c: yield c @pytest_asyncio.fixture async def authed_plugin_client(plugin_client: AsyncClient, db_session: AsyncSession) -> AsyncClient: """Authenticated admin client for plugin tests.""" seed = await seed_tenant_and_users(db_session) await login_client(plugin_client, "admin@tenanta.com") return plugin_client @pytest_asyncio.fixture async def db_session_for_plugins(engine: AsyncEngine) -> AsyncSession: """DB session for plugin test verification.""" factory = async_sessionmaker(bind=engine, expire_on_commit=False, class_=AsyncSession) async with factory() as session: yield session await session.rollback() # ─── AC1: GET /api/v1/plugins → 200 + list of plugins with status ─── @pytest.mark.asyncio async def test_ac01_list_plugins(authed_plugin_client: AsyncClient): """AC1: GET /api/v1/plugins returns 200 with plugin list and status.""" resp = await authed_plugin_client.get("/api/v1/plugins", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert "plugins" in data assert "total" in data assert data["total"] >= 2 plugin_names = [p["name"] for p in data["plugins"]] assert "test_sample" in plugin_names assert "bad_migration_plugin" in plugin_names # Check status field exists for p in data["plugins"]: assert "status" in p assert "installed" in p assert "active" in p # ─── AC2: POST /api/v1/plugins/{name}/install → 200, status=installed, migrations run ─── @pytest.mark.asyncio async def test_ac02_install_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession): """AC2: Install plugin runs migrations and sets status=installed.""" resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["name"] == "test_sample" assert data["status"] == "installed" assert data["installed"] is True assert data["active"] is False # Verify migration table was created (tenant_id column exists) result = await db_session_for_plugins.execute( text("SELECT column_name FROM information_schema.columns WHERE table_name = 'plugin_test_data' AND column_name = 'tenant_id'") ) assert result.fetchone() is not None, "plugin_test_data table should have tenant_id column" # ─── AC3: POST /api/v1/plugins/{name}/activate → 200, status=active, routes registered ─── @pytest.mark.asyncio async def test_ac03_activate_plugin(authed_plugin_client: AsyncClient): """AC3: Activate plugin sets status=active and registers event listeners.""" # Install first resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) assert resp.status_code == 200 # Activate resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["name"] == "test_sample" assert data["status"] == "active" assert data["active"] is True # ─── AC4: POST /api/v1/plugins/{name}/deactivate → 200, status=inactive, routes unregistered ─── @pytest.mark.asyncio async def test_ac04_deactivate_plugin(authed_plugin_client: AsyncClient): """AC4: Deactivate plugin sets status=inactive and unregisters event listeners.""" # Install and activate first await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) # Deactivate resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["name"] == "test_sample" assert data["status"] == "inactive" assert data["active"] is False # ─── AC5: DELETE /api/v1/plugins/{name} → 200, plugin removed ─── @pytest.mark.asyncio async def test_ac05_uninstall_plugin(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession): """AC5: Uninstall plugin removes DB record.""" # Install first await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) # Uninstall resp = await authed_plugin_client.delete("/api/v1/plugins/test_sample", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["status"] == "uninstalled" assert data["installed"] is False # Verify DB record is gone result = await db_session_for_plugins.execute( select(PluginModel).where(PluginModel.name == "test_sample") ) assert result.scalar_one_or_none() is None # ─── AC6: DELETE /api/v1/plugins/{name}?remove_data=true → 200, plugin tables dropped ─── @pytest.mark.asyncio async def test_ac06_uninstall_remove_data(authed_plugin_client: AsyncClient, engine: AsyncEngine): """AC6: Uninstall with remove_data=true drops plugin tables.""" # Install first (creates plugin_test_data table) await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) # Verify table exists def _check_table(sync_conn): insp = inspect(sync_conn) return "plugin_test_data" in insp.get_table_names() async with engine.connect() as conn: table_exists_before = await conn.run_sync(_check_table) assert table_exists_before, "plugin_test_data table should exist after install" # Uninstall with remove_data=true resp = await authed_plugin_client.delete( "/api/v1/plugins/test_sample?remove_data=true", headers=ORIGIN_HEADER ) assert resp.status_code == 200 data = resp.json() assert data["status"] == "uninstalled" assert "plugin_test_data" in data.get("dropped_tables", []) # Verify table is gone async with engine.connect() as conn: table_exists_after = await conn.run_sync(_check_table) assert not table_exists_after, "plugin_test_data table should be dropped after uninstall with remove_data=true" # ─── AC7: GET /api/v1/plugins/manifest → 200 + manifest schema documentation ─── @pytest.mark.asyncio async def test_ac07_manifest_schema(authed_plugin_client: AsyncClient): """AC7: GET /api/v1/plugins/manifest returns schema documentation.""" resp = await authed_plugin_client.get("/api/v1/plugins/manifest", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert "fields" in data assert "example" in data # Check required fields are documented assert "name" in data["fields"] assert "version" in data["fields"] assert "display_name" in data["fields"] assert "dependencies" in data["fields"] assert "routes" in data["fields"] assert "events" in data["fields"] assert "migrations" in data["fields"] # Check example has expected values assert data["example"]["name"] == "example_plugin" # ─── AC8: Plugin activation registers event listeners on event bus ─── @pytest.mark.asyncio async def test_ac08_activation_registers_event_listeners( authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession ): """AC8: Activating a plugin registers its event listeners on the event bus.""" registry = get_registry() plugin = registry.get_plugin("test_sample") assert plugin is not None # Install and activate await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) event_bus = get_event_bus() # Check that event bus has handlers for the plugin's events assert len(event_bus._handlers.get("company.created", [])) > 0 assert len(event_bus._handlers.get("contact.created", [])) > 0 # Publish an event and verify the handler is called await event_bus.publish("company.created", {"company_id": "test-123", "name": "Test Corp"}) # Give async tasks a moment to complete await asyncio.sleep(0.1) # Check that the plugin's event handler was called assert len(plugin.event_log) > 0 assert plugin.event_log[0]["event"] == "company.created" # ─── AC9: Plugin deactivation unregisters event listeners ─── @pytest.mark.asyncio async def test_ac09_deactivation_unregisters_event_listeners(authed_plugin_client: AsyncClient): """AC9: Deactivating a plugin unregisters its event listeners from the event bus.""" registry = get_registry() plugin = registry.get_plugin("test_sample") assert plugin is not None # Install, activate, then deactivate await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER) event_bus = get_event_bus() # Publish an event — handler should NOT be called after deactivation initial_log_count = len(plugin.event_log) await event_bus.publish("company.created", {"company_id": "test-456", "name": "After Deactivate"}) await asyncio.sleep(0.1) # Event log should not have grown assert len(plugin.event_log) == initial_log_count, "Event handler should not be called after deactivation" # Check event bus no longer has the plugin's handlers handlers = event_bus._handlers.get("company.created", []) # The specific handler reference should be removed for handler in handlers: assert handler not in plugin._event_handlers.values() # ─── AC10: Plugin migration creates tables with tenant_id column ─── @pytest.mark.asyncio async def test_ac10_migration_creates_tenant_id(authed_plugin_client: AsyncClient, engine: AsyncEngine): """AC10: Plugin migration creates tables that have tenant_id column.""" # Install the test_sample plugin which has a migration creating plugin_test_data resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) assert resp.status_code == 200 # Verify the table has tenant_id column def _get_columns(sync_conn): insp = inspect(sync_conn) if "plugin_test_data" not in insp.get_table_names(): return None return [col["name"] for col in insp.get_columns("plugin_test_data")] async with engine.connect() as conn: columns = await conn.run_sync(_get_columns) assert columns is not None, "plugin_test_data table should exist" assert "tenant_id" in columns, "plugin_test_data table must have tenant_id column" # ─── AC11: Plugin migration validator rejects tables without tenant_id ─── @pytest.mark.asyncio async def test_ac11_validator_rejects_no_tenant_id(authed_plugin_client: AsyncClient): """AC11: Migration validator rejects tables created without tenant_id column.""" # Try to install the bad_migration_plugin — should fail with 422 resp = await authed_plugin_client.post( "/api/v1/plugins/bad_migration_plugin/install", headers=ORIGIN_HEADER ) assert resp.status_code == 422 data = resp.json() # detail is a dict with {detail: str, code: str} detail = data.get("detail", {}) if isinstance(detail, dict): detail_text = detail.get("detail", "").lower() detail_code = detail.get("code", "") else: detail_text = str(detail).lower() detail_code = data.get("code", "") assert "tenant_id" in detail_text or "migration_validation" in detail_code # ─── AC12: Plugin DB migrations tracked in plugin_migrations table ─── @pytest.mark.asyncio async def test_ac12_migrations_tracked(authed_plugin_client: AsyncClient, db_session_for_plugins: AsyncSession): """AC12: Plugin migrations are tracked in plugin_migrations table.""" # Install the test_sample plugin resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) assert resp.status_code == 200 # Check plugin_migrations table has a record result = await db_session_for_plugins.execute( select(PluginMigration).where(PluginMigration.plugin_name == "test_sample") ) migrations = result.scalars().all() assert len(migrations) > 0, "plugin_migrations table should have a record for test_sample" assert migrations[0].migration_file == "0001_test_plugin.sql" assert migrations[0].status == "applied" # ─── AC13: Activating already-active plugin → idempotent (200, no error) ─── @pytest.mark.asyncio async def test_ac13_activate_already_active(authed_plugin_client: AsyncClient): """AC13: Activating an already-active plugin is idempotent (returns 200, no error).""" # Install and activate await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) assert resp.status_code == 200 assert resp.json()["status"] == "active" # Activate again — should be idempotent resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["status"] == "active" assert data["active"] is True assert "already active" in data["message"].lower() # ─── AC14: Deactivating inactive plugin → idempotent (200) ─── @pytest.mark.asyncio async def test_ac14_deactivate_already_inactive(authed_plugin_client: AsyncClient): """AC14: Deactivating an already-inactive plugin is idempotent (returns 200, no error).""" # Install and activate await authed_plugin_client.post("/api/v1/plugins/test_sample/install", headers=ORIGIN_HEADER) await authed_plugin_client.post("/api/v1/plugins/test_sample/activate", headers=ORIGIN_HEADER) # Deactivate resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER) assert resp.status_code == 200 assert resp.json()["status"] == "inactive" # Deactivate again — should be idempotent resp = await authed_plugin_client.post("/api/v1/plugins/test_sample/deactivate", headers=ORIGIN_HEADER) assert resp.status_code == 200 data = resp.json() assert data["status"] == "inactive" assert data["active"] is False assert "already inactive" in data["message"].lower() # ─── Additional Tests: Direct MigrationRunner Unit Tests ─── @pytest.mark.asyncio async def test_registry_discover_builtins(engine: AsyncEngine): """Test that registry can discover built-in plugins.""" registry = reset_registry_for_testing() registry.initialize(engine, None) discovered = registry.discover_builtins() # test_sample should be discovered from builtins package assert "test_sample" in discovered assert registry.get_plugin("test_sample") is not None @pytest.mark.asyncio async def test_registry_list_plugins_mixed_states(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test listing plugins in various states (discovered, installed, active, inactive).""" registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) # List before install — all discovered plugins = await registry.list_plugins(db_session_for_plugins) assert len(plugins) == 1 assert plugins[0]["status"] == "discovered" assert plugins[0]["installed"] is False # Install await registry.install(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() # List after install — installed plugins = await registry.list_plugins(db_session_for_plugins) assert len(plugins) == 1 assert plugins[0]["status"] == "installed" assert plugins[0]["installed"] is True # Activate await registry.activate(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() # List after activate — active plugins = await registry.list_plugins(db_session_for_plugins) assert plugins[0]["status"] == "active" assert plugins[0]["active"] is True @pytest.mark.asyncio async def test_registry_install_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test that installing an already-installed plugin is idempotent.""" registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) record1 = await registry.install(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() record2 = await registry.install(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() # Same record returned assert record1.id == record2.id @pytest.mark.asyncio async def test_registry_not_found_errors(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test registry raises ValueError for unknown plugins.""" registry = reset_registry_for_testing() registry.initialize(engine, None) with pytest.raises(ValueError, match="not found"): await registry.install(db_session_for_plugins, "nonexistent") with pytest.raises(ValueError, match="not found"): await registry.activate(db_session_for_plugins, "nonexistent") with pytest.raises(ValueError, match="not found"): await registry.deactivate(db_session_for_plugins, "nonexistent") with pytest.raises(ValueError, match="not found"): await registry.uninstall(db_session_for_plugins, "nonexistent") @pytest.mark.asyncio async def test_registry_activate_without_install(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test that activating without install raises error.""" registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) with pytest.raises(ValueError, match="not installed"): await registry.activate(db_session_for_plugins, "test_sample") @pytest.mark.asyncio async def test_migration_runner_drop_tables(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test MigrationRunner.drop_plugin_tables removes tables and migration records.""" runner = MigrationRunner(engine) # Run migration first await runner.run_migration(db_session_for_plugins, "drop_test", "0001_test_plugin.sql") await db_session_for_plugins.commit() # Verify table exists result = await db_session_for_plugins.execute( text("SELECT tablename FROM pg_tables WHERE tablename = 'plugin_test_data'") ) assert result.fetchone() is not None # Drop tables dropped = await runner.drop_plugin_tables(db_session_for_plugins, "drop_test") await db_session_for_plugins.commit() assert "plugin_test_data" in dropped # Verify table is gone result = await db_session_for_plugins.execute( text("SELECT tablename FROM pg_tables WHERE tablename = 'plugin_test_data'") ) assert result.fetchone() is None # Verify migration records are removed result = await db_session_for_plugins.execute( select(PluginMigration).where(PluginMigration.plugin_name == "drop_test") ) assert result.scalars().all() == [] @pytest.mark.asyncio async def test_migration_runner_file_not_found(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test MigrationRunner raises FileNotFoundError for missing migration file.""" runner = MigrationRunner(engine) with pytest.raises(FileNotFoundError): await runner.run_migration(db_session_for_plugins, "test", "nonexistent.sql") def test_migration_runner_split_sql(): """Test SQL splitting logic.""" sql = "CREATE TABLE a();\nCREATE INDEX idx ON a();\n" statements = MigrationRunner._split_sql(sql) assert len(statements) == 2 # Test with comments sql2 = "-- comment\nCREATE TABLE b();\n" statements2 = MigrationRunner._split_sql(sql2) assert len(statements2) == 1 # Test empty SQL assert MigrationRunner._split_sql("") == [] def test_migration_runner_extract_table_names(): """Test table name extraction from SQL.""" sql = "CREATE TABLE foo (); CREATE TABLE IF NOT EXISTS bar ();" names = MigrationRunner._extract_table_names(sql) assert "foo" in names assert "bar" in names def test_plugin_manifest_validation(): """Test PluginManifest field validation.""" # Valid manifest m = PluginManifest( name="my_plugin", version="1.0.0", display_name="My Plugin", ) assert m.name == "my_plugin" assert m.dependencies == [] assert m.routes == [] # Name is lowercased m2 = PluginManifest(name="MyPlugin", version="1.0.0", display_name="Test") assert m2.name == "myplugin" # Invalid name with special chars with pytest.raises(Exception): PluginManifest(name="my-plugin!", version="1.0.0", display_name="Test") def test_base_plugin_repr(): """Test BasePlugin __repr__ method.""" plugin = TestSamplePlugin() repr_str = repr(plugin) assert "test_sample" in repr_str assert "1.0.0" in repr_str def test_base_plugin_no_manifest_error(): """Test that BasePlugin without manifest raises ValueError.""" class NoManifestPlugin(BasePlugin): pass with pytest.raises(ValueError, match="manifest"): NoManifestPlugin() def test_base_plugin_name_version_properties(): """Test BasePlugin name and version properties.""" plugin = TestSamplePlugin() assert plugin.name == "test_sample" assert plugin.version == "1.0.0" @pytest.mark.asyncio async def test_base_plugin_on_install_default(): """Test default on_install returns None (no-op).""" class MinimalPlugin(BasePlugin): manifest = PluginManifest( name="minimal", version="1.0.0", display_name="Minimal", ) plugin = MinimalPlugin() result = await plugin.on_install(None, None) assert result is None @pytest.mark.asyncio async def test_base_plugin_on_uninstall_default(): """Test default on_uninstall returns None (no-op).""" class MinimalPlugin(BasePlugin): manifest = PluginManifest( name="minimal2", version="1.0.0", display_name="Minimal", ) plugin = MinimalPlugin() result = await plugin.on_uninstall(None, None) assert result is None def test_base_plugin_get_routes_empty(): """Test get_routes returns empty list when manifest has no routes.""" plugin = TestSamplePlugin() routes = plugin.get_routes() assert routes == [] def test_base_plugin_make_event_handler_noop(): """Test that _make_event_handler creates noop for unknown events.""" class MinimalPlugin(BasePlugin): manifest = PluginManifest( name="minimal3", version="1.0.0", display_name="Minimal", events=["unknown.event"], ) plugin = MinimalPlugin() handler = plugin._make_event_handler("unknown.event") assert handler is not None # It should be callable assert callable(handler) @pytest.mark.asyncio async def test_service_manifest_schema(): """Test plugin service get_manifest_schema returns dict.""" from app.services.plugin_service import PluginService service = PluginService() schema = service.get_manifest_schema() assert isinstance(schema, dict) assert "fields" in schema assert "example" in schema @pytest.mark.asyncio async def test_uninstall_inactive_plugin(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test uninstalling a plugin that is installed but not active.""" registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) # Install only (not activated) await registry.install(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() # Uninstall should work without deactivation record = await registry.uninstall(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() assert record.status == "uninstalled" @pytest.mark.asyncio async def test_registry_activate_with_app_routes(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test that activating a plugin with a FastAPI app registers routes.""" from fastapi import FastAPI, APIRouter from app.plugins.manifest import PluginManifest, PluginRouteDef # Create a plugin with a route class RoutePlugin(BasePlugin): manifest = PluginManifest( name="route_plugin", version="1.0.0", display_name="Route Plugin", events=["test.event"], ) def get_routes(self) -> list: test_router = APIRouter() @test_router.get("/api/v1/route-plugin/test") async def test_endpoint(): return {"status": "ok"} return [test_router] app = FastAPI() registry = reset_registry_for_testing() registry.initialize(engine, app) registry.register_plugin(RoutePlugin()) # Install and activate await registry.install(db_session_for_plugins, "route_plugin") await db_session_for_plugins.commit() await registry.activate(db_session_for_plugins, "route_plugin") await db_session_for_plugins.commit() # Verify route was mounted route_paths = [r.path for r in app.router.routes] assert "/api/v1/route-plugin/test" in route_paths # Deactivate — route should be unmounted await registry.deactivate(db_session_for_plugins, "route_plugin") await db_session_for_plugins.commit() route_paths_after = [r.path for r in app.router.routes] assert "/api/v1/route-plugin/test" not in route_paths_after @pytest.mark.asyncio async def test_registry_uninstall_with_remove_data(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test registry uninstall with remove_data drops tables.""" registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) await registry.install(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() record = await registry.uninstall(db_session_for_plugins, "test_sample", remove_data=True) await db_session_for_plugins.commit() assert record.status == "uninstalled" assert "plugin_test_data" in getattr(record, "dropped_tables", []) @pytest.mark.asyncio async def test_registry_not_initialized_errors(): """Test that uninitialized registry raises RuntimeError.""" registry = reset_registry_for_testing() with pytest.raises(RuntimeError, match="not initialized"): _ = registry.migration_runner with pytest.raises(RuntimeError, match="not initialized"): _ = registry.engine @pytest.mark.asyncio async def test_registry_deactivate_already_inactive_direct(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test registry.deactivate is idempotent when already inactive.""" registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) await registry.install(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() # Deactivate without activating first — status is 'installed', not 'inactive' # But calling deactivate should still work (sets to inactive) record = await registry.deactivate(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() assert record.status == "inactive" # Deactivate again — idempotent record2 = await registry.deactivate(db_session_for_plugins, "test_sample") await db_session_for_plugins.commit() assert record2.status == "inactive" @pytest.mark.asyncio async def test_plugin_service_install_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test plugin service raises ValueError for unknown plugin.""" from app.services.plugin_service import PluginService registry = reset_registry_for_testing() registry.initialize(engine, None) service = PluginService(registry=registry) with pytest.raises(ValueError, match="not found"): await service.install_plugin(db_session_for_plugins, "nonexistent") @pytest.mark.asyncio async def test_plugin_service_activate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test plugin service raises ValueError when activating uninstalled plugin.""" from app.services.plugin_service import PluginService registry = reset_registry_for_testing() registry.initialize(engine, None) registry.register_plugin(TestSamplePlugin()) service = PluginService(registry=registry) with pytest.raises(ValueError, match="not installed"): await service.activate_plugin(db_session_for_plugins, "test_sample") @pytest.mark.asyncio async def test_plugin_service_deactivate_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test plugin service raises ValueError for deactivating unknown plugin.""" from app.services.plugin_service import PluginService registry = reset_registry_for_testing() registry.initialize(engine, None) service = PluginService(registry=registry) with pytest.raises(ValueError, match="not found"): await service.deactivate_plugin(db_session_for_plugins, "nonexistent") @pytest.mark.asyncio async def test_plugin_service_uninstall_error_handling(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test plugin service raises ValueError for uninstalling unknown plugin.""" from app.services.plugin_service import PluginService registry = reset_registry_for_testing() registry.initialize(engine, None) service = PluginService(registry=registry) with pytest.raises(ValueError, match="not found"): await service.uninstall_plugin(db_session_for_plugins, "nonexistent") @pytest.mark.asyncio async def test_migration_runner_split_sql_dollar_quotes(): """Test SQL splitting with dollar-quoted blocks.""" sql = "CREATE FUNCTION foo() RETURNS void AS $$\nBEGIN\nEND;\n$$ LANGUAGE plpgsql;\nCREATE TABLE bar();\n" statements = MigrationRunner._split_sql(sql) assert len(statements) >= 2 @pytest.mark.asyncio async def test_registry_list_plugins_db_only_record(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test listing plugins includes DB-only records (installed but not discovered).""" registry = reset_registry_for_testing() registry.initialize(engine, None) # Manually create a DB record without registering the plugin record = PluginModel( name="orphan_plugin", display_name="Orphan Plugin", version="0.1.0", status="installed", installed=True, active=False, ) db_session_for_plugins.add(record) await db_session_for_plugins.commit() plugins = await registry.list_plugins(db_session_for_plugins) names = [p["name"] for p in plugins] assert "orphan_plugin" in names orphan = [p for p in plugins if p["name"] == "orphan_plugin"][0] assert orphan["installed"] is True @pytest.mark.asyncio async def test_base_plugin_on_event_fallback(): """Test that on_event fallback handler works.""" class FallbackPlugin(BasePlugin): manifest = PluginManifest( name="fallback", version="1.0.0", display_name="Fallback", events=["custom.event"], ) async def on_event(self, payload: dict[str, Any]) -> None: self.event_received = payload plugin = FallbackPlugin() handler = plugin._make_event_handler("custom.event") await handler({"test": True}) assert plugin.event_received == {"test": True} @pytest.mark.asyncio async def test_migration_runner_valid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test MigrationRunner directly with a valid migration (has tenant_id).""" runner = MigrationRunner(engine) record = await runner.run_migration( db_session_for_plugins, "test_plugin", "0001_test_plugin.sql", ) await db_session_for_plugins.commit() assert record.plugin_name == "test_plugin" assert record.migration_file == "0001_test_plugin.sql" assert record.status == "applied" @pytest.mark.asyncio async def test_migration_runner_invalid_migration(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test MigrationRunner directly rejects migration without tenant_id.""" runner = MigrationRunner(engine) with pytest.raises(MigrationValidationError) as exc_info: await runner.run_migration( db_session_for_plugins, "bad_plugin", "0001_bad_migration.sql", ) assert "tenant_id" in str(exc_info.value).lower() @pytest.mark.asyncio async def test_migration_runner_idempotent(engine: AsyncEngine, db_session_for_plugins: AsyncSession): """Test MigrationRunner skips already-applied migrations.""" runner = MigrationRunner(engine) # Run migration await runner.run_migration(db_session_for_plugins, "test_idempotent", "0001_test_plugin.sql") await db_session_for_plugins.commit() # Run all migrations again — should skip records = await runner.run_all_migrations( db_session_for_plugins, "test_idempotent", ["0001_test_plugin.sql"] ) assert len(records) == 0, "Already-applied migration should be skipped"