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
@@ -128,6 +128,21 @@ async def run_agent(
# Reactive mode: use trigger_data as context
context_data = trigger_data or {}
# ── Lifecycle: before run ──
from app.core.hooks import do_action
await do_action("agent.before_run", agent_id=str(agent.id), tenant_id=str(agent.tenant_id), trigger_type=trigger_type)
from app.core.outbox import enqueue_outbox_event
async with factory() as db:
await enqueue_outbox_event(
db,
agent.tenant_id,
'agent.run_started',
{'agent_id': str(agent.id), 'tenant_id': str(agent.tenant_id), 'trigger_type': trigger_type},
aggregate_type='agent',
aggregate_id=agent.id,
)
await db.commit()
# Call LLM via LiteLLM
result_data: dict[str, Any] = {
"agent_id": str(agent.id),
@@ -239,6 +254,21 @@ async def run_agent(
result_data["status"] = "failed"
result_data["error"] = str(e)
# ── Lifecycle: after run ──
from app.core.hooks import do_action
await do_action("agent.after_run", agent_id=str(agent.id), tenant_id=str(agent.tenant_id), status=result_data.get("status"), result=result_data)
from app.core.outbox import enqueue_outbox_event
async with factory() as db:
await enqueue_outbox_event(
db,
agent.tenant_id,
'agent.run_completed',
{'agent_id': str(agent.id), 'tenant_id': str(agent.tenant_id), 'status': result_data.get('status'), 'cost_usd': result_data.get('cost_usd', 0.0)},
aggregate_type='agent',
aggregate_id=agent.id,
)
await db.commit()
# Save result to AgentRun
try:
async with factory() as db:
+8
View File
@@ -612,6 +612,8 @@ async def update_entry(
if not has_write:
raise HTTPException(403, detail={"detail": "No write permission", "code": "forbidden"})
from app.core.hooks import do_action
await do_action("calendar.before_update", body=body, tenant_id=tenant_id, user_id=user_id, entry_id=entry_id)
# Apply updates
if body.title is not None:
entry.title = body.title
@@ -646,6 +648,8 @@ async def update_entry(
await db.flush()
await db.refresh(entry)
from app.core.hooks import do_action
await do_action("calendar.after_update", entry_id=str(entry.id), tenant_id=tenant_id, user_id=user_id)
return _entry_to_dict(entry)
@@ -664,8 +668,12 @@ async def delete_entry(
has_write = await _check_write_permission(db, entry.calendar_id, user_id, role)
if not has_write:
raise HTTPException(403, detail={"detail": "No write permission", "code": "forbidden"})
from app.core.hooks import do_action
await do_action("calendar.before_delete", tenant_id=tenant_id, user_id=user_id, entry_id=entry_id)
entry.deleted_at = datetime.now(UTC)
await db.flush()
from app.core.hooks import do_action
await do_action("calendar.after_delete", tenant_id=tenant_id, user_id=user_id, entry_id=entry_id)
return Response(status_code=204)
+55
View File
@@ -257,6 +257,10 @@ async def create_folder(
409, detail={"detail": "Folder name already exists", "code": "duplicate"}
)
# Lifecycle hook: dms.folder.before_create
from app.core.hooks import do_action
await do_action("dms.folder.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
folder = Folder(
tenant_id=tenant_id,
name=body.name,
@@ -266,6 +270,9 @@ async def create_folder(
db.add(folder)
await db.flush()
# Lifecycle hook: dms.folder.after_create
await do_action("dms.folder.after_create", {'id': str(folder.id), 'name': folder.name, 'parent_id': str(folder.parent_id) if folder.parent_id else None}, db=db, tenant_id=tenant_id, user_id=user_id)
# Build path
path = body.name
if parent_id is not None:
@@ -441,6 +448,10 @@ async def delete_folder(
if not await check_single_entity_access(db, "dms_folder", fid, user_id, tenant_id, "delete", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.folder.before_delete
from app.core.hooks import do_action
await do_action("dms.folder.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
from datetime import UTC, datetime
now = datetime.now(UTC)
@@ -476,6 +487,11 @@ async def delete_folder(
)
await db.flush()
# Lifecycle hook: dms.folder.after_delete
from app.core.hooks import do_action
await do_action("dms.folder.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, folder_id=str(fid))
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -597,6 +613,14 @@ async def upload_file(
db.add(dms_file)
await db.flush()
# Lifecycle hook: dms.after_upload
from app.core.hooks import do_action
await do_action("dms.after_upload", {'id': str(dms_file.id), 'name': dms_file.name, 'folder_id': str(dms_file.folder_id) if dms_file.folder_id else None, 'mime_type': dms_file.mime_type, 'size_bytes': dms_file.size_bytes}, db=db, tenant_id=tenant_id, user_id=user_id)
# Outbox event: file.created
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'file.created', {'file_id': str(dms_file.id), 'tenant_id': str(tenant_id), 'name': dms_file.name, 'mime_type': dms_file.mime_type, 'size_bytes': dms_file.size_bytes}, aggregate_type='dms_file', aggregate_id=dms_file.id)
return {
"id": str(dms_file.id),
"name": dms_file.name,
@@ -763,6 +787,10 @@ async def update_file(
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.before_update
from app.core.hooks import do_action
await do_action("dms.before_update", body.model_dump(exclude_unset=True), db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
data = body.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
@@ -785,6 +813,10 @@ async def update_file(
await db.flush()
await db.refresh(dms_file)
# Lifecycle hook: dms.after_update
from app.core.hooks import do_action
await do_action("dms.after_update", {'id': str(dms_file.id), 'name': dms_file.name}, db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
return {
"id": str(dms_file.id),
"name": dms_file.name,
@@ -824,10 +856,22 @@ async def delete_file(
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "delete", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.before_delete
from app.core.hooks import do_action
await do_action("dms.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
from datetime import UTC, datetime
dms_file.deleted_at = datetime.now(UTC)
await db.flush()
# Lifecycle hook: dms.after_delete
await do_action("dms.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
# Outbox event: file.deleted
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'file.deleted', {'file_id': str(fid), 'tenant_id': str(tenant_id)}, aggregate_type='dms_file', aggregate_id=fid)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -857,10 +901,21 @@ async def restore_file(
if not await check_single_entity_access(db, "dms_file", fid, user_id, tenant_id, "write", is_system_admin):
raise HTTPException(403, detail={"detail": "Access denied", "code": "forbidden"})
# Lifecycle hook: dms.before_restore
from app.core.hooks import do_action
await do_action("dms.before_restore", db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
dms_file.deleted_at = None
await db.flush()
await db.refresh(dms_file)
# Lifecycle hook: dms.after_restore
await do_action("dms.after_restore", {'id': str(dms_file.id), 'name': dms_file.name}, db=db, tenant_id=tenant_id, user_id=user_id, file_id=file_id)
# Outbox event: file.restored
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'file.restored', {'file_id': str(fid), 'tenant_id': str(tenant_id)}, aggregate_type='dms_file', aggregate_id=fid)
return {
"id": str(dms_file.id),
"name": dms_file.name,
@@ -264,8 +264,11 @@ async def create_conversation(
created_by_type="user",
metadata_={},
)
from app.core.hooks import do_action
await do_action("comm.conversation.before_create", tenant_id=tenant_id, user_id=user_id)
db.add(conv)
await db.flush()
await do_action("comm.conversation.after_create", conversation_id=conv.id, tenant_id=tenant_id, user_id=user_id)
# Add creator as admin
creator = CommParticipant(
@@ -669,8 +672,11 @@ async def send_message(
except ValueError:
pass
from app.core.hooks import do_action
await do_action("comm.before_message", conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id)
db.add(msg)
await db.flush()
await do_action("comm.after_message", message_id=msg.id, conversation_id=conversation_id, tenant_id=tenant_id, sender_id=sender_id)
# Create blocks
if blocks:
@@ -872,10 +878,14 @@ async def edit_message(
)
db.add(edit)
from app.core.hooks import do_action
await do_action("comm.before_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id)
# Update message
msg.content = new_content
msg.edited_at = datetime.now(timezone.utc)
await db.flush()
await do_action("comm.after_edit", message_id=message_id, tenant_id=tenant_id, user_id=user_id)
return message_to_response(msg)
@@ -891,8 +901,11 @@ async def delete_message(
msg = result.scalar_one_or_none()
if msg is None:
return False
from app.core.hooks import do_action
await do_action("comm.before_delete", message_id=message_id)
msg.deleted_at = datetime.now(timezone.utc)
await db.flush()
await do_action("comm.after_delete", message_id=message_id)
return True
+24
View File
@@ -824,6 +824,14 @@ async def imap_sync_folder(
'from_address': from_addr,
})
# Lifecycle hook: mail.after_receive
from app.core.hooks import do_action
await do_action("mail.after_receive", {'mail_id': str(mail.id), 'tenant_id': str(tenant_id), 'account_id': str(account.id), 'folder_id': str(folder.id), 'subject': subject, 'from_address': from_addr}, db=db, tenant_id=tenant_id)
# Outbox event: mail.received
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, 'mail.received', {'mail_id': str(mail.id), 'tenant_id': str(tenant_id), 'account_id': str(account.id), 'folder_id': str(folder.id), 'subject': subject, 'from_address': from_addr}, aggregate_type='mail', aggregate_id=mail.id)
# Save attachments
for att_data in attachments:
try:
@@ -2343,6 +2351,10 @@ async def imap_delete_mail(
logger.warning("imap_delete_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id)
return
# Lifecycle hook: mail.before_delete
from app.core.hooks import do_action
await do_action("mail.before_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db)
# Find Trash folder on IMAP server (only needed for non-permanent delete)
trash_folder_name = None
if not permanent:
@@ -2409,6 +2421,10 @@ async def imap_delete_mail(
await client.expunge()
logger.info("imap_delete_mail: moved mail %s (UID %s) to Trash via COPY+DELETE", mail_id, uid_str)
# Lifecycle hook: mail.after_delete
from app.core.hooks import do_action
await do_action("mail.after_delete", mail_id=str(mail.id), tenant_id=str(tenant_id), permanent=permanent, db=db)
except Exception as exc:
logger.warning("imap_delete_mail: failed for mail %s: %s", mail_id, exc)
raise
@@ -2487,6 +2503,10 @@ async def imap_move_mail(
logger.warning("imap_move_mail: mail %s has no imap_uid or message_id, cannot sync", mail_id)
return
# Lifecycle hook: mail.before_move
from app.core.hooks import do_action
await do_action("mail.before_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db)
password = await get_account_password(account)
client = None
@@ -2532,6 +2552,10 @@ async def imap_move_mail(
logger.info("imap_move_mail: moved mail %s (UID %s) via COPY+DELETE", mail_id, uid_str)
# Lifecycle hook: mail.after_move
from app.core.hooks import do_action
await do_action("mail.after_move", mail_id=str(mail.id), tenant_id=str(tenant_id), source_folder_id=str(source_folder.id), target_folder_id=str(target_folder.id), db=db)
except Exception as exc:
logger.warning("imap_move_mail: failed for mail %s: %s", mail_id, exc)
raise
+16
View File
@@ -95,9 +95,13 @@ async def create_tag(
if existing.scalar_one_or_none() is not None:
raise HTTPException(409, detail={"detail": "Tag name already exists", "code": "duplicate"})
from app.core.hooks import do_action
await do_action("tag.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
tag = Tag(tenant_id=tenant_id, name=body.name, color=body.color, owner_id=user_id)
db.add(tag)
await db.flush()
from app.core.hooks import do_action
await do_action("tag.after_create", {"id": str(tag.id), "name": tag.name, "color": tag.color}, db=db, tenant_id=tenant_id, user_id=user_id)
return {
"id": str(tag.id),
"name": tag.name,
@@ -185,6 +189,8 @@ async def assign_tag(
"already_assigned": True,
}
from app.core.hooks import do_action
await do_action("tag.before_assign", body, db=db, tenant_id=tenant_id, user_id=user_id)
assignment = TagAssignment(
tenant_id=tenant_id,
tag_id=tag_id,
@@ -193,6 +199,8 @@ async def assign_tag(
)
db.add(assignment)
await db.flush()
from app.core.hooks import do_action
await do_action("tag.after_assign", {"id": str(assignment.id), "tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id)}, db=db, tenant_id=tenant_id, user_id=user_id)
return {
"id": str(assignment.id),
"tag_id": str(tag_id),
@@ -225,7 +233,11 @@ async def unassign_tag(
if assignment is None:
raise HTTPException(404, detail={"detail": "Assignment not found", "code": "not_found"})
from app.core.hooks import do_action
await do_action("tag.before_unassign", body, db=db, tenant_id=tenant_id, user_id=user_id)
await db.delete(assignment)
from app.core.hooks import do_action
await do_action("tag.after_unassign", {"tag_id": str(tag_id), "entity_type": body.entity_type, "entity_id": str(entity_id)}, db=db, tenant_id=tenant_id, user_id=user_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
@@ -244,6 +256,8 @@ async def delete_tag(
if tag is None:
raise HTTPException(404, detail={"detail": "Tag not found", "code": "not_found"})
from app.core.hooks import do_action
await do_action("tag.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, tag_id=tag_id)
# Cascade delete assignments
await db.execute(
delete(TagAssignment).where(
@@ -251,6 +265,8 @@ async def delete_tag(
)
)
await db.delete(tag)
from app.core.hooks import do_action
await do_action("tag.after_delete", db=db, tenant_id=tenant_id, user_id=user_id, tag_id=tag_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+16
View File
@@ -101,6 +101,8 @@ async def create_task(
data: dict[str, Any],
) -> dict[str, Any]:
"""Create a new task."""
from app.core.hooks import do_action
await do_action("task.before_create", data, db=db, tenant_id=tenant_id, user_id=user_id)
task = Task(
tenant_id=tenant_id,
title=data["title"],
@@ -116,6 +118,9 @@ async def create_task(
db.add(task)
await db.flush()
from app.core.hooks import do_action
await do_action("task.after_create", _task_to_dict(task), db=db, tenant_id=tenant_id, user_id=user_id)
# Publish task.created event
from app.core.event_bus import get_event_bus
event_bus = get_event_bus()
@@ -144,6 +149,8 @@ async def update_task(
if task is None:
return None
from app.core.hooks import do_action
await do_action("task.before_update", data, db=db, tenant_id=tenant_id, task_id=str(task_id))
if "title" in data and data["title"] is not None:
task.title = data["title"]
if "description" in data:
@@ -161,6 +168,8 @@ async def update_task(
await db.flush()
await db.refresh(task)
from app.core.hooks import do_action
await do_action("task.after_update", _task_to_dict(task), db=db, tenant_id=tenant_id, task_id=str(task_id))
return _task_to_dict(task)
@@ -172,8 +181,12 @@ async def delete_task(db: AsyncSession, tenant_id: uuid.UUID, task_id: uuid.UUID
task = result.scalar_one_or_none()
if task is None:
return False
from app.core.hooks import do_action
await do_action("task.before_delete", db=db, tenant_id=tenant_id, task_id=str(task_id))
task.deleted_at = datetime.now(timezone.utc)
await db.flush()
from app.core.hooks import do_action
await do_action("task.after_delete", db=db, tenant_id=tenant_id, task_id=str(task_id))
return True
@@ -210,6 +223,9 @@ async def update_task_status(
return None
task.status = new_status
await db.flush()
if new_status == "done":
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(db, tenant_id, "task.completed", {"task_id": str(task.id), "tenant_id": str(tenant_id), "title": task.title, "assigned_to": str(task.assigned_to) if task.assigned_to else None}, aggregate_type="task", aggregate_id=task.id)
return _task_to_dict(task)
@@ -101,6 +101,8 @@ async def hybrid_search(
query_text = f"{normalized_query} {' '.join(semantic_terms)}"
query_embedding = await generate_embedding(query_text, db=db, tenant_id=tenant_id)
from app.core.hooks import apply_filters
query_analysis = await apply_filters("search.before_search", query_analysis)
all_results: list[dict[str, Any]] = []
fetch_limit = limit * 2
@@ -132,6 +134,8 @@ async def hybrid_search(
all_results.append(result)
all_results.sort(key=lambda x: x.get("score", 0.0), reverse=True)
from app.core.hooks import apply_filters
all_results = await apply_filters("search.after_search", all_results)
return all_results[:limit]
+12
View File
@@ -101,6 +101,8 @@ async def create_company(
for k, v in body.items():
if k not in ("name", "industry", "description"):
custom[k] = v
from app.core.hooks import do_action
await do_action("company.before_create", body, db=db, tenant_id=tenant_id, user_id=user_id)
company = Contact(
tenant_id=tenant_id, type="company", name=name, displayname=name,
status=body.get("status", "lead"), custom=custom,
@@ -115,6 +117,8 @@ async def create_company(
)
db.add(audit_entry)
await db.flush()
from app.core.hooks import do_action
await do_action("company.after_create", _serialize_company(company), db=db, tenant_id=tenant_id, user_id=user_id)
return _serialize_company(company)
@@ -202,6 +206,8 @@ async def update_company(
company = result.scalar_one_or_none()
if not company:
raise HTTPException(status_code=404, detail="Company not found")
from app.core.hooks import do_action
await do_action("company.before_update", body, db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
if "name" in body:
company.name = body["name"]
company.displayname = body["name"]
@@ -220,6 +226,8 @@ async def update_company(
entity_type="contact", entity_id=company.id, changes=body)
db.add(audit_entry)
await db.flush()
from app.core.hooks import do_action
await do_action("company.after_update", _serialize_company(company), db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
return _serialize_company(company)
@@ -241,6 +249,8 @@ async def delete_company(
if not company:
raise HTTPException(status_code=404, detail="Company not found")
snapshot = _serialize_company(company)
from app.core.hooks import do_action
await do_action("company.before_delete", db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
# Soft-delete contact persons via SQL to avoid lazy loading
company.deleted_at = datetime.now(UTC)
company.updated_by = user_id
@@ -253,6 +263,8 @@ async def delete_company(
entity_type="contact", entity_id=company.id, changes={"name": company.name})
db.add(audit_entry)
await db.flush()
from app.core.hooks import do_action
await do_action("company.after_delete", snapshot, db=db, tenant_id=tenant_id, user_id=user_id, company_id=company_id)
return Response(status_code=status.HTTP_204_NO_CONTENT)
+35
View File
@@ -352,9 +352,21 @@ async def create_instance(
timeout_hours=timeout_hours,
timeout_at=timeout_at,
)
from app.core.hooks import do_action
await do_action("workflow.before_start", instance_id=instance.id, workflow_id=wf_uuid, tenant_id=tenant_id, user_id=user_id)
db.add(instance)
await db.flush()
await db.refresh(instance)
await do_action("workflow.after_start", instance_id=instance.id, workflow_id=wf_uuid, tenant_id=tenant_id, user_id=user_id)
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(
db,
tenant_id,
'workflow.started',
{'instance_id': str(instance.id), 'workflow_id': str(wf_uuid), 'tenant_id': str(tenant_id)},
aggregate_type='workflow_instance',
aggregate_id=instance.id,
)
# Log initial step entry
steps = workflow.steps or []
@@ -567,6 +579,17 @@ async def advance_instance(
action="completed",
actor_id=user_id,
)
from app.core.hooks import do_action
await do_action("workflow.after_complete", instance_id=instance.id, workflow_id=instance.workflow_id, tenant_id=tenant_id, user_id=user_id)
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(
db,
tenant_id,
'workflow.completed',
{'instance_id': str(instance.id), 'workflow_id': str(instance.workflow_id), 'tenant_id': str(tenant_id), 'status': 'completed'},
aggregate_type='workflow_instance',
aggregate_id=instance.id,
)
else:
instance.current_step_index = next_idx
next_step = steps[next_idx]
@@ -626,6 +649,18 @@ async def cancel_instance(
instance.status = "cancelled"
instance.completed_at = datetime.now(UTC)
from app.core.hooks import do_action
await do_action("workflow.after_cancel", instance_id=instance.id, workflow_id=instance.workflow_id, tenant_id=tenant_id, user_id=user_id)
from app.core.outbox import enqueue_outbox_event
await enqueue_outbox_event(
db,
tenant_id,
'workflow.cancelled',
{'instance_id': str(instance.id), 'workflow_id': str(instance.workflow_id), 'tenant_id': str(tenant_id), 'status': 'cancelled'},
aggregate_type='workflow_instance',
aggregate_id=instance.id,
)
# Get current step for history
wf_result = await db.execute(select(Workflow).where(Workflow.id == instance.workflow_id))
workflow = wf_result.scalar_one_or_none()
+102
View File
@@ -1003,6 +1003,88 @@ await do_action("contact.before_create", contact_data, db=db)
display_name = await apply_filters("contact.format_display_name", contact.name)
```
#### Verfügbare Hooks
**Actions** (fire-and-forget, kein Return-Wert):
| Hook Name | Modul | Parameter |
|-----------|-------|----------|
| `contact.before_create` | `app/services/contact_service.py` | `data, db, tenant_id, user_id` |
| `contact.after_create` | `app/services/contact_service.py` | `serialized, db, tenant_id, user_id` |
| `contact.before_update` | `app/services/contact_service.py` | `data, db, tenant_id, user_id, contact_id` |
| `contact.after_update` | `app/services/contact_service.py` | `snapshot, db, tenant_id, user_id, contact_id` |
| `contact.before_delete` | `app/services/contact_service.py` | `db, tenant_id, contact_id, user_id` |
| `contact.after_delete` | `app/services/contact_service.py` | `db, tenant_id, contact_id, user_id` |
| `company.before_create` | `app/routes/companies.py` | `body, db, tenant_id, user_id` |
| `company.after_create` | `app/routes/companies.py` | `serialized, db, tenant_id, user_id` |
| `company.before_update` | `app/routes/companies.py` | `body, db, tenant_id, user_id, company_id` |
| `company.after_update` | `app/routes/companies.py` | `serialized, db, tenant_id, user_id, company_id` |
| `company.before_delete` | `app/routes/companies.py` | `db, tenant_id, user_id, company_id` |
| `company.after_delete` | `app/routes/companies.py` | `snapshot, db, tenant_id, user_id, company_id` |
| `mail.before_send` | `app/plugins/builtins/mail/services.py` | `mail_data` (Filter) |
| `mail.after_send` | `app/plugins/builtins/mail/services.py` | `mail_data, db, account, msg_id` |
| `mail.after_receive` | `app/plugins/builtins/mail/services.py` | `payload, db, tenant_id` |
| `mail.before_delete` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, permanent, db` |
| `mail.after_delete` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, permanent, db` |
| `mail.before_move` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, source_folder_id, target_folder_id, db` |
| `mail.after_move` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, source_folder_id, target_folder_id, db` |
| `dms.before_upload` | `app/plugins/builtins/dms/routes.py` | `upload_data` (Filter) |
| `dms.after_upload` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id` |
| `dms.before_update` | `app/plugins/builtins/dms/routes.py` | `data, db, tenant_id, user_id, file_id` |
| `dms.after_update` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id, file_id` |
| `dms.before_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, file_id` |
| `dms.after_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, file_id` |
| `dms.before_restore` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, file_id` |
| `dms.after_restore` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id, file_id` |
| `dms.folder.before_create` | `app/plugins/builtins/dms/routes.py` | `body, db, tenant_id, user_id` |
| `dms.folder.after_create` | `app/plugins/builtins/dms/routes.py` | `payload, db, tenant_id, user_id` |
| `dms.folder.before_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, folder_id` |
| `dms.folder.after_delete` | `app/plugins/builtins/dms/routes.py` | `db, tenant_id, user_id, folder_id` |
| `calendar.before_appointment` | `app/plugins/builtins/calendar/routes.py` | `body, tenant_id, user_id, cal_id` |
| `calendar.after_appointment` | `app/plugins/builtins/calendar/routes.py` | `entry_id, tenant_id, user_id` |
| `calendar.before_update` | `app/plugins/builtins/calendar/routes.py` | `body, tenant_id, user_id, entry_id` |
| `calendar.after_update` | `app/plugins/builtins/calendar/routes.py` | `entry_id, tenant_id, user_id` |
| `calendar.before_delete` | `app/plugins/builtins/calendar/routes.py` | `tenant_id, user_id, entry_id` |
| `calendar.after_delete` | `app/plugins/builtins/calendar/routes.py` | `tenant_id, user_id, entry_id` |
| `task.before_create` | `app/plugins/builtins/tasks/services.py` | `data, db, tenant_id, user_id` |
| `task.after_create` | `app/plugins/builtins/tasks/services.py` | `serialized, db, tenant_id, user_id` |
| `task.before_update` | `app/plugins/builtins/tasks/services.py` | `data, db, tenant_id, task_id` |
| `task.after_update` | `app/plugins/builtins/tasks/services.py` | `serialized, db, tenant_id, task_id` |
| `task.before_delete` | `app/plugins/builtins/tasks/services.py` | `db, tenant_id, task_id` |
| `task.after_delete` | `app/plugins/builtins/tasks/services.py` | `db, tenant_id, task_id` |
| `comm.conversation.before_create` | `app/plugins/builtins/kommunikation/services.py` | `tenant_id, user_id` |
| `comm.conversation.after_create` | `app/plugins/builtins/kommunikation/services.py` | `conversation_id, tenant_id, user_id` |
| `comm.before_message` | `app/plugins/builtins/kommunikation/services.py` | `conversation_id, tenant_id, sender_id` |
| `comm.after_message` | `app/plugins/builtins/kommunikation/services.py` | `message_id, conversation_id, tenant_id, sender_id` |
| `comm.before_edit` | `app/plugins/builtins/kommunikation/services.py` | `message_id, tenant_id, user_id` |
| `comm.after_edit` | `app/plugins/builtins/kommunikation/services.py` | `message_id, tenant_id, user_id` |
| `comm.before_delete` | `app/plugins/builtins/kommunikation/services.py` | `message_id` |
| `comm.after_delete` | `app/plugins/builtins/kommunikation/services.py` | `message_id` |
| `agent.before_run` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, trigger_type` |
| `agent.after_run` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, status, result` |
| `workflow.before_start` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `workflow.after_start` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `workflow.after_complete` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `workflow.after_cancel` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, user_id` |
| `tag.before_create` | `app/plugins/builtins/tags/routes.py` | `body, db, tenant_id, user_id` |
| `tag.after_create` | `app/plugins/builtins/tags/routes.py` | `payload, db, tenant_id, user_id` |
| `tag.before_assign` | `app/plugins/builtins/tags/routes.py` | `body, db, tenant_id, user_id` |
| `tag.after_assign` | `app/plugins/builtins/tags/routes.py` | `payload, db, tenant_id, user_id` |
| `tag.before_unassign` | `app/plugins/builtins/tags/routes.py` | `body, db, tenant_id, user_id` |
| `tag.after_unassign` | `app/plugins/builtins/tags/routes.py` | `payload, db, tenant_id, user_id` |
| `tag.before_delete` | `app/plugins/builtins/tags/routes.py` | `db, tenant_id, user_id, tag_id` |
| `tag.after_delete` | `app/plugins/builtins/tags/routes.py` | `db, tenant_id, user_id, tag_id` |
**Filters** (modifizieren Wert, Return erforderlich):
| Hook Name | Modul | Parameter |
|-----------|-------|----------|
| `contact.format_display_name` | `app/services/contact_service.py` | `name, data, db, tenant_id, user_id` |
| `dms.before_upload` | `app/plugins/builtins/dms/routes.py` | `upload_data` (filename, mime_type) |
| `mail.before_send` | `app/plugins/builtins/mail/services.py` | `mail_data` |
| `search.before_search` | `app/plugins/builtins/unified_search/search_engine.py` | `query_analysis` |
| `search.after_search` | `app/plugins/builtins/unified_search/search_engine.py` | `all_results` |
### 8.2 EventBus (`app/core/event_bus.py`)
**Wann verwenden:** Für flüchtige interne Notifikationen, UI-Events, Proactive Suggestions. **Nicht** für Events die reliable Delivery brauchen.
@@ -1036,6 +1118,26 @@ await enqueue_outbox_event(db, tenant_id, "contact.created", {
**Features:** DLQ (`error_message`, `failed_at`), Replay (`replay_failed_event`), Consumer Registry, Stats.
#### Verfügbare Outbox Events
| Event Name | Modul | Payload | Aggregate |
|------------|-------|---------|-----------|
| `contact.created` | `app/services/contact_service.py` | `contact_id, tenant_id, displayname, type` | `contact` |
| `contact.updated` | `app/services/contact_service.py` | `contact_id, tenant_id, changes` | `contact` |
| `lead.created` | `app/services/contact_service.py` | `contact_id, tenant_id` | `contact` |
| `mail.received` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, account_id, folder_id, subject, from_address` | `mail` |
| `mail.send` | `app/plugins/builtins/mail/services.py` | `mail_id, tenant_id, account_id` | `mail` |
| `file.created` | `app/plugins/builtins/dms/routes.py` | `file_id, tenant_id, name, mime_type, size_bytes` | `dms_file` |
| `file.deleted` | `app/plugins/builtins/dms/routes.py` | `file_id, tenant_id` | `dms_file` |
| `file.restored` | `app/plugins/builtins/dms/routes.py` | `file_id, tenant_id` | `dms_file` |
| `dms.file.uploaded` | `app/plugins/builtins/dms/routes.py` | (legacy alias for `file.created`) | `dms_file` |
| `task.completed` | `app/plugins/builtins/tasks/services.py` | `task_id, tenant_id, title, assigned_to` | `task` |
| `workflow.started` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id` | `workflow_instance` |
| `workflow.completed` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id, status` | `workflow_instance` |
| `workflow.cancelled` | `app/services/workflow_service.py` | `instance_id, workflow_id, tenant_id` | `workflow_instance` |
| `agent.run_started` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, trigger_type` | `agent` |
| `agent.run_completed` | `app/plugins/builtins/automation/agent_runner.py` | `agent_id, tenant_id, status, cost_usd` | `agent` |
### 8.4 WebhookDispatcher (`app/core/webhook_dispatcher.py`)
**Wann verwenden:** Für externe HTTP-Zustellung an registrierte Webhook-URLs.
+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"]