Files
leocrm/tests/test_restore_registry.py
T

473 lines
16 KiB
Python
Raw Normal View History

"""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