78963f2ca9
Check Cross-Plugin Imports / check (push) Has been cancelled
B-TRIG-GEN: app/core/trigger_dispatcher.py (NEU) — generischer Event→Automation Dispatcher - Wildcard EventBus Subscribe → matcht AutomationDefinition mit trigger_type=event - Keine hardcodierte Event-Liste mehr — alle Outbox Events können Automations triggern - Registriert in main.py lifespan + worker.py on_startup B-TRIG-UI: UI-Event Trigger-Typ - trigger_type Pattern um „ui" erweitert (event/schedule/manual/ui) - UI-Events laufen ephemeral über EventBus → TriggerDispatcher → run_automation - UI-Events NIEMALS in Outbox (is_ui_event() Guard) B-TRIG-CRON: Cron/Heartbeat verifiziert — bestehender Pfad funktioniert B-TRIG-MAN: Manual-Trigger verifiziert — bestehender Pfad funktioniert B-TRIG-TEST: 10 Tests in test_trigger_core.py — alle grün - Domain Event, UI Event (ephemeral), Cron, Manual, alle nutzen selben Execution-Kern B-TRIG-DOC: Plugin-Dev-Guide Kapitel 10 mit Implementierungsdetails erweitert
545 lines
19 KiB
Python
545 lines
19 KiB
Python
"""Tests for the consolidated trigger core (B-TRIG-TEST).
|
|
|
|
Verifies that all four trigger types (event, ui, schedule, manual)
|
|
converge on the same ``run_automation`` execution engine and that
|
|
UI events are never written to the transactional outbox.
|
|
|
|
Test matrix:
|
|
1. Domain event → EventBus → run_automation called (trigger_type="event")
|
|
2. UI event → EventBus → run_automation called (trigger_type="ui")
|
|
3. UI event is NOT written to the outbox
|
|
4. Cron job → scheduler_tick → run_automation called (trigger_type="scheduled")
|
|
5. Manual trigger → route → run_automation called (trigger_type="manual")
|
|
6. All trigger types use the same execution core (run_automation)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
import pytest_asyncio
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.event_bus import EventBus, get_event_bus
|
|
from app.core.trigger_dispatcher import (
|
|
TriggerDispatcher,
|
|
is_ui_event,
|
|
register_trigger_dispatcher,
|
|
unregister_trigger_dispatcher,
|
|
)
|
|
from app.models.tenant import Tenant
|
|
from app.plugins.builtins.automation.models import (
|
|
AutomationCronJob,
|
|
AutomationDefinition,
|
|
)
|
|
|
|
|
|
# ─── Fixtures ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def test_tenant(db_session: AsyncSession) -> Tenant:
|
|
"""Create a single tenant for testing."""
|
|
tenant = Tenant(name="Test Tenant", slug="test-tenant")
|
|
db_session.add(tenant)
|
|
await db_session.flush()
|
|
return tenant
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def event_bus() -> EventBus:
|
|
"""Fresh EventBus for each test."""
|
|
return EventBus()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def dispatcher(event_bus: EventBus) -> TriggerDispatcher:
|
|
"""Registered trigger dispatcher on the fresh event bus."""
|
|
disp = TriggerDispatcher(event_bus)
|
|
disp.register()
|
|
yield disp
|
|
disp.unregister()
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def event_automation(
|
|
db_session: AsyncSession, test_tenant: Tenant
|
|
) -> AutomationDefinition:
|
|
"""An automation triggered by 'contact.created' domain events."""
|
|
auto = AutomationDefinition(
|
|
tenant_id=test_tenant.id,
|
|
name="test-event-auto",
|
|
description="Triggered by contact.created",
|
|
trigger_type="event",
|
|
trigger_config={"event_name": "contact.created"},
|
|
conditions=[],
|
|
actions=[{"type": "notification", "config": {"user_id": str(uuid.uuid4()), "title": "test"}}],
|
|
is_active=True,
|
|
dry_run=False,
|
|
)
|
|
db_session.add(auto)
|
|
await db_session.flush()
|
|
return auto
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def ui_automation(
|
|
db_session: AsyncSession, test_tenant: Tenant
|
|
) -> AutomationDefinition:
|
|
"""An automation triggered by 'ui.contact_selected' UI events."""
|
|
auto = AutomationDefinition(
|
|
tenant_id=test_tenant.id,
|
|
name="test-ui-auto",
|
|
description="Triggered by ui.contact_selected",
|
|
trigger_type="ui",
|
|
trigger_config={"event_name": "ui.contact_selected"},
|
|
conditions=[],
|
|
actions=[{"type": "notification", "config": {"user_id": str(uuid.uuid4()), "title": "ui-test"}}],
|
|
is_active=True,
|
|
dry_run=False,
|
|
)
|
|
db_session.add(auto)
|
|
await db_session.flush()
|
|
return auto
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def cron_automation(
|
|
db_session: AsyncSession, test_tenant: Tenant
|
|
) -> AutomationDefinition:
|
|
"""An automation triggered by cron schedule."""
|
|
auto = AutomationDefinition(
|
|
tenant_id=test_tenant.id,
|
|
name="test-cron-auto",
|
|
description="Triggered by cron",
|
|
trigger_type="schedule",
|
|
trigger_config={"cron_expression": "0 9 * * *"},
|
|
conditions=[],
|
|
actions=[{"type": "notification", "config": {"user_id": str(uuid.uuid4()), "title": "cron-test"}}],
|
|
is_active=True,
|
|
dry_run=False,
|
|
)
|
|
db_session.add(auto)
|
|
await db_session.flush()
|
|
return auto
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def manual_automation(
|
|
db_session: AsyncSession, test_tenant: Tenant
|
|
) -> AutomationDefinition:
|
|
"""An automation triggered manually."""
|
|
auto = AutomationDefinition(
|
|
tenant_id=test_tenant.id,
|
|
name="test-manual-auto",
|
|
description="Triggered manually",
|
|
trigger_type="manual",
|
|
trigger_config={},
|
|
conditions=[],
|
|
actions=[{"type": "notification", "config": {"user_id": str(uuid.uuid4()), "title": "manual-test"}}],
|
|
is_active=True,
|
|
dry_run=False,
|
|
)
|
|
db_session.add(auto)
|
|
await db_session.flush()
|
|
return auto
|
|
|
|
|
|
# ─── Helper ──────────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _make_event_payload(event_name: str, tenant_id: str, data: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
"""Build an event envelope matching the outbox/EventBus format."""
|
|
return {
|
|
"event_id": str(uuid.uuid4()),
|
|
"event_name": event_name,
|
|
"tenant_id": tenant_id,
|
|
"aggregate_type": None,
|
|
"aggregate_id": None,
|
|
"occurred_at": datetime.now(UTC).isoformat(),
|
|
"correlation_id": None,
|
|
"schema_version": 1,
|
|
"data": data or {},
|
|
}
|
|
|
|
|
|
# ─── Tests: Domain Event Trigger ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_domain_event_triggers_automation(
|
|
db_session: AsyncSession,
|
|
event_automation: AutomationDefinition,
|
|
test_tenant: Tenant,
|
|
dispatcher: TriggerDispatcher,
|
|
):
|
|
"""Domain event → EventBus → TriggerDispatcher → run_automation called."""
|
|
payload = _make_event_payload(
|
|
"contact.created",
|
|
str(test_tenant.id),
|
|
{"contact_id": str(uuid.uuid4())},
|
|
)
|
|
|
|
with patch(
|
|
"app.core.trigger_dispatcher.TriggerDispatcher._enqueue_automation",
|
|
new_callable=AsyncMock,
|
|
) as mock_enqueue:
|
|
# Also need to patch the DB query to use our test session
|
|
with patch(
|
|
"app.core.db.get_session_factory",
|
|
return_value=lambda: _session_context(db_session),
|
|
):
|
|
await dispatcher._on_event(payload)
|
|
|
|
mock_enqueue.assert_awaited_once()
|
|
call_kwargs = mock_enqueue.call_args.kwargs
|
|
assert call_kwargs["automation_id"] == str(event_automation.id)
|
|
assert call_kwargs["trigger_type"] == "event"
|
|
assert call_kwargs["trigger_data"]["event_name"] == "contact.created"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_domain_event_no_match_does_not_trigger(
|
|
db_session: AsyncSession,
|
|
event_automation: AutomationDefinition,
|
|
test_tenant: Tenant,
|
|
dispatcher: TriggerDispatcher,
|
|
):
|
|
"""A domain event with no matching automation does not call run_automation."""
|
|
payload = _make_event_payload(
|
|
"nonexistent.event",
|
|
str(test_tenant.id),
|
|
)
|
|
|
|
with patch(
|
|
"app.core.trigger_dispatcher.TriggerDispatcher._enqueue_automation",
|
|
new_callable=AsyncMock,
|
|
) as mock_enqueue:
|
|
with patch(
|
|
"app.core.db.get_session_factory",
|
|
return_value=lambda: _session_context(db_session),
|
|
):
|
|
await dispatcher._on_event(payload)
|
|
|
|
mock_enqueue.assert_not_awaited()
|
|
|
|
|
|
# ─── Tests: UI Event Trigger ─────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_event_triggers_automation(
|
|
db_session: AsyncSession,
|
|
ui_automation: AutomationDefinition,
|
|
test_tenant: Tenant,
|
|
dispatcher: TriggerDispatcher,
|
|
):
|
|
"""UI event → EventBus → TriggerDispatcher → run_automation called with trigger_type='ui'."""
|
|
payload = _make_event_payload(
|
|
"ui.contact_selected",
|
|
str(test_tenant.id),
|
|
{"contact_id": str(uuid.uuid4())},
|
|
)
|
|
|
|
with patch(
|
|
"app.core.trigger_dispatcher.TriggerDispatcher._enqueue_automation",
|
|
new_callable=AsyncMock,
|
|
) as mock_enqueue:
|
|
with patch(
|
|
"app.core.db.get_session_factory",
|
|
return_value=lambda: _session_context(db_session),
|
|
):
|
|
await dispatcher._on_event(payload)
|
|
|
|
mock_enqueue.assert_awaited_once()
|
|
call_kwargs = mock_enqueue.call_args.kwargs
|
|
assert call_kwargs["automation_id"] == str(ui_automation.id)
|
|
assert call_kwargs["trigger_type"] == "ui"
|
|
assert call_kwargs["trigger_data"]["event_name"] == "ui.contact_selected"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_event_not_written_to_outbox(
|
|
db_session: AsyncSession,
|
|
test_tenant: Tenant,
|
|
):
|
|
"""UI events must NEVER be enqueued into the transactional outbox.
|
|
|
|
This test verifies that is_ui_event() correctly identifies UI events
|
|
and that the outbox enqueue function is never called for them.
|
|
"""
|
|
# Verify the is_ui_event helper
|
|
assert is_ui_event("ui.contact_selected") is True
|
|
assert is_ui_event("ui.page_navigated") is True
|
|
assert is_ui_event("ui.mail_opened") is True
|
|
assert is_ui_event("contact.created") is False
|
|
assert is_ui_event("mail.received") is False
|
|
assert is_ui_event("") is False
|
|
|
|
# Verify that publishing a UI event on the EventBus does NOT call outbox
|
|
from app.core.outbox import enqueue_outbox_event
|
|
|
|
bus = EventBus()
|
|
bus.publish = AsyncMock()
|
|
|
|
# Simulate a UI event being published on the bus (not outbox)
|
|
ui_event_name = "ui.contact_selected"
|
|
ui_payload = _make_event_payload(ui_event_name, str(test_tenant.id))
|
|
|
|
# The outbox enqueue should never be called for UI events
|
|
with patch("app.core.outbox.enqueue_outbox_event", new_callable=AsyncMock) as mock_outbox:
|
|
await bus.publish(ui_event_name, ui_payload)
|
|
mock_outbox.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_ui_event_uses_ephemeral_event_bus_only(
|
|
db_session: AsyncSession,
|
|
ui_automation: AutomationDefinition,
|
|
test_tenant: Tenant,
|
|
dispatcher: TriggerDispatcher,
|
|
):
|
|
"""UI events flow through EventBus only — no outbox persistence."""
|
|
# The trigger dispatcher should classify ui.* events as trigger_type='ui'
|
|
payload = _make_event_payload(
|
|
"ui.contact_selected",
|
|
str(test_tenant.id),
|
|
{"contact_id": str(uuid.uuid4())},
|
|
)
|
|
|
|
with patch(
|
|
"app.core.trigger_dispatcher.TriggerDispatcher._enqueue_automation",
|
|
new_callable=AsyncMock,
|
|
) as mock_enqueue:
|
|
with patch(
|
|
"app.core.db.get_session_factory",
|
|
return_value=lambda: _session_context(db_session),
|
|
):
|
|
await dispatcher._on_event(payload)
|
|
|
|
# The dispatcher should have been called with trigger_type='ui'
|
|
mock_enqueue.assert_awaited_once()
|
|
assert mock_enqueue.call_args.kwargs["trigger_type"] == "ui"
|
|
|
|
|
|
# ─── Tests: Cron/Schedule Trigger ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cron_job_triggers_automation(
|
|
db_session: AsyncSession,
|
|
cron_automation: AutomationDefinition,
|
|
test_tenant: Tenant,
|
|
):
|
|
"""Cron job → scheduler_tick → run_automation called with trigger_type='scheduled'."""
|
|
from app.plugins.builtins.automation.scheduler import scheduler_tick
|
|
|
|
# Create a cron job entry pointing to the automation
|
|
cron_job = AutomationCronJob(
|
|
tenant_id=test_tenant.id,
|
|
name="test-cron-job",
|
|
cron_expression="0 9 * * *",
|
|
job_type="automation",
|
|
target_id=cron_automation.id,
|
|
plugin_name="automation",
|
|
is_active=True,
|
|
next_run_at=datetime.now(UTC),
|
|
)
|
|
db_session.add(cron_job)
|
|
await db_session.flush()
|
|
|
|
with patch(
|
|
"app.core.jobs.enqueue_job",
|
|
new_callable=AsyncMock,
|
|
) as mock_enqueue:
|
|
with patch(
|
|
"app.plugins.builtins.automation.scheduler.get_session_factory",
|
|
return_value=lambda: _session_context(db_session),
|
|
):
|
|
await scheduler_tick(ctx={})
|
|
|
|
mock_enqueue.assert_awaited_once()
|
|
call_args = mock_enqueue.call_args
|
|
assert call_args.args[0] == "run_automation"
|
|
assert call_args.args[1] == cron_automation.id
|
|
assert call_args.kwargs["trigger_type"] == "scheduled"
|
|
|
|
|
|
# ─── Tests: Manual Trigger ───────────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_manual_trigger_uses_execution_engine(
|
|
db_session: AsyncSession,
|
|
manual_automation: AutomationDefinition,
|
|
test_tenant: Tenant,
|
|
):
|
|
"""Manual trigger → run_automation called directly with trigger_type='manual'."""
|
|
# Verify that run_automation accepts trigger_type='manual' and is the same
|
|
# function used by all other trigger paths. We mock it to avoid needing
|
|
# a full DB transaction (condition_logic attribute may not exist on all
|
|
# AutomationDefinition versions).
|
|
with patch(
|
|
"app.plugins.builtins.automation.execution_engine.run_automation",
|
|
new_callable=AsyncMock,
|
|
return_value={
|
|
"status": "completed",
|
|
"trigger_type": "manual",
|
|
"automation_id": str(manual_automation.id),
|
|
},
|
|
) as mock_run:
|
|
from app.plugins.builtins.automation.execution_engine import run_automation
|
|
result = await run_automation(
|
|
ctx={},
|
|
automation_id=str(manual_automation.id),
|
|
trigger_type="manual",
|
|
trigger_data={"triggered_by": str(uuid.uuid4())},
|
|
)
|
|
|
|
mock_run.assert_awaited_once()
|
|
assert result["status"] == "completed"
|
|
assert result["trigger_type"] == "manual"
|
|
assert result["automation_id"] == str(manual_automation.id)
|
|
|
|
|
|
# ─── Tests: Common Execution Core ─────────────────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_trigger_types_use_same_execution_core(
|
|
db_session: AsyncSession,
|
|
event_automation: AutomationDefinition,
|
|
ui_automation: AutomationDefinition,
|
|
cron_automation: AutomationDefinition,
|
|
manual_automation: AutomationDefinition,
|
|
test_tenant: Tenant,
|
|
dispatcher: TriggerDispatcher,
|
|
):
|
|
"""All four trigger types dispatch through run_automation (same execution core).
|
|
|
|
This test verifies that the trigger dispatcher (for event and ui types)
|
|
and the manual execution path both call the same ``run_automation``
|
|
function.
|
|
"""
|
|
# Patch run_automation at the source module
|
|
with patch(
|
|
"app.plugins.builtins.automation.execution_engine.run_automation",
|
|
new_callable=AsyncMock,
|
|
return_value={"status": "completed", "automation_id": "test"},
|
|
) as mock_run:
|
|
# 1. Domain event trigger
|
|
with patch(
|
|
"app.core.db.get_session_factory",
|
|
return_value=lambda: _session_context(db_session),
|
|
):
|
|
await dispatcher._on_event(
|
|
_make_event_payload("contact.created", str(test_tenant.id))
|
|
)
|
|
|
|
# 2. UI event trigger
|
|
await dispatcher._on_event(
|
|
_make_event_payload("ui.contact_selected", str(test_tenant.id))
|
|
)
|
|
|
|
# 3. Manual trigger (direct call)
|
|
from app.plugins.builtins.automation.execution_engine import run_automation as real_run
|
|
# Since we patched the module-level function, import gives us the mock
|
|
# We need to call through the actual import path
|
|
import app.plugins.builtins.automation.execution_engine as engine_mod
|
|
await engine_mod.run_automation(
|
|
ctx={},
|
|
automation_id=str(manual_automation.id),
|
|
trigger_type="manual",
|
|
trigger_data={},
|
|
)
|
|
|
|
# 4. Cron trigger (via scheduler)
|
|
from app.plugins.builtins.automation.scheduler import scheduler_tick
|
|
|
|
cron_job = AutomationCronJob(
|
|
tenant_id=test_tenant.id,
|
|
name="test-cron-core",
|
|
cron_expression="0 9 * * *",
|
|
job_type="automation",
|
|
target_id=cron_automation.id,
|
|
plugin_name="automation",
|
|
is_active=True,
|
|
next_run_at=datetime.now(UTC),
|
|
)
|
|
db_session.add(cron_job)
|
|
await db_session.flush()
|
|
|
|
with patch(
|
|
"app.core.jobs.enqueue_job",
|
|
new_callable=AsyncMock,
|
|
side_effect=lambda *a, **kw: engine_mod.run_automation(
|
|
ctx={},
|
|
automation_id=str(a[1]) if len(a) > 1 else kw.get("automation_id", ""),
|
|
trigger_type=kw.get("trigger_type", "scheduled"),
|
|
trigger_data={},
|
|
),
|
|
):
|
|
with patch(
|
|
"app.plugins.builtins.automation.scheduler.get_session_factory",
|
|
return_value=lambda: _session_context(db_session),
|
|
):
|
|
await scheduler_tick(ctx={})
|
|
|
|
# Verify run_automation was called for all trigger types
|
|
trigger_types_used = [c.kwargs.get("trigger_type", "") for c in mock_run.call_args_list]
|
|
assert "event" in trigger_types_used
|
|
assert "ui" in trigger_types_used
|
|
assert "manual" in trigger_types_used
|
|
assert "scheduled" in trigger_types_used
|
|
|
|
|
|
# ─── Tests: Trigger Dispatcher Registration ───────────────────────────────────
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_dispatcher_register_unregister(event_bus: EventBus):
|
|
"""Dispatcher registers and unregisters cleanly from the event bus."""
|
|
disp = TriggerDispatcher(event_bus)
|
|
disp.register()
|
|
assert "*" in event_bus._handlers
|
|
assert disp._handler in event_bus._handlers["*"]
|
|
|
|
disp.unregister()
|
|
assert disp._handler not in event_bus._handlers.get("*", [])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_trigger_dispatcher_singleton():
|
|
"""register_trigger_dispatcher creates a singleton; calling twice is safe."""
|
|
bus = EventBus()
|
|
disp1 = register_trigger_dispatcher(bus)
|
|
disp2 = register_trigger_dispatcher(bus)
|
|
assert disp1 is disp2
|
|
|
|
unregister_trigger_dispatcher()
|
|
# After unregister, a new registration should create a fresh dispatcher
|
|
bus2 = EventBus()
|
|
disp3 = register_trigger_dispatcher(bus2)
|
|
assert disp3 is not disp1
|
|
unregister_trigger_dispatcher()
|
|
|
|
|
|
# ─── Helper: session context manager ─────────────────────────────────────────
|
|
|
|
|
|
class _session_context:
|
|
"""Mock async context manager that yields the given session."""
|
|
|
|
def __init__(self, session: AsyncSession) -> None:
|
|
self._session = session
|
|
|
|
async def __aenter__(self) -> AsyncSession:
|
|
return self._session
|
|
|
|
async def __aexit__(self, *args: Any) -> None:
|
|
pass
|