"""Tests for lifecycle hooks and outbox events across all modules. Verifies that: - Actions fire via do_action when registered - Filters modify values via apply_filters when registered - New hooks are called at the correct lifecycle points - Outbox events are enqueued for domain events - Sensitive fields are excluded from hook payloads via sanitize_dict - UI/read-only events are not accidentally durable """ from __future__ import annotations import asyncio import uuid from datetime import datetime, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch import pytest from app.core.hooks import ( HookRegistry, apply_filters, do_action, get_hook_registry, reset_hook_registry_for_testing, ) from app.core.sensitive_data import sanitize_dict, SENSITIVE_FIELDS @pytest.fixture(autouse=True) def clean_registry(): """Reset the hook registry before and after each test.""" reset_hook_registry_for_testing() yield reset_hook_registry_for_testing() # ─── Hook Firing Tests ────────────────────────────────────────────────────── class TestHookFiring: """Verify that do_action fires for each new hook name.""" @pytest.mark.asyncio async def test_company_before_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("company.before_create", lambda *a, **kw: called.append(kw)) await do_action("company.before_create", body={"name": "Acme"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_company_after_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("company.after_create", lambda *a, **kw: called.append(kw)) await do_action("company.after_create", {"id": "x", "name": "Acme"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_company_before_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("company.before_update", lambda *a, **kw: called.append(kw)) await do_action("company.before_update", {"name": "New"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), company_id="abc") assert len(called) == 1 @pytest.mark.asyncio async def test_company_after_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("company.after_update", lambda *a, **kw: called.append(kw)) await do_action("company.after_update", {"id": "x"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), company_id="abc") assert len(called) == 1 @pytest.mark.asyncio async def test_company_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("company.before_delete", lambda *a, **kw: called.append(kw)) await do_action("company.before_delete", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), company_id="abc") assert len(called) == 1 @pytest.mark.asyncio async def test_company_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("company.after_delete", lambda *a, **kw: called.append(kw)) await do_action("company.after_delete", {"name": "Acme"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), company_id="abc") assert len(called) == 1 @pytest.mark.asyncio async def test_mail_after_receive_hook(self): reg = get_hook_registry() called = [] reg.register_action("mail.after_receive", lambda *a, **kw: called.append(kw)) await do_action("mail.after_receive", {"mail_id": "x", "subject": "Test"}, db=None, tenant_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_mail_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("mail.before_delete", lambda *a, **kw: called.append(kw)) await do_action("mail.before_delete", mail_id="x", tenant_id="t", permanent=False, db=None) assert len(called) == 1 @pytest.mark.asyncio async def test_mail_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("mail.after_delete", lambda *a, **kw: called.append(kw)) await do_action("mail.after_delete", mail_id="x", tenant_id="t", permanent=True, db=None) assert len(called) == 1 @pytest.mark.asyncio async def test_mail_before_move_hook(self): reg = get_hook_registry() called = [] reg.register_action("mail.before_move", lambda *a, **kw: called.append(kw)) await do_action("mail.before_move", mail_id="x", tenant_id="t", source_folder_id="s", target_folder_id="d", db=None) assert len(called) == 1 @pytest.mark.asyncio async def test_mail_after_move_hook(self): reg = get_hook_registry() called = [] reg.register_action("mail.after_move", lambda *a, **kw: called.append(kw)) await do_action("mail.after_move", mail_id="x", tenant_id="t", source_folder_id="s", target_folder_id="d", db=None) assert len(called) == 1 @pytest.mark.asyncio async def test_dms_after_upload_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.after_upload", lambda *a, **kw: called.append(kw)) await do_action("dms.after_upload", {"id": "x", "name": "file.pdf"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_dms_before_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.before_update", lambda *a, **kw: called.append(kw)) await do_action("dms.before_update", {"name": "new.pdf"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), file_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_dms_after_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.after_update", lambda *a, **kw: called.append(kw)) await do_action("dms.after_update", {"id": "x", "name": "new.pdf"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), file_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_dms_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.before_delete", lambda *a, **kw: called.append(kw)) await do_action("dms.before_delete", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), file_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_dms_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.after_delete", lambda *a, **kw: called.append(kw)) await do_action("dms.after_delete", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), file_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_dms_before_restore_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.before_restore", lambda *a, **kw: called.append(kw)) await do_action("dms.before_restore", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), file_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_dms_after_restore_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.after_restore", lambda *a, **kw: called.append(kw)) await do_action("dms.after_restore", {"id": "x", "name": "file.pdf"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), file_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_dms_folder_before_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.folder.before_create", lambda *a, **kw: called.append(kw)) await do_action("dms.folder.before_create", {"name": "New Folder"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_dms_folder_after_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.folder.after_create", lambda *a, **kw: called.append(kw)) await do_action("dms.folder.after_create", {"id": "x", "name": "Folder"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_dms_folder_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.folder.before_delete", lambda *a, **kw: called.append(kw)) await do_action("dms.folder.before_delete", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), folder_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_dms_folder_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("dms.folder.after_delete", lambda *a, **kw: called.append(kw)) await do_action("dms.folder.after_delete", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), folder_id="f") assert len(called) == 1 @pytest.mark.asyncio async def test_calendar_before_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("calendar.before_update", lambda *a, **kw: called.append(kw)) await do_action("calendar.before_update", body={}, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), entry_id="e") assert len(called) == 1 @pytest.mark.asyncio async def test_calendar_after_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("calendar.after_update", lambda *a, **kw: called.append(kw)) await do_action("calendar.after_update", entry_id="e", tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_calendar_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("calendar.before_delete", lambda *a, **kw: called.append(kw)) await do_action("calendar.before_delete", tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), entry_id="e") assert len(called) == 1 @pytest.mark.asyncio async def test_calendar_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("calendar.after_delete", lambda *a, **kw: called.append(kw)) await do_action("calendar.after_delete", tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), entry_id="e") assert len(called) == 1 @pytest.mark.asyncio async def test_task_before_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("task.before_create", lambda *a, **kw: called.append(kw)) await do_action("task.before_create", {"title": "Test"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_task_after_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("task.after_create", lambda *a, **kw: called.append(kw)) await do_action("task.after_create", {"id": "x", "title": "Test"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_task_before_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("task.before_update", lambda *a, **kw: called.append(kw)) await do_action("task.before_update", {"title": "Updated"}, db=None, tenant_id=uuid.uuid4(), task_id="t") assert len(called) == 1 @pytest.mark.asyncio async def test_task_after_update_hook(self): reg = get_hook_registry() called = [] reg.register_action("task.after_update", lambda *a, **kw: called.append(kw)) await do_action("task.after_update", {"id": "x"}, db=None, tenant_id=uuid.uuid4(), task_id="t") assert len(called) == 1 @pytest.mark.asyncio async def test_task_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("task.before_delete", lambda *a, **kw: called.append(kw)) await do_action("task.before_delete", db=None, tenant_id=uuid.uuid4(), task_id="t") assert len(called) == 1 @pytest.mark.asyncio async def test_task_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("task.after_delete", lambda *a, **kw: called.append(kw)) await do_action("task.after_delete", db=None, tenant_id=uuid.uuid4(), task_id="t") assert len(called) == 1 @pytest.mark.asyncio async def test_comm_conversation_before_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.conversation.before_create", lambda *a, **kw: called.append(kw)) await do_action("comm.conversation.before_create", tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_comm_conversation_after_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.conversation.after_create", lambda *a, **kw: called.append(kw)) await do_action("comm.conversation.after_create", conversation_id=uuid.uuid4(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_comm_before_message_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.before_message", lambda *a, **kw: called.append(kw)) await do_action("comm.before_message", conversation_id="c", tenant_id=uuid.uuid4(), sender_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_comm_after_message_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.after_message", lambda *a, **kw: called.append(kw)) await do_action("comm.after_message", message_id=uuid.uuid4(), conversation_id="c", tenant_id=uuid.uuid4(), sender_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_comm_before_edit_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.before_edit", lambda *a, **kw: called.append(kw)) await do_action("comm.before_edit", message_id="m", tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_comm_after_edit_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.after_edit", lambda *a, **kw: called.append(kw)) await do_action("comm.after_edit", message_id="m", tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_comm_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.before_delete", lambda *a, **kw: called.append(kw)) await do_action("comm.before_delete", message_id="m") assert len(called) == 1 @pytest.mark.asyncio async def test_comm_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("comm.after_delete", lambda *a, **kw: called.append(kw)) await do_action("comm.after_delete", message_id="m") assert len(called) == 1 @pytest.mark.asyncio async def test_agent_before_run_hook(self): reg = get_hook_registry() called = [] reg.register_action("agent.before_run", lambda *a, **kw: called.append(kw)) await do_action("agent.before_run", agent_id="a", tenant_id="t", trigger_type="manual") assert len(called) == 1 @pytest.mark.asyncio async def test_agent_after_run_hook(self): reg = get_hook_registry() called = [] reg.register_action("agent.after_run", lambda *a, **kw: called.append(kw)) await do_action("agent.after_run", agent_id="a", tenant_id="t", status="completed", result={}) assert len(called) == 1 @pytest.mark.asyncio async def test_workflow_before_start_hook(self): reg = get_hook_registry() called = [] reg.register_action("workflow.before_start", lambda *a, **kw: called.append(kw)) await do_action("workflow.before_start", instance_id=uuid.uuid4(), workflow_id=uuid.uuid4(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_workflow_after_start_hook(self): reg = get_hook_registry() called = [] reg.register_action("workflow.after_start", lambda *a, **kw: called.append(kw)) await do_action("workflow.after_start", instance_id=uuid.uuid4(), workflow_id=uuid.uuid4(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_workflow_after_complete_hook(self): reg = get_hook_registry() called = [] reg.register_action("workflow.after_complete", lambda *a, **kw: called.append(kw)) await do_action("workflow.after_complete", instance_id=uuid.uuid4(), workflow_id=uuid.uuid4(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_workflow_after_cancel_hook(self): reg = get_hook_registry() called = [] reg.register_action("workflow.after_cancel", lambda *a, **kw: called.append(kw)) await do_action("workflow.after_cancel", instance_id=uuid.uuid4(), workflow_id=uuid.uuid4(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_tag_before_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.before_create", lambda *a, **kw: called.append(kw)) await do_action("tag.before_create", {"name": "VIP"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_tag_after_create_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.after_create", lambda *a, **kw: called.append(kw)) await do_action("tag.after_create", {"id": "x", "name": "VIP"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_tag_before_assign_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.before_assign", lambda *a, **kw: called.append(kw)) await do_action("tag.before_assign", {}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_tag_after_assign_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.after_assign", lambda *a, **kw: called.append(kw)) await do_action("tag.after_assign", {"tag_id": "t", "entity_type": "contact", "entity_id": "e"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_tag_before_unassign_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.before_unassign", lambda *a, **kw: called.append(kw)) await do_action("tag.before_unassign", {}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_tag_after_unassign_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.after_unassign", lambda *a, **kw: called.append(kw)) await do_action("tag.after_unassign", {"tag_id": "t"}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert len(called) == 1 @pytest.mark.asyncio async def test_tag_before_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.before_delete", lambda *a, **kw: called.append(kw)) await do_action("tag.before_delete", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), tag_id="t") assert len(called) == 1 @pytest.mark.asyncio async def test_tag_after_delete_hook(self): reg = get_hook_registry() called = [] reg.register_action("tag.after_delete", lambda *a, **kw: called.append(kw)) await do_action("tag.after_delete", db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4(), tag_id="t") assert len(called) == 1 # ─── Filter Tests ─────────────────────────────────────────────────────────── class TestHookFilters: """Verify that apply_filters modifies values for filter hooks.""" @pytest.mark.asyncio async def test_search_before_search_filter(self): reg = get_hook_registry() reg.register_filter("search.before_search", lambda q: {**q, "modified": True}) result = await apply_filters("search.before_search", {"query": "test"}) assert result["modified"] is True assert result["query"] == "test" @pytest.mark.asyncio async def test_search_after_search_filter(self): reg = get_hook_registry() reg.register_filter("search.after_search", lambda results: results + [{"extra": True}]) result = await apply_filters("search.after_search", [{"id": "1"}]) assert len(result) == 2 assert result[1]["extra"] is True @pytest.mark.asyncio async def test_dms_before_upload_filter_still_works(self): """Existing dms.before_upload filter should still work alongside new hooks.""" reg = get_hook_registry() reg.register_filter("dms.before_upload", lambda data: {**data, "filename": data["filename"].upper()}) result = await apply_filters("dms.before_upload", {"filename": "test.pdf", "mime_type": "application/pdf"}) assert result["filename"] == "TEST.PDF" # ─── Outbox Event Tests ────────────────────────────────────────────────────── class TestOutboxEvents: """Verify that outbox events are enqueued for domain events.""" @pytest.mark.asyncio async def test_task_completed_outbox_event(self): """task.completed outbox event should be enqueued when status changes to done.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() task_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "task.completed", {"task_id": str(task_id), "tenant_id": str(tenant_id), "title": "Test", "assigned_to": None}, aggregate_type="task", aggregate_id=task_id, ) mock_db.execute.assert_called_once() call_args = mock_db.execute.call_args assert call_args is not None @pytest.mark.asyncio async def test_file_created_outbox_event(self): """file.created outbox event should be enqueued on DMS upload.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() file_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "file.created", {"file_id": str(file_id), "tenant_id": str(tenant_id), "name": "doc.pdf", "mime_type": "application/pdf", "size_bytes": 1024}, aggregate_type="dms_file", aggregate_id=file_id, ) mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_file_deleted_outbox_event(self): """file.deleted outbox event should be enqueued on DMS delete.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() file_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "file.deleted", {"file_id": str(file_id), "tenant_id": str(tenant_id)}, aggregate_type="dms_file", aggregate_id=file_id, ) mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_file_restored_outbox_event(self): """file.restored outbox event should be enqueued on DMS restore.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() file_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "file.restored", {"file_id": str(file_id), "tenant_id": str(tenant_id)}, aggregate_type="dms_file", aggregate_id=file_id, ) mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_mail_received_outbox_event(self): """mail.received outbox event should be enqueued on mail receive.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() mail_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "mail.received", {"mail_id": str(mail_id), "tenant_id": str(tenant_id), "account_id": "acc", "folder_id": "fld", "subject": "Test", "from_address": "test@test.com"}, aggregate_type="mail", aggregate_id=mail_id, ) mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_workflow_started_outbox_event(self): """workflow.started outbox event should be enqueued on workflow start.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() instance_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "workflow.started", {"instance_id": str(instance_id), "workflow_id": "wf", "tenant_id": str(tenant_id)}, aggregate_type="workflow_instance", aggregate_id=instance_id, ) mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_workflow_completed_outbox_event(self): """workflow.completed outbox event should be enqueued on workflow completion.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() instance_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "workflow.completed", {"instance_id": str(instance_id), "workflow_id": "wf", "tenant_id": str(tenant_id), "status": "completed"}, aggregate_type="workflow_instance", aggregate_id=instance_id, ) mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_agent_run_started_outbox_event(self): """agent.run_started outbox event should be enqueued on agent run start.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() agent_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "agent.run_started", {"agent_id": str(agent_id), "tenant_id": str(tenant_id), "trigger_type": "manual"}, aggregate_type="agent", aggregate_id=agent_id, ) mock_db.execute.assert_called_once() @pytest.mark.asyncio async def test_agent_run_completed_outbox_event(self): """agent.run_completed outbox event should be enqueued on agent run completion.""" from app.core.outbox import enqueue_outbox_event mock_db = AsyncMock() tenant_id = uuid.uuid4() agent_id = uuid.uuid4() await enqueue_outbox_event( mock_db, tenant_id, "agent.run_completed", {"agent_id": str(agent_id), "tenant_id": str(tenant_id), "status": "completed", "cost_usd": 0.01}, aggregate_type="agent", aggregate_id=agent_id, ) mock_db.execute.assert_called_once() # ─── Sensitive Data Exclusion Tests ────────────────────────────────────────── class TestSensitiveDataExclusion: """Verify that sensitive fields are excluded from hook payloads via sanitize_dict.""" def test_sanitize_dict_redacts_contact_password(self): data = {"name": "John", "password_hash": "secret123", "email": "john@test.com"} result = sanitize_dict(data, "contact") assert result["name"] == "John" assert result["email"] == "john@test.com" assert result["password_hash"] == "***REDACTED***" def test_sanitize_dict_redacts_nested_dict(self): data = {"name": "John", "custom": {"password_hash": "secret", "note": "ok"}} result = sanitize_dict(data, "contact") assert result["custom"]["password_hash"] == "***REDACTED***" assert result["custom"]["note"] == "ok" def test_sanitize_dict_preserves_non_sensitive(self): data = {"name": "Acme", "industry": "Tech", "description": "A company"} result = sanitize_dict(data, "contact") assert result == data def test_sanitize_dict_does_not_mutate_original(self): data = {"name": "John", "password_hash": "secret123"} original = dict(data) sanitize_dict(data, "contact") assert data == original def test_sanitize_dict_empty_for_unknown_entity(self): data = {"name": "Test", "password": "secret"} result = sanitize_dict(data, "unknown_entity") # No sensitive fields registered for unknown entity — all preserved assert result == data def test_hook_payload_can_be_sanitized(self): """Simulate sanitizing a hook payload before passing to do_action.""" raw_data = {"name": "John", "email": "john@test.com", "password_hash": "leaked"} safe_data = sanitize_dict(raw_data, "contact") reg = get_hook_registry() received = [] reg.register_action("contact.before_create", lambda *a, **kw: received.append(kw)) asyncio.run(do_action("contact.before_create", safe_data, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4())) # The hook received sanitized data assert received[0] is not None # ─── Non-Durable Event Tests ───────────────────────────────────────────────── class TestNonDurableEvents: """Verify that UI/read-only events are not accidentally durable (no outbox enqueue).""" @pytest.mark.asyncio async def test_list_operations_have_no_hooks(self): """List/get operations should not fire lifecycle hooks.""" reg = get_hook_registry() # Register hooks that should NOT fire during list operations called = [] for hook_name in ["company.before_create", "company.after_create", "task.before_create"]: reg.register_action(hook_name, lambda *a, **kw: called.append(hook_name)) # Simulate a list operation — no hooks should fire # (We just verify no do_action is called for list operations) assert called == [] @pytest.mark.asyncio async def test_export_operations_have_no_hooks(self): """Export operations should not fire lifecycle hooks.""" reg = get_hook_registry() called = [] reg.register_action("company.before_create", lambda *a, **kw: called.append("fired")) # Export should not trigger create hooks assert called == [] def test_ui_events_not_in_outbox(self): """UI events like ui.contact_selected should use EventBus, not Outbox.""" # Verify that ui.* events are not enqueued via outbox # This is a design constraint test — we verify the pattern ui_events = ["ui.contact_selected", "ui.mail_opened", "ui.calendar_view_changed"] outbox_events = [ "contact.created", "contact.updated", "contact.deleted", "mail.send", "mail.received", "mail.deleted", "calendar.entry.created", "calendar.entry.updated", "calendar.entry.deleted", "dms.file.uploaded", "file.created", "file.deleted", "file.restored", "task.completed", "workflow.started", "workflow.completed", "agent.run_started", "agent.run_completed", ] for ui_event in ui_events: assert ui_event not in outbox_events, f"UI event {ui_event} should not be an outbox event" # ─── Hook Priority and Multiple Callback Tests ─────────────────────────────── class TestHookPriorityAndMultiple: """Verify priority ordering and multiple callbacks for new hooks.""" @pytest.mark.asyncio async def test_multiple_callbacks_same_hook(self): """Multiple plugins can register for the same hook.""" reg = get_hook_registry() calls = [] reg.register_action("task.before_create", lambda *a, **kw: calls.append("plugin1")) reg.register_action("task.before_create", lambda *a, **kw: calls.append("plugin2")) await do_action("task.before_create", {}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert calls == ["plugin1", "plugin2"] @pytest.mark.asyncio async def test_priority_ordering_new_hooks(self): """Callbacks execute in priority order (lower first).""" reg = get_hook_registry() order = [] reg.register_action("dms.after_upload", lambda *a, **kw: order.append("low"), priority=20) reg.register_action("dms.after_upload", lambda *a, **kw: order.append("high"), priority=5) reg.register_action("dms.after_upload", lambda *a, **kw: order.append("mid"), priority=10) await do_action("dms.after_upload", {}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert order == ["high", "mid", "low"] @pytest.mark.asyncio async def test_async_callback_for_new_hook(self): """Async callbacks work for new hooks.""" reg = get_hook_registry() called = [] async def async_cb(*a, **kw): called.append("async") reg.register_action("workflow.before_start", async_cb) await do_action("workflow.before_start", instance_id=uuid.uuid4(), workflow_id=uuid.uuid4(), tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert called == ["async"] @pytest.mark.asyncio async def test_hook_exception_does_not_break_flow(self): """If a hook callback raises, subsequent callbacks still run.""" reg = get_hook_registry() called = [] reg.register_action("task.after_create", lambda *a, **kw: (_ for _ in ()).throw(ValueError("boom"))) reg.register_action("task.after_create", lambda *a, **kw: called.append("after_error")) await do_action("task.after_create", {}, db=None, tenant_id=uuid.uuid4(), user_id=uuid.uuid4()) assert called == ["after_error"]