T03: plugin system framework + lifecycle + migrations + event bus + DI
- Plugin registry with discover/install/activate/deactivate/uninstall lifecycle - PluginManifest Pydantic v2 schema (name, version, dependencies, routes, events, migrations) - BasePlugin abstract class with lifecycle hooks (on_install/activate/deactivate/uninstall) - Migration runner with tenant_id validator (rejects tables without tenant_id) - Event bus integration: register/unregister listeners on activate/deactivate - Service container DI: plugins receive db, cache, event_bus, storage, notifications - Idempotent operations (activate active=200, deactivate inactive=200) - UI registry for frontend component registration - 47 new tests (14 ACs + 33 unit tests), 103 total tests pass - Migration 0003: plugins + plugin_migrations tables - Coverage: 85.92% for plugin modules
This commit is contained in:
@@ -0,0 +1,926 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user