From dfd9e778c5e97ff7b7ec8445348667038ea66f06 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 1 Aug 2026 23:29:20 +0200 Subject: [PATCH] =?UTF-8?q?test:=20Phase=203=20=E2=80=94=20Plugin=20lifecy?= =?UTF-8?q?cle=20tests=20(14/14=20passed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/test_plugin_lifecycle.py | 295 +++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100644 tests/test_plugin_lifecycle.py diff --git a/tests/test_plugin_lifecycle.py b/tests/test_plugin_lifecycle.py new file mode 100644 index 0000000..2b02e3d --- /dev/null +++ b/tests/test_plugin_lifecycle.py @@ -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"])