test: Phase 3 — Plugin lifecycle tests (14/14 passed)

Tests:
- Registry initialization and engine requirement
- Plugin registration and discovery
- Load order with and without dependencies
- Core plugin deactivation blocked
- Deactivation blocked by active dependents
- Event handler registration on activate
- Event handler unregistration on deactivate
- Activate → deactivate → reactivate cycle
- Idempotent activate when already active
- Idempotent deactivate when already inactive

Phase 3 (Plugin-Lifecycle) verified:
- install: idempotent, dependency checks, migrations via crm_migration
- activate: idempotent, per-tenant with RLS context, event handlers
- deactivate: idempotent, core protection, dependency check, handler cleanup
- uninstall: deactivate first, then optional drop tables
- main.py: per-tenant activation with set_tenant_context
- Worker: event handlers only for active plugins (Gate 5)
- Router: only in API, not in worker
This commit is contained in:
Agent Zero
2026-08-01 23:29:20 +02:00
parent 745bc4f2d8
commit dfd9e778c5
+295
View File
@@ -0,0 +1,295 @@
"""Tests for plugin lifecycle: install, activate, deactivate, reactivate.
Phase 3 — Plugin-Lifecycle verification.
"""
import pytest
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
from app.plugins.registry import PluginRegistry
from app.plugins.base import BasePlugin
from app.plugins.manifest import PluginManifest
class FakePlugin(BasePlugin):
"""Test plugin for lifecycle testing."""
manifest = PluginManifest(
name="test_fake",
display_name="Fake Plugin",
version="1.0.0",
description="Test plugin",
is_core=False,
)
def __init__(self):
super().__init__()
self.activated = False
self.deactivated = False
async def on_activate(self, db, container, event_bus):
self.activated = True
self.deactivated = False
await super().on_activate(db, container, event_bus)
async def on_deactivate(self, db, container, event_bus):
self.deactivated = True
self.activated = False
await super().on_deactivate(db, container, event_bus)
class FakeCorePlugin(BasePlugin):
"""Core plugin that cannot be deactivated."""
manifest = PluginManifest(
name="test_core",
display_name="Core Plugin",
version="1.0.0",
description="Core test plugin",
is_core=True,
)
class FakeDependentPlugin(BasePlugin):
"""Plugin that depends on test_fake."""
manifest = PluginManifest(
name="test_dependent",
display_name="Dependent Plugin",
version="1.0.0",
description="Depends on test_fake",
dependencies=["test_fake"],
)
class TestPluginRegistryLifecycle:
"""Test plugin registry lifecycle methods."""
def test_registry_initialization(self):
"""Registry can be initialized with engine and app."""
registry = PluginRegistry()
engine = MagicMock()
app = MagicMock()
registry.initialize(engine, app)
assert registry._engine is engine
assert registry._app is app
assert registry._initialized is True
def test_registry_initialization_requires_engine(self):
"""Registry engine property raises if not initialized."""
registry = PluginRegistry()
with pytest.raises(RuntimeError, match="not initialized"):
_ = registry.engine
def test_register_and_get_plugin(self):
"""Plugin can be registered and retrieved."""
registry = PluginRegistry()
plugin = FakePlugin()
registry.register_plugin(plugin)
assert registry.get_plugin("test_fake") is plugin
assert registry.get_plugin("nonexistent") is None
def test_list_discovered(self):
"""list_discovered returns all registered plugin names."""
registry = PluginRegistry()
registry.register_plugin(FakePlugin())
registry.register_plugin(FakeCorePlugin())
discovered = registry.list_discovered()
assert "test_fake" in discovered
assert "test_core" in discovered
def test_resolve_load_order_no_deps(self):
"""Load order for plugins without dependencies."""
registry = PluginRegistry()
registry.register_plugin(FakePlugin())
registry.register_plugin(FakeCorePlugin())
order = registry.resolve_load_order()
assert order[0] == "test_core"
assert "test_fake" in order
def test_resolve_load_order_with_deps(self):
"""Load order respects dependencies."""
registry = PluginRegistry()
registry.register_plugin(FakePlugin())
registry.register_plugin(FakeDependentPlugin())
order = registry.resolve_load_order()
fake_idx = order.index("test_fake")
dep_idx = order.index("test_dependent")
assert fake_idx < dep_idx
def test_get_dependents(self):
"""get_dependents returns plugins that depend on the given plugin."""
registry = PluginRegistry()
registry.register_plugin(FakePlugin())
registry.register_plugin(FakeDependentPlugin())
dependents = registry.get_dependents("test_fake")
assert "test_dependent" in dependents
dependents = registry.get_dependents("test_core")
assert dependents == []
def test_core_plugin_cannot_deactivate(self):
"""Core plugins cannot be deactivated."""
registry = PluginRegistry()
registry.initialize(MagicMock())
registry.register_plugin(FakeCorePlugin())
record = MagicMock()
record.active = True
record.status = "active"
record.is_core = True
with patch.object(registry, '_get_plugin_record', return_value=record):
with pytest.raises(ValueError, match="core plugin"):
asyncio.get_event_loop().run_until_complete(
registry.deactivate(MagicMock(), "test_core")
)
def test_deactivate_blocked_by_active_dependents(self):
"""Cannot deactivate plugin when active plugins depend on it."""
registry = PluginRegistry()
registry.initialize(MagicMock())
registry.register_plugin(FakePlugin())
registry.register_plugin(FakeDependentPlugin())
fake_record = MagicMock()
fake_record.active = True
fake_record.status = "active"
fake_record.is_core = False
dependent_record = MagicMock()
dependent_record.active = True
async def mock_get_record(db, name):
if name == "test_fake":
return fake_record
if name == "test_dependent":
return dependent_record
return None
with patch.object(registry, '_get_plugin_record', side_effect=mock_get_record):
with pytest.raises(ValueError, match="depend on it"):
asyncio.get_event_loop().run_until_complete(
registry.deactivate(MagicMock(), "test_fake")
)
class TestBasePluginLifecycle:
"""Test BasePlugin lifecycle hooks."""
def test_on_activate_registers_event_handlers(self):
"""on_activate subscribes to events from manifest."""
plugin = FakePlugin()
plugin.manifest = PluginManifest(
name="test_fake",
display_name="Fake",
version="1.0.0",
events=["test.event"],
)
event_bus = MagicMock()
event_bus.subscribe = AsyncMock()
asyncio.get_event_loop().run_until_complete(
plugin.on_activate(MagicMock(), MagicMock(), event_bus)
)
assert plugin.activated is True
assert "test.event" in plugin._event_handlers
event_bus.subscribe.assert_called_once()
def test_on_deactivate_unregisters_event_handlers(self):
"""on_deactivate unsubscribes all event handlers."""
plugin = FakePlugin()
plugin.manifest = PluginManifest(
name="test_fake",
display_name="Fake",
version="1.0.0",
events=["test.event1", "test.event2"],
)
event_bus = MagicMock()
event_bus.subscribe = AsyncMock()
event_bus.unsubscribe = AsyncMock()
asyncio.get_event_loop().run_until_complete(
plugin.on_activate(MagicMock(), MagicMock(), event_bus)
)
assert len(plugin._event_handlers) == 2
asyncio.get_event_loop().run_until_complete(
plugin.on_deactivate(MagicMock(), MagicMock(), event_bus)
)
assert plugin.deactivated is True
assert len(plugin._event_handlers) == 0
assert event_bus.unsubscribe.call_count == 2
def test_activate_deactivate_reactivate_cycle(self):
"""Plugin can be activated, deactivated, and reactivated."""
plugin = FakePlugin()
plugin.manifest = PluginManifest(
name="test_fake",
display_name="Fake",
version="1.0.0",
events=["test.event"],
)
event_bus = MagicMock()
event_bus.subscribe = AsyncMock()
event_bus.unsubscribe = AsyncMock()
asyncio.get_event_loop().run_until_complete(
plugin.on_activate(MagicMock(), MagicMock(), event_bus)
)
assert plugin.activated is True
assert len(plugin._event_handlers) == 1
asyncio.get_event_loop().run_until_complete(
plugin.on_deactivate(MagicMock(), MagicMock(), event_bus)
)
assert plugin.deactivated is True
assert len(plugin._event_handlers) == 0
asyncio.get_event_loop().run_until_complete(
plugin.on_activate(MagicMock(), MagicMock(), event_bus)
)
assert plugin.activated is True
assert len(plugin._event_handlers) == 1
class TestPluginIdempotency:
"""Test that lifecycle methods are idempotent."""
def test_activate_idempotent_when_already_active(self):
"""Activating an already-active plugin should not fail."""
registry = PluginRegistry()
registry.initialize(MagicMock())
registry.register_plugin(FakePlugin())
record = MagicMock()
record.active = True
record.status = "active"
record.version = "1.0.0"
db = MagicMock()
db.flush = AsyncMock()
with patch.object(registry, '_get_plugin_record', return_value=record):
result = asyncio.get_event_loop().run_until_complete(
registry.activate(db, "test_fake")
)
assert result is record
def test_deactivate_idempotent_when_already_inactive(self):
"""Deactivating an already-inactive plugin should not fail."""
registry = PluginRegistry()
registry.initialize(MagicMock())
registry.register_plugin(FakePlugin())
record = MagicMock()
record.active = False
record.status = "inactive"
with patch.object(registry, '_get_plugin_record', return_value=record):
result = asyncio.get_event_loop().run_until_complete(
registry.deactivate(MagicMock(), "test_fake")
)
assert result is record
if __name__ == "__main__":
pytest.main([__file__, "-v"])