"""Test: Contacts plugin lifecycle — deactivation deregisters everything. Verifies that when the Contacts plugin is deactivated: - Entity models (contact, contacts, company) are removed from ENTITY_MODELS - Permissions (contacts:read, contacts:write, contacts:delete) are unregistered - Restore config for 'contact' entity type is removed - History hooks for 'contact' are unregistered - The app still starts and core functionality works """ from __future__ import annotations import pytest from app.core.restore_registry import get_restore_registry, reset_restore_registry_for_testing from app.core.hooks import get_hook_registry from app.core.permission_registry import ( get_permission_registry, init_permission_registry, ) from app.services.entity_permission_service import ENTITY_MODELS class TestContactsPluginLifecycle: """Test Contacts plugin full lifecycle — activate, deactivate, verify deregistration.""" @pytest.fixture(autouse=True) def _setup_registries(self): """Reset registries before each test.""" reset_restore_registry_for_testing() init_permission_registry(set()) get_hook_registry()._reset_for_testing() yield # Cleanup reset_restore_registry_for_testing() init_permission_registry(set()) get_hook_registry()._reset_for_testing() def test_contacts_plugin_registers_on_activate(self): """ContactsPlugin.on_activate registers entity models, permissions, restore, history.""" from app.plugins.builtins.contacts.plugin import ContactsPlugin from unittest.mock import AsyncMock, MagicMock plugin = ContactsPlugin() # Before activation: nothing registered reg = get_restore_registry() assert not reg.is_registered("contact") # Audit P2 (false-green): this used to be # ``assert "contact" not in ENTITY_MODELS or True`` — always true. # Entity models are registered by the conftest bootstrap for ALL # discovered plugins (mirroring main.py), so "contact" IS present # before on_activate; what must NOT be registered yet is the # restore config (checked above) and the plugin gate: assert not get_permission_registry().is_plugin_active("contacts") # Activate import asyncio db = AsyncMock() container = MagicMock() event_bus = MagicMock() event_bus.subscribe = MagicMock() event_bus.unsubscribe = MagicMock() asyncio.get_event_loop().run_until_complete( plugin.on_activate(db, container, event_bus) ) # Verify restore config registered assert reg.is_registered("contact") config = reg.get("contact") assert config is not None assert config.restore_permission == "contacts:write" # Verify history hooks registered (hook registry has actions) hook_reg = get_hook_registry() # The hooks should be registered for contact.after_create/update/delete all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {} assert any("contact.after_create" in k for k in all_hooks) def test_contacts_plugin_deregisters_on_deactivate(self): """ContactsPlugin.on_deactivate removes restore config and history hooks.""" from app.plugins.builtins.contacts.plugin import ContactsPlugin from unittest.mock import AsyncMock, MagicMock plugin = ContactsPlugin() db = AsyncMock() container = MagicMock() event_bus = MagicMock() event_bus.subscribe = MagicMock() event_bus.unsubscribe = MagicMock() import asyncio loop = asyncio.get_event_loop() # Activate first loop.run_until_complete(plugin.on_activate(db, container, event_bus)) assert get_restore_registry().is_registered("contact") # Deactivate loop.run_until_complete(plugin.on_deactivate(db, container, event_bus)) # Verify restore config removed assert not get_restore_registry().is_registered("contact") # Verify history hooks removed (hook registry should have no contact hooks) hook_reg = get_hook_registry() all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {} assert not any("contact.after_create" in k for k in all_hooks) def test_contacts_permissions_from_manifest_not_core(self): """contacts:* permissions come from plugin manifest, not CORE_PERMISSIONS.""" from app.core.permission_registry import CORE_PERMISSIONS from app.plugins.builtins.contacts.plugin import ContactsPlugin # contacts:* should NOT be in CORE_PERMISSIONS core_keys = {p["key"] for p in CORE_PERMISSIONS} assert "contacts:read" not in core_keys assert "contacts:write" not in core_keys assert "contacts:delete" not in core_keys # contacts:* should be in plugin manifest permissions plugin = ContactsPlugin() assert "contacts:read" in plugin.manifest.permissions assert "contacts:write" in plugin.manifest.permissions assert "contacts:delete" in plugin.manifest.permissions def test_contacts_entity_models_from_plugin(self): """Contact entity models come from plugin get_entity_models(), not hardcoded.""" from app.plugins.builtins.contacts.plugin import ContactsPlugin plugin = ContactsPlugin() models = plugin.get_entity_models() assert "contact" in models assert "contacts" in models assert "company" in models # All should be the same Contact model from app.models.contact import Contact assert models["contact"] is Contact assert models["contacts"] is Contact assert models["company"] is Contact def test_app_still_starts_without_contacts_special_case(self): """App creates successfully with Contacts as a plugin, not a Core special case. Audit P2 (false-green / stale assertion): the old ``> 100 routes`` assertion reflected the PRE-plugin architecture where business routes were hardcoded in main.py. Since the plugin refactor, create_app() mounts only CORE routes (~85); plugin routes are mounted by the registry lifecycle. The real "no special case" proof is that no contacts route is statically included. """ from app.main import create_app app = create_app() assert app is not None # Core routes must exist assert len(app.routes) > 50 # Contacts is a plugin: its routes must NOT be hardcoded in the # core app (they are mounted via registry.activate at startup). static_paths = {getattr(r, "path", "") for r in app.routes} assert not any(p.startswith("/api/v1/contacts") for p in static_paths), ( "contacts routes must not be statically mounted — Contacts is a plugin" ) def test_hook_isolation_deactivate_one_plugin_keeps_other(self): """Two plugins register on same event; deactivating one keeps the other's handler.""" from app.core.hooks import get_hook_registry, HookRegistry from app.core.history_hooks import register_history_hooks from unittest.mock import AsyncMock, MagicMock reg = get_hook_registry() reg._reset_for_testing() # Plugin A (contacts) registers history hooks for contact.after_update register_history_hooks( reg, "contact", "contact.after_create", "contact.after_update", "contact.after_delete", owner_tag="contacts", ) # Plugin B (tasks) also registers a handler on contact.after_update async def _other_plugin_handler(**kwargs): pass reg.register_action("contact.after_update", _other_plugin_handler, priority=50, owner_tag="tasks") # Verify both handlers exist actions = reg._actions.get("contact.after_update", []) assert len(actions) == 2, f"Expected 2 handlers, got {len(actions)}" # Deactivate Plugin A (contacts) — should only remove contacts' handler reg.unregister_actions_by_owner("contact.after_update", "contacts") reg.unregister_actions_by_owner("contact.after_create", "contacts") reg.unregister_actions_by_owner("contact.after_delete", "contacts") # Plugin B's handler should still be registered remaining = reg._actions.get("contact.after_update", []) assert len(remaining) == 1, f"Expected 1 remaining handler, got {len(remaining)}" assert remaining[0][1] is _other_plugin_handler, "Remaining handler should be from tasks plugin" # Cleanup reg._reset_for_testing() def test_full_lifecycle_activate_deactivate_simulated(self): """Full lifecycle: simulate the service activation sequence → verify registered → simulate deactivation → verify deregistered. Audit P2 (false-green): renamed — this test drives the plugin hooks and registry calls MANUALLY (mirroring what PluginService does), it does NOT go through the real PluginService. The true E2E test through PluginService lives in test_plugin_lifecycle_service.py. """ from app.services.plugin_service import PluginService from app.core.permission_registry import get_permission_registry, init_permission_registry from app.core.restore_registry import get_restore_registry from app.core.hooks import get_hook_registry from app.services.entity_permission_service import ENTITY_MODELS from unittest.mock import AsyncMock, MagicMock import asyncio # Reset all registries reset_restore_registry_for_testing() init_permission_registry(set()) get_hook_registry()._reset_for_testing() plugin_name = "contacts" from app.plugins.builtins.contacts.plugin import ContactsPlugin plugin = ContactsPlugin() # Simulate activation (what registry.activate + plugin_service would do) db = AsyncMock() container = MagicMock() event_bus = MagicMock() event_bus.subscribe = MagicMock() event_bus.unsubscribe = MagicMock() loop = asyncio.get_event_loop() # Activate loop.run_until_complete(plugin.on_activate(db, container, event_bus)) # Register permissions (what plugin_service.activate_plugin does) perm_reg = get_permission_registry() if plugin.manifest.permissions: from app.core.permission_registry import register_plugin_permissions register_plugin_permissions(plugin_name, plugin.manifest.permissions) perm_reg._active_plugins.add(plugin_name) # Register entity models (what plugin_service does) from app.services.entity_permission_service import register_entity_model for entity_type, model_class in plugin.get_entity_models().items(): register_entity_model(entity_type, model_class) # Verify everything is registered assert perm_reg.is_plugin_active(plugin_name), "Plugin should be active" assert get_restore_registry().is_registered("contact"), "Restore config should be registered" assert "contact" in ENTITY_MODELS, "Entity model should be registered" hook_reg = get_hook_registry() all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {} assert any("contact.after_create" in k for k in all_hooks), "History hooks should be registered" # Deactivate loop.run_until_complete(plugin.on_deactivate(db, container, event_bus)) # Unregister permissions (what plugin_service.deactivate_plugin does) from app.core.permission_registry import unregister_plugin_permissions unregister_plugin_permissions(plugin_name) perm_reg._active_plugins.discard(plugin_name) # Unregister entity models from app.services.entity_permission_service import unregister_entity_model for entity_type in plugin.get_entity_models(): unregister_entity_model(entity_type) # Verify everything is deregistered assert not perm_reg.is_plugin_active(plugin_name), "Plugin should be inactive" assert not get_restore_registry().is_registered("contact"), "Restore config should be removed" # Audit P2 (false-green): the old assertion # ``"contact" not in ENTITY_MODELS or "contact" in {...}`` was # always true. After unregister_entity_model() the type must be # GONE from the registry (contacts is plugin-owned, not core). assert "contact" not in ENTITY_MODELS, "contact must be deregistered after plugin deactivation" all_hooks = hook_reg._actions if hasattr(hook_reg, '_actions') else {} assert not any("contact.after_create" in k for k in all_hooks), "History hooks should be removed" # Cleanup reset_restore_registry_for_testing() init_permission_registry(set()) get_hook_registry()._reset_for_testing()