831 lines
35 KiB
Python
831 lines
35 KiB
Python
|
|
"""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"]
|