feat(B-TRIG): Trigger-Kern konsolidiert — generischer Dispatcher, UI-Event-Typ, 4 Trigger-Typen
Check Cross-Plugin Imports / check (push) Has been cancelled
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
This commit is contained in:
@@ -0,0 +1,227 @@
|
|||||||
|
"""Trigger dispatcher — routes events to matching automation definitions.
|
||||||
|
|
||||||
|
This module provides the **generic** bridge between the EventBus and the
|
||||||
|
automation execution engine. It replaces the previous hard-coded event
|
||||||
|
handler stubs (``on_contact_created`` etc.) with a single wildcard
|
||||||
|
subscriber that queries the database for matching
|
||||||
|
``AutomationDefinition`` rows and dispatches them through the common
|
||||||
|
``run_automation`` execution core.
|
||||||
|
|
||||||
|
Two event categories are supported:
|
||||||
|
|
||||||
|
1. **Domain events** (durable, via Outbox → Worker → EventBus)
|
||||||
|
- trigger_type = ``"event"``
|
||||||
|
- trigger_config = ``{"event_name": "contact.created"}``
|
||||||
|
- Any event published to the EventBus (either directly or via the
|
||||||
|
outbox processor) can trigger an automation.
|
||||||
|
|
||||||
|
2. **UI events** (ephemeral, via WebSocket → EventBus only)
|
||||||
|
- trigger_type = ``"ui"``
|
||||||
|
- trigger_config = ``{"event_name": "ui.contact_selected"}``
|
||||||
|
- UI events are **never** written to the outbox. They flow directly
|
||||||
|
from the frontend WebSocket handler through the EventBus to this
|
||||||
|
dispatcher.
|
||||||
|
|
||||||
|
All four trigger types (event, ui, schedule, manual) converge on the
|
||||||
|
same ``run_automation`` execution engine, ensuring consistent condition
|
||||||
|
evaluation, action execution, and run logging.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.core.event_bus import EventBus
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Prefix that distinguishes ephemeral UI events from domain events.
|
||||||
|
# UI events must NEVER be enqueued into the transactional outbox.
|
||||||
|
_UI_EVENT_PREFIX = "ui."
|
||||||
|
|
||||||
|
|
||||||
|
class TriggerDispatcher:
|
||||||
|
"""Generic event-to-automation dispatcher.
|
||||||
|
|
||||||
|
Subscribes to the ``*`` wildcard on the EventBus so that **every**
|
||||||
|
event — domain or UI — is evaluated for matching automations.
|
||||||
|
|
||||||
|
The dispatcher is stateless after construction; it queries the
|
||||||
|
database on each event to find matching ``AutomationDefinition``
|
||||||
|
rows. This is intentionally generic: no hard-coded event list is
|
||||||
|
maintained, and any registered outbox event can trigger an
|
||||||
|
automation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, event_bus: EventBus) -> None:
|
||||||
|
self._event_bus = event_bus
|
||||||
|
self._handler: Any = None
|
||||||
|
|
||||||
|
def register(self) -> None:
|
||||||
|
"""Subscribe the wildcard handler on the event bus."""
|
||||||
|
self._handler = self._on_event
|
||||||
|
self._event_bus.subscribe("*", self._handler)
|
||||||
|
logger.info("TriggerDispatcher registered — listening to all events")
|
||||||
|
|
||||||
|
def unregister(self) -> None:
|
||||||
|
"""Unsubscribe from the event bus (idempotent)."""
|
||||||
|
if self._handler is not None:
|
||||||
|
self._event_bus.unsubscribe("*", self._handler)
|
||||||
|
self._handler = None
|
||||||
|
|
||||||
|
async def _on_event(self, payload: dict[str, Any]) -> None:
|
||||||
|
"""Wildcard handler invoked for every EventBus event.
|
||||||
|
|
||||||
|
Determines the event name from the payload envelope, classifies
|
||||||
|
it as domain or UI, queries matching automation definitions, and
|
||||||
|
dispatches each through ``run_automation``.
|
||||||
|
"""
|
||||||
|
event_name: str = payload.get("event_name", "")
|
||||||
|
if not event_name:
|
||||||
|
logger.debug("TriggerDispatcher: event without event_name, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
is_ui_event = event_name.startswith(_UI_EVENT_PREFIX)
|
||||||
|
trigger_type = "ui" if is_ui_event else "event"
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
"TriggerDispatcher: evaluating event '%s' (trigger_type=%s)",
|
||||||
|
event_name,
|
||||||
|
trigger_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._dispatch_matching_automations(
|
||||||
|
event_name=event_name,
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"TriggerDispatcher: error dispatching event '%s'", event_name
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _dispatch_matching_automations(
|
||||||
|
self,
|
||||||
|
event_name: str,
|
||||||
|
trigger_type: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Query DB for active automations matching *event_name* and dispatch."""
|
||||||
|
from app.core.db import get_session_factory
|
||||||
|
from app.plugins.builtins.automation.models import AutomationDefinition
|
||||||
|
|
||||||
|
factory = get_session_factory()
|
||||||
|
tenant_id = payload.get("tenant_id")
|
||||||
|
|
||||||
|
async with factory() as db:
|
||||||
|
query = (
|
||||||
|
select(AutomationDefinition)
|
||||||
|
.where(AutomationDefinition.is_active.is_(True))
|
||||||
|
.where(AutomationDefinition.trigger_type == trigger_type)
|
||||||
|
)
|
||||||
|
if tenant_id is not None:
|
||||||
|
query = query.where(
|
||||||
|
AutomationDefinition.tenant_id == tenant_id
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
automations = list(result.scalars().all())
|
||||||
|
|
||||||
|
if not automations:
|
||||||
|
logger.debug(
|
||||||
|
"TriggerDispatcher: no active automations for event '%s' (type=%s)",
|
||||||
|
event_name,
|
||||||
|
trigger_type,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
for automation in automations:
|
||||||
|
config = automation.trigger_config or {}
|
||||||
|
configured_event = config.get("event_name", "")
|
||||||
|
if configured_event != event_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"TriggerDispatcher: dispatching automation '%s' (%s) for event '%s'",
|
||||||
|
automation.name,
|
||||||
|
automation.id,
|
||||||
|
event_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._enqueue_automation(
|
||||||
|
automation_id=str(automation.id),
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
trigger_data=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _enqueue_automation(
|
||||||
|
self,
|
||||||
|
automation_id: str,
|
||||||
|
trigger_type: str,
|
||||||
|
trigger_data: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
"""Dispatch automation through the common execution core.
|
||||||
|
|
||||||
|
Uses ``run_automation`` directly (in-process) for low latency.
|
||||||
|
For production workloads with back-pressure, the caller may
|
||||||
|
alternatively enqueue via ``enqueue_job``.
|
||||||
|
"""
|
||||||
|
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||||
|
|
||||||
|
try:
|
||||||
|
await run_automation(
|
||||||
|
ctx={},
|
||||||
|
automation_id=automation_id,
|
||||||
|
trigger_type=trigger_type,
|
||||||
|
trigger_data=trigger_data,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"TriggerDispatcher: run_automation failed for automation_id=%s",
|
||||||
|
automation_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Module-level helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_dispatcher: TriggerDispatcher | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_trigger_dispatcher() -> TriggerDispatcher | None:
|
||||||
|
"""Return the singleton dispatcher, or ``None`` if not registered."""
|
||||||
|
return _dispatcher
|
||||||
|
|
||||||
|
|
||||||
|
def register_trigger_dispatcher(event_bus: EventBus) -> TriggerDispatcher:
|
||||||
|
"""Create and register the trigger dispatcher on *event_bus*.
|
||||||
|
|
||||||
|
Safe to call multiple times — subsequent calls are no-ops.
|
||||||
|
"""
|
||||||
|
global _dispatcher
|
||||||
|
if _dispatcher is not None:
|
||||||
|
logger.debug("TriggerDispatcher already registered")
|
||||||
|
return _dispatcher
|
||||||
|
_dispatcher = TriggerDispatcher(event_bus)
|
||||||
|
_dispatcher.register()
|
||||||
|
return _dispatcher
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_trigger_dispatcher() -> None:
|
||||||
|
"""Unregister and discard the singleton dispatcher."""
|
||||||
|
global _dispatcher
|
||||||
|
if _dispatcher is not None:
|
||||||
|
_dispatcher.unregister()
|
||||||
|
_dispatcher = None
|
||||||
|
|
||||||
|
|
||||||
|
def is_ui_event(event_name: str) -> bool:
|
||||||
|
"""Return ``True`` if *event_name* is an ephemeral UI event.
|
||||||
|
|
||||||
|
UI events must never be written to the transactional outbox.
|
||||||
|
"""
|
||||||
|
return event_name.startswith(_UI_EVENT_PREFIX)
|
||||||
@@ -159,6 +159,11 @@ async def on_startup(ctx: dict[str, Any]) -> None:
|
|||||||
register_webhook_event_handlers(event_bus)
|
register_webhook_event_handlers(event_bus)
|
||||||
logger.info("Worker: webhook event handlers registered")
|
logger.info("Worker: webhook event handlers registered")
|
||||||
|
|
||||||
|
# Register trigger dispatcher — generic event→automation bridge
|
||||||
|
from app.core.trigger_dispatcher import register_trigger_dispatcher
|
||||||
|
register_trigger_dispatcher(event_bus)
|
||||||
|
logger.info("Worker: trigger dispatcher registered")
|
||||||
|
|
||||||
# Register search providers (normally done by app startup)
|
# Register search providers (normally done by app startup)
|
||||||
try:
|
try:
|
||||||
from app.plugins.builtins.unified_search.provider_registry import auto_register_providers
|
from app.plugins.builtins.unified_search.provider_registry import auto_register_providers
|
||||||
|
|||||||
@@ -275,6 +275,11 @@ async def lifespan(app: FastAPI):
|
|||||||
register_webhook_event_handlers(event_bus)
|
register_webhook_event_handlers(event_bus)
|
||||||
logger.info("Webhook event handlers registered")
|
logger.info("Webhook event handlers registered")
|
||||||
|
|
||||||
|
# Register trigger dispatcher — generic event→automation bridge
|
||||||
|
from app.core.trigger_dispatcher import register_trigger_dispatcher
|
||||||
|
register_trigger_dispatcher(event_bus)
|
||||||
|
logger.info("Trigger dispatcher registered")
|
||||||
|
|
||||||
# Register field definitions from active plugins only
|
# Register field definitions from active plugins only
|
||||||
from app.core.permission_registry import get_permission_registry
|
from app.core.permission_registry import get_permission_registry
|
||||||
for name in active_plugin_names:
|
for name in active_plugin_names:
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class AutomationDefinitionCreate(BaseModel):
|
|||||||
|
|
||||||
name: str = Field(..., min_length=1, max_length=120)
|
name: str = Field(..., min_length=1, max_length=120)
|
||||||
description: str = Field(default="", max_length=500)
|
description: str = Field(default="", max_length=500)
|
||||||
trigger_type: str = Field(default="manual", pattern="^(event|schedule|manual)$")
|
trigger_type: str = Field(default="manual", pattern="^(event|schedule|manual|ui)$")
|
||||||
trigger_config: dict[str, Any] = Field(default_factory=dict)
|
trigger_config: dict[str, Any] = Field(default_factory=dict)
|
||||||
conditions: list[dict[str, Any]] = Field(default_factory=list)
|
conditions: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
actions: list[dict[str, Any]] = Field(default_factory=list)
|
actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
@@ -84,7 +84,7 @@ class AutomationDefinitionUpdate(BaseModel):
|
|||||||
|
|
||||||
name: str | None = Field(None, max_length=120)
|
name: str | None = Field(None, max_length=120)
|
||||||
description: str | None = Field(None, max_length=500)
|
description: str | None = Field(None, max_length=500)
|
||||||
trigger_type: str | None = Field(None, pattern="^(event|schedule|manual)$")
|
trigger_type: str | None = Field(None, pattern="^(event|schedule|manual|ui)$")
|
||||||
trigger_config: dict[str, Any] | None = None
|
trigger_config: dict[str, Any] | None = None
|
||||||
conditions: list[dict[str, Any]] | None = None
|
conditions: list[dict[str, Any]] | None = None
|
||||||
actions: list[dict[str, Any]] | None = None
|
actions: list[dict[str, Any]] | None = None
|
||||||
|
|||||||
@@ -1208,40 +1208,135 @@ Plugin-Migrationen werden bei Plugin-Aktivierung automatisch ausgeführt (`sync_
|
|||||||
|
|
||||||
## 10. Trigger
|
## 10. Trigger
|
||||||
|
|
||||||
Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events**, **UI-Events**, **Cron-Jobs** und **manuelle Trigger**. Webhook-Trigger folgt in Phase G.
|
Plugins können durch vier Trigger-Typen aktiviert werden: **Domain-Events**, **UI-Events**, **Cron-Jobs** und **manuelle Trigger**. Alle vier Typen konvergieren im selben Execution-Kern (`run_automation`). Webhook-Trigger folgt in Phase G.
|
||||||
|
|
||||||
|
### 10.0 Trigger-Typen-Übersicht
|
||||||
|
|
||||||
|
| Trigger-Typ | `trigger_type` | Event-Quelle | Durability | Dispatch-Pfad |
|
||||||
|
|-------------|----------------|-------------|------------|---------------|
|
||||||
|
| Domain-Event | `event` | Outbox → Worker → EventBus | **durable** (at-least-once) | EventBus `*` → TriggerDispatcher → `run_automation` |
|
||||||
|
| UI-Event | `ui` | WebSocket → EventBus (ephemeral) | **ephemeral** (keine Persistenz) | EventBus `*` → TriggerDispatcher → `run_automation` |
|
||||||
|
| Cron/Schedule | `schedule` | ARQ Cron → `scheduler_tick` | **durable** (ARQ-Queue) | `scheduler_tick` → `enqueue_job("run_automation")` |
|
||||||
|
| Manual | `manual` | API-Route `/execute` | **durable** (HTTP-Request) | Route → `run_automation(trigger_type="manual")` |
|
||||||
|
|
||||||
|
**Wichtig:** UI-Events (`ui.*`) dürfen **niemals** in die Outbox geschrieben werden. Sie sind ephemeral und fließen direkt über den EventBus.
|
||||||
|
|
||||||
### 10.1 Domain-Event-Trigger (durable)
|
### 10.1 Domain-Event-Trigger (durable)
|
||||||
|
|
||||||
Domain-Events werden über die **Outbox** gepublished und sind reliable. Siehe Kapitel 8 für die Event-System-Rollen.
|
Domain-Events werden über die **Transaction Outbox** gepublished: ein Service schreibt das Event in die `event_outbox` Tabelle innerhalb derselben DB-Transaktion. Der ARQ-Worker pollt die Outbox alle 5 Sekunden und published die Events auf den **EventBus**.
|
||||||
|
|
||||||
|
Der **TriggerDispatcher** (`app/core/trigger_dispatcher.py`) abonniert den `*` Wildcard-Handler auf dem EventBus und evaluiert jedes eingehende Event. Bei einer Übereinstimmung mit einer `AutomationDefinition` (`trigger_type="event"`, `trigger_config.event_name` matcht) wird `run_automation` aufgerufen.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
Service → enqueue_outbox_event() → event_outbox Tabelle
|
||||||
|
↓ (commit)
|
||||||
|
ARQ Worker (process_outbox_job, alle 5s)
|
||||||
|
↓
|
||||||
|
EventBus.publish_with_results(event_name, envelope)
|
||||||
|
↓
|
||||||
|
TriggerDispatcher._on_event(payload)
|
||||||
|
↓
|
||||||
|
DB-Query: AutomationDefinition WHERE trigger_type='event' AND trigger_config->>'event_name' = event_name
|
||||||
|
↓
|
||||||
|
run_automation(trigger_type="event", trigger_data=payload)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel — Domain-Event in Automation umwandeln:**
|
||||||
```python
|
```python
|
||||||
# Plugin deklariert Events im Manifest
|
# AutomationDefinition erstellen
|
||||||
manifest = PluginManifest(
|
POST /api/v1/automation/
|
||||||
name="my_plugin",
|
{
|
||||||
events=["contact.created", "contact.updated"],
|
"name": "notify-on-contact-create",
|
||||||
...
|
"trigger_type": "event",
|
||||||
)
|
"trigger_config": {"event_name": "contact.created"},
|
||||||
|
"conditions": [],
|
||||||
|
"actions": [
|
||||||
|
{"type": "notification", "config": {"user_id": "...", "title": "Neuer Kontakt"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
# Handler wird automatisch via on_activate registriert:
|
**Beispiel — Plugin veröffentlicht Domain-Event:**
|
||||||
async def on_contact_created(self, payload: dict[str, Any]) -> None:
|
```python
|
||||||
"""Reagiert auf contact.created Outbox-Event."""
|
from app.core.outbox import enqueue_outbox_event
|
||||||
contact_id = payload.get("contact_id")
|
|
||||||
# Business logic here
|
await enqueue_outbox_event(db, tenant_id, "contact.created", {
|
||||||
|
"contact_id": str(contact.id),
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
})
|
||||||
|
# Event wird beim Commit der Transaktion persistent.
|
||||||
|
# Der Worker published es an den EventBus.
|
||||||
|
# Der TriggerDispatcher findet passende Automations und führt sie aus.
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.2 UI-Event-Trigger (ephemeral)
|
### 10.2 UI-Event-Trigger (ephemeral)
|
||||||
|
|
||||||
Flüchtige UI-Events (z.B. `ui.contact_selected`) laufen über den **EventBus** — nicht über die Outbox. Siehe Kapitel 18.
|
UI-Events (`ui.*`) sind **ephemeral** — sie werden **nicht** in die Outbox geschrieben. Sie fließen direkt vom Frontend über WebSocket → EventBus → TriggerDispatcher.
|
||||||
|
|
||||||
```python
|
Der TriggerDispatcher erkennt UI-Events am `ui.` Prefix und setzt `trigger_type="ui"` beim Dispatch. `AutomationDefinition`-Einträge mit `trigger_type="ui"` werden automatisch gematcht.
|
||||||
event_bus = get_event_bus()
|
|
||||||
event_bus.subscribe("ui.contact_selected", self._on_contact_selected)
|
**Flow:**
|
||||||
|
```
|
||||||
|
Frontend (WebSocket) → ws_helpers → EventBus.publish("ui.contact_selected", payload)
|
||||||
|
↓
|
||||||
|
TriggerDispatcher._on_event(payload)
|
||||||
|
→ is_ui_event("ui.contact_selected") = True
|
||||||
|
→ trigger_type = "ui"
|
||||||
|
↓
|
||||||
|
DB-Query: AutomationDefinition WHERE trigger_type='ui' AND trigger_config->>'event_name' = 'ui.contact_selected'
|
||||||
|
↓
|
||||||
|
run_automation(trigger_type="ui", trigger_data=payload)
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.3 Cron-Trigger
|
**⚠️ Kritische Regel:** UI-Events dürfen **niemals** über `enqueue_outbox_event()` gepublished werden. Verwende ausschließlich `event_bus.publish()`:
|
||||||
|
```python
|
||||||
|
from app.core.event_bus import get_event_bus
|
||||||
|
|
||||||
Cron-Jobs werden im Manifest deklariert und vom Worker ausgeführt:
|
# RICHTIG — ephemeral, nur EventBus
|
||||||
|
event_bus = get_event_bus()
|
||||||
|
await event_bus.publish("ui.contact_selected", {
|
||||||
|
"event_name": "ui.contact_selected",
|
||||||
|
"tenant_id": str(tenant_id),
|
||||||
|
"data": {"contact_id": str(contact_id)},
|
||||||
|
})
|
||||||
|
|
||||||
|
# FALSCH — würde UI-Event in Outbox persistieren
|
||||||
|
# await enqueue_outbox_event(db, tenant_id, "ui.contact_selected", {...}) # ❌
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel — UI-Event-Automation erstellen:**
|
||||||
|
```python
|
||||||
|
POST /api/v1/automation/
|
||||||
|
{
|
||||||
|
"name": "track-contact-selection",
|
||||||
|
"trigger_type": "ui",
|
||||||
|
"trigger_config": {"event_name": "ui.contact_selected"},
|
||||||
|
"conditions": [],
|
||||||
|
"actions": [
|
||||||
|
{"type": "api_call", "config": {"url": "https://analytics.example.com/track", "method": "POST"}}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 10.3 Cron-Trigger (schedule)
|
||||||
|
|
||||||
|
Cron-Jobs werden über `AutomationCronJob`-Einträge in der Datenbank verwaltet. Der ARQ-Worker führt `scheduler_tick` alle 5 Minuten aus, liest fällige Cron-Jobs und enqueued `run_automation` mit `trigger_type="scheduled"`.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
ARQ Cron (alle 5min) → scheduler_tick(ctx)
|
||||||
|
↓
|
||||||
|
DB-Query: AutomationCronJob WHERE is_active=True AND next_run_at <= now()
|
||||||
|
↓
|
||||||
|
enqueue_job("run_automation", job.target_id, trigger_type="scheduled")
|
||||||
|
↓
|
||||||
|
run_automation(trigger_type="scheduled", trigger_data={})
|
||||||
|
↓
|
||||||
|
Update: last_run_at = now, next_run_at = calculate_next_run(cron_expression)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel — Cron-Job im Plugin-Manifest deklarieren:**
|
||||||
```python
|
```python
|
||||||
from app.plugins.manifest import CronJobContribution
|
from app.plugins.manifest import CronJobContribution
|
||||||
|
|
||||||
@@ -1255,19 +1350,80 @@ cron_jobs=[
|
|||||||
],
|
],
|
||||||
```
|
```
|
||||||
|
|
||||||
### 10.4 Manuelle Trigger
|
**Beispiel — Cron-Job zur Automation verknüpfen:**
|
||||||
|
```python
|
||||||
|
POST /api/v1/automation/cron-jobs/
|
||||||
|
{
|
||||||
|
"name": "daily-report-auto",
|
||||||
|
"cron_expression": "0 9 * * *",
|
||||||
|
"job_type": "automation",
|
||||||
|
"target_id": "<automation-definition-uuid>",
|
||||||
|
"is_active": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
Manuelle Trigger laufen über API-Routes oder Automation-Templates:
|
### 10.4 Manuelle Trigger (manual)
|
||||||
|
|
||||||
|
Manuelle Trigger werden über die API-Route `POST /api/v1/automation/{id}/execute` ausgelöst. Die Route ruft `run_automation` direkt mit `trigger_type="manual"` auf.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
```
|
||||||
|
HTTP POST /api/v1/automation/{id}/execute
|
||||||
|
↓
|
||||||
|
AutomationRun (status="running") wird in DB erstellt
|
||||||
|
↓
|
||||||
|
run_automation(trigger_type="manual", trigger_data={"triggered_by": user_id})
|
||||||
|
↓
|
||||||
|
AutomationRun wird mit Ergebnis aktualisiert (status="completed"/"partial")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel — Manuelle Automation auslösen:**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://crm.media-on.de/api/v1/automation/{id}/execute \
|
||||||
|
-H "Cookie: session=..."
|
||||||
|
```
|
||||||
|
|
||||||
|
**Beispiel — Eigene Manual-Trigger-Route im Plugin:**
|
||||||
```python
|
```python
|
||||||
@router.post("/api/v1/my-plugin/run-sync")
|
@router.post("/api/v1/my-plugin/run-sync")
|
||||||
async def run_manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
|
async def run_manual_sync(request: Request, db: AsyncSession = Depends(get_db)):
|
||||||
"""Manueller Trigger — Benutzer startet Sync von der UI."""
|
"""Manueller Trigger — Benutzer startet Sync von der UI."""
|
||||||
# Business logic
|
# Business logic oder: run_automation direkt aufrufen
|
||||||
return {"status": "started"}
|
from app.plugins.builtins.automation.execution_engine import run_automation
|
||||||
|
result = await run_automation(
|
||||||
|
ctx={},
|
||||||
|
automation_id=str(automation_id),
|
||||||
|
trigger_type="manual",
|
||||||
|
trigger_data={"triggered_by": str(user_id)},
|
||||||
|
)
|
||||||
|
return {"status": "started", "result": result}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Wichtig:** Durable Domain Events (Outbox) und ephemere UI-Events (EventBus) strikt trennen. Siehe Kapitel 8.5 Entscheidungsregel.
|
### 10.5 TriggerDispatcher — Der generische Event→Automation Dispatcher
|
||||||
|
|
||||||
|
Der `TriggerDispatcher` (`app/core/trigger_dispatcher.py`) ist das zentrale Bindeglied zwischen EventBus und Automation-Engine. Er ersetzt frühere hardcodierte Event-Handler-Stubs (`on_contact_created` etc.) durch eine generische Wildcard-Subscription.
|
||||||
|
|
||||||
|
**Registrierung:**
|
||||||
|
- In `app/main.py` lifespan (API-Container)
|
||||||
|
- In `app/core/worker.py` `on_startup` (Worker-Container)
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.core.trigger_dispatcher import register_trigger_dispatcher
|
||||||
|
from app.core.event_bus import get_event_bus
|
||||||
|
|
||||||
|
event_bus = get_event_bus()
|
||||||
|
register_trigger_dispatcher(event_bus)
|
||||||
|
# Dispatcher abonniert '*' auf dem EventBus
|
||||||
|
```
|
||||||
|
|
||||||
|
**Matching-Logik:**
|
||||||
|
1. Jedes Event auf dem EventBus erreicht `_on_event(payload)`
|
||||||
|
2. `event_name` wird aus `payload["event_name"]` extrahiert
|
||||||
|
3. `ui.*` Prefix → `trigger_type="ui"`, sonst `trigger_type="event"`
|
||||||
|
4. DB-Query: aktive `AutomationDefinition` mit passendem `trigger_type` und `trigger_config.event_name`
|
||||||
|
5. Jede Match-Definition wird über `run_automation` ausgeführt
|
||||||
|
|
||||||
|
**Wichtig:** Der Dispatcher ist **generisch** — es gibt keine hardcodierte Event-Liste. Jedes registrierte Outbox-Event kann eine Automation triggern, sobald eine `AutomationDefinition` mit passendem `trigger_config.event_name` existiert.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
"""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
|
||||||
Reference in New Issue
Block a user