feat(B-HOOK): Lifecycle Hooks + Outbox Events — 55 neue Hooks, 10 Domain Events
Check Cross-Plugin Imports / check (push) Has been cancelled

B-HOOK-CORE: Company Hooks (6: before/after create/update/delete)
B-HOOK-MAIL: Mail Hooks (5: after_receive, before/after_delete, before/after_move)
B-HOOK-DMS: DMS Hooks (10: after_upload, before/after_update/delete/restore, folder CRUD)
B-HOOK-CAL: Calendar Hooks (4: before/after update/delete)
B-HOOK-TASK: Task Hooks (6: before/after create/update/delete)
B-HOOK-COMM: Communication Hooks (8: conversation create, message/edit/delete)
B-HOOK-AI: Agent Hooks (2: before/after_run)
B-HOOK-WF: Workflow Hooks (4: before/after_start, after_complete/cancel)
B-HOOK-TAG: Tag Hooks (8: create/assign/unassign/delete)
B-HOOK-SEARCH: Search Filters (2: before/after_search)

B-EVT-OUTBOX: 10 Domain Events (task.completed, file.created/deleted/restored, mail.received, workflow.started/completed/cancelled, agent.run_started/completed)

B-HOOK-TEST: 79 Tests in test_lifecycle_hooks.py — alle grün
B-HOOK-DOC: Plugin-Dev-Guide Kapitel 8 aktualisiert (Hook-Liste + Outbox Event-Liste)
This commit is contained in:
Agent Zero
2026-08-13 20:54:15 +02:00
parent b231c2d0d3
commit ae228bb484
12 changed files with 1145 additions and 0 deletions
+830
View File
@@ -0,0 +1,830 @@
"""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"]