"""Tests for Phase D — Restore Registry, History Hooks, Trash, Bulk Restore, Retention.""" from __future__ import annotations import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.core.restore_registry import ( RestoreConfig, RestoreRegistry, get_restore_registry, reset_restore_registry_for_testing, ) from app.core.history_hooks import ( _extract_entity_id, register_history_hooks, reset_history_hooks_for_testing, ) from app.services.entity_history_service import ( archive_old_history, bulk_restore, list_trash, record_history, restore_from_history, ) # ─── Restore Registry Tests ─── class TestRestoreRegistry: """Tests for the RestoreRegistry singleton.""" def setup_method(self): reset_restore_registry_for_testing() def test_register_and_get(self): reg = get_restore_registry() config = RestoreConfig( entity_type="test_entity", model_class=MagicMock, restore_permission="test:write", ) reg.register(config) assert reg.get("test_entity") is config def test_get_unregistered_returns_none(self): reg = get_restore_registry() assert reg.get("nonexistent") is None def test_is_registered(self): reg = get_restore_registry() config = RestoreConfig( entity_type="test_entity", model_class=MagicMock, restore_permission="test:write", ) reg.register(config) assert reg.is_registered("test_entity") is True assert reg.is_registered("nonexistent") is False def test_list_registered(self): reg = get_restore_registry() reg.register(RestoreConfig("alpha", MagicMock, "a:write")) reg.register(RestoreConfig("beta", MagicMock, "b:write")) assert reg.list_registered() == ["alpha", "beta"] def test_overwrite_warns(self, caplog): reg = get_restore_registry() reg.register(RestoreConfig("test", MagicMock, "t:write")) reg.register(RestoreConfig("test", MagicMock, "t:write2")) assert reg.get("test").restore_permission == "t:write2" def test_excluded_fields_merge(self): config = RestoreConfig( entity_type="test", model_class=MagicMock, restore_permission="t:write", excluded_fields=frozenset({"custom_field", "secret"}), ) excluded = config.all_excluded_fields assert "id" in excluded assert "tenant_id" in excluded assert "deleted_at" in excluded assert "custom_field" in excluded assert "secret" in excluded class TestRestoreFromHistory: """Tests for restore_from_history with registry.""" @pytest.mark.asyncio async def test_restore_unsupported_entity_type_raises(self): """Restore should raise ValueError for unregistered entity types.""" reset_restore_registry_for_testing() db = AsyncMock() tenant_id = uuid.uuid4() user_id = uuid.uuid4() history_id = uuid.uuid4() # Mock get_history_entry to return an entry with unregistered type with patch("app.services.entity_history_service.get_history_entry") as mock_get: mock_entry = MagicMock() mock_entry.entity_type = "unsupported_type" mock_entry.entity_id = uuid.uuid4() mock_entry.action = "delete" mock_get.return_value = mock_entry with pytest.raises(ValueError, match="Unsupported entity type"): await restore_from_history(db, tenant_id, history_id, user_id) @pytest.mark.asyncio async def test_restore_history_not_found_raises(self): """Restore should raise ValueError if history entry not found.""" reset_restore_registry_for_testing() db = AsyncMock() tenant_id = uuid.uuid4() user_id = uuid.uuid4() history_id = uuid.uuid4() with patch("app.services.entity_history_service.get_history_entry") as mock_get: mock_get.return_value = None with pytest.raises(ValueError, match="History entry not found"): await restore_from_history(db, tenant_id, history_id, user_id) # ─── History Hooks Tests ─── class TestHistoryHooks: """Tests for hook-based history recording.""" def test_extract_entity_id_from_uuid(self): eid = uuid.uuid4() assert _extract_entity_id({"id": eid}) == eid def test_extract_entity_id_from_string(self): eid = uuid.uuid4() assert _extract_entity_id({"id": str(eid)}) == eid def test_extract_entity_id_none(self): assert _extract_entity_id(None) is None assert _extract_entity_id({}) is None assert _extract_entity_id({"id": None}) is None def test_extract_entity_id_invalid(self): assert _extract_entity_id({"id": "not-a-uuid"}) is None def test_register_history_hooks(self): """Test that hooks are registered correctly.""" reset_history_hooks_for_testing() from app.core.hooks import get_hook_registry reg = get_hook_registry() register_history_hooks( reg, "test_entity", "test_entity.after_create", "test_entity.after_update", "test_entity.after_delete", ) assert reg.has_action("test_entity.after_create") assert reg.has_action("test_entity.after_update") assert reg.has_action("test_entity.after_delete") @pytest.mark.asyncio async def test_history_hook_create_calls_record_history(self): """Test that after_create hook calls record_history.""" reset_history_hooks_for_testing() from app.core.hooks import do_action, get_hook_registry reg = get_hook_registry() register_history_hooks( reg, "test_entity", "test_entity.after_create", "test_entity.after_update", "test_entity.after_delete", ) db = AsyncMock() tenant_id = uuid.uuid4() user_id = uuid.uuid4() entity_id = uuid.uuid4() with patch("app.core.history_hooks.record_history", new_callable=AsyncMock) as mock_rh: await do_action( "test_entity.after_create", {"id": str(entity_id), "name": "Test"}, db=db, tenant_id=tenant_id, user_id=user_id, ) mock_rh.assert_called_once() call_args = mock_rh.call_args # record_history(db, tenant_id, user_id, entity_type, entity_id, action=..., snapshot_after=...) # args: (db, tenant_id, user_id, entity_type, entity_id) # kwargs: action=..., snapshot_after=... assert call_args.args[3] == "test_entity" # entity_type assert call_args.args[4] == entity_id # entity_id assert call_args.kwargs.get("action") == "create" # action # ─── Trash List Tests ─── class TestTrashList: """Tests for list_trash service function.""" @pytest.mark.asyncio async def test_list_trash_returns_delete_entries(self): """list_trash should only return delete actions.""" db = AsyncMock() tenant_id = uuid.uuid4() # Mock the query results mock_entry1 = MagicMock() mock_entry1.id = uuid.uuid4() mock_entry1.entity_type = "contact" mock_entry1.entity_id = uuid.uuid4() mock_entry1.snapshot_before = {"name": "John"} mock_entry1.created_at = datetime.now(timezone.utc) mock_entry1.user_id = uuid.uuid4() mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [mock_entry1] mock_count_result = MagicMock() mock_count_result.scalar.return_value = 1 db.execute = AsyncMock(side_effect=[mock_count_result, mock_result]) result = await list_trash(db, tenant_id, limit=50, offset=0) assert result["total"] == 1 assert len(result["items"]) == 1 assert result["items"][0]["entity_type"] == "contact" @pytest.mark.asyncio async def test_list_trash_with_entity_type_filter(self): """list_trash should filter by entity_type.""" db = AsyncMock() tenant_id = uuid.uuid4() mock_result = MagicMock() mock_result.scalars.return_value.all.return_value = [] mock_count_result = MagicMock() mock_count_result.scalar.return_value = 0 db.execute = AsyncMock(side_effect=[mock_count_result, mock_result]) result = await list_trash(db, tenant_id, entity_type="task", limit=50, offset=0) assert result["total"] == 0 assert result["items"] == [] # ─── Bulk Restore Tests ─── class TestBulkRestore: """Tests for bulk_restore service function.""" @pytest.mark.asyncio async def test_bulk_restore_all_success(self): """bulk_restore should succeed for all valid entries.""" db = AsyncMock() tenant_id = uuid.uuid4() user_id = uuid.uuid4() hid1 = uuid.uuid4() hid2 = uuid.uuid4() with patch("app.services.entity_history_service.restore_from_history", new_callable=AsyncMock) as mock_restore: mock_restore.side_effect = [ {"id": str(uuid.uuid4()), "_entity_type": "contact", "_restored": True}, {"id": str(uuid.uuid4()), "_entity_type": "task", "_restored": True}, ] result = await bulk_restore(db, tenant_id, [hid1, hid2], user_id) assert result["total"] == 2 assert result["succeeded"] == 2 assert result["failed"] == 0 assert result["partial_success"] is False @pytest.mark.asyncio async def test_bulk_restore_partial_failure(self): """bulk_restore should report partial_success when some fail.""" db = AsyncMock() tenant_id = uuid.uuid4() user_id = uuid.uuid4() hid1 = uuid.uuid4() hid2 = uuid.uuid4() with patch("app.services.entity_history_service.restore_from_history", new_callable=AsyncMock) as mock_restore: mock_restore.side_effect = [ {"id": str(uuid.uuid4()), "_entity_type": "contact"}, ValueError("Entity not found"), ] result = await bulk_restore(db, tenant_id, [hid1, hid2], user_id) assert result["total"] == 2 assert result["succeeded"] == 1 assert result["failed"] == 1 assert result["partial_success"] is True assert result["results"][1]["error"] == "Entity not found" @pytest.mark.asyncio async def test_bulk_restore_all_fail(self): """bulk_restore should report no partial_success when all fail.""" db = AsyncMock() tenant_id = uuid.uuid4() user_id = uuid.uuid4() with patch("app.services.entity_history_service.restore_from_history", new_callable=AsyncMock) as mock_restore: mock_restore.side_effect = [ValueError("Not found"), ValueError("Not found")] result = await bulk_restore(db, tenant_id, [uuid.uuid4(), uuid.uuid4()], user_id) assert result["succeeded"] == 0 assert result["failed"] == 2 assert result["partial_success"] is False # ─── Retention Tests ─── class TestRetention: """Tests for archive_old_history service function.""" @pytest.mark.asyncio async def test_archive_old_history_returns_count(self): """archive_old_history should return the number of archived entries.""" db = AsyncMock() tenant_id = uuid.uuid4() mock_result = MagicMock() mock_result.rowcount = 42 db.execute = AsyncMock(return_value=mock_result) count = await archive_old_history(db, tenant_id, days=90) assert count == 42 db.flush.assert_called_once() @pytest.mark.asyncio async def test_archive_old_history_zero_when_none(self): """archive_old_history should return 0 when no entries to archive.""" db = AsyncMock() tenant_id = uuid.uuid4() mock_result = MagicMock() mock_result.rowcount = 0 db.execute = AsyncMock(return_value=mock_result) count = await archive_old_history(db, tenant_id, days=90) assert count == 0 # ─── Sensitive Fields Exclusion Tests ─── class TestSensitiveFieldsExclusion: """Tests that sensitive fields are excluded from restore.""" def test_default_excluded_fields(self): """Default excluded fields should include id, tenant_id, timestamps.""" from app.core.restore_registry import _DEFAULT_EXCLUDED assert "id" in _DEFAULT_EXCLUDED assert "tenant_id" in _DEFAULT_EXCLUDED assert "created_at" in _DEFAULT_EXCLUDED assert "updated_at" in _DEFAULT_EXCLUDED assert "deleted_at" in _DEFAULT_EXCLUDED assert "search_tsv" in _DEFAULT_EXCLUDED assert "embedding" in _DEFAULT_EXCLUDED def test_contact_excluded_fields(self): """Contact should exclude search_tsv, embedding, and relationship IDs.""" reset_restore_registry_for_testing() from app.core.restore_registry import register_default_entities register_default_entities() reg = get_restore_registry() config = reg.get("contact") assert config is not None excluded = config.all_excluded_fields assert "search_tsv" in excluded assert "embedding" in excluded assert "default_person_id" in excluded assert "admin_contactperson_id" in excluded def test_dms_file_excludes_storage_path(self): """DMS File should exclude storage_path, content_hash, size_bytes. Plugin entities are registered by their plugins in on_activate(), not by register_default_entities() (P0-7 fix). """ reset_restore_registry_for_testing() from app.core.restore_registry import RestoreConfig, get_restore_registry from app.plugins.builtins.dms.models import File as DmsFile reg = get_restore_registry() reg.register(RestoreConfig( entity_type="dms_file", model_class=DmsFile, restore_permission="dms:write", excluded_fields=frozenset({ "storage_path", "content_hash", "size_bytes", "uploaded_by", "folder_id", }), )) config = reg.get("dms_file") assert config is not None excluded = config.all_excluded_fields assert "storage_path" in excluded assert "content_hash" in excluded assert "size_bytes" in excluded assert "uploaded_by" in excluded def test_mail_excludes_message_id_and_raw_path(self): """Mail should exclude message_id, rfc822_size, raw_path. Plugin entities are registered by their plugins in on_activate(), not by register_default_entities() (P0-7 fix). """ reset_restore_registry_for_testing() from app.core.restore_registry import RestoreConfig, get_restore_registry from app.plugins.builtins.mail.plugin import _mail_restore_handler from app.plugins.builtins.mail.models import Mail reg = get_restore_registry() reg.register(RestoreConfig( entity_type="mail", model_class=Mail, restore_permission="mail:write", excluded_fields=frozenset({ "message_id", "rfc822_size", "raw_path", "account_id", "folder_id", }), special_handler=_mail_restore_handler, )) config = reg.get("mail") assert config is not None excluded = config.all_excluded_fields assert "message_id" in excluded assert "rfc822_size" in excluded assert "raw_path" in excluded assert config.special_handler is not None def test_all_default_entities_registered(self): """register_default_entities should register only Core entity types. Plugin entities (task, calendar_entry, dms_file, mail) are registered by their respective plugins in on_activate() (P0-7 fix). """ reset_restore_registry_for_testing() from app.core.restore_registry import register_default_entities register_default_entities() reg = get_restore_registry() registered = reg.list_registered() # Only Core entity (contact) is registered by register_default_entities() assert "contact" in registered # Plugin entities are registered by their plugins in on_activate() assert len(registered) == 1