feat(automation): add Zapier and Make.com integration

Add REST hooks subscription endpoints, incoming action endpoints, and
Zapier-compatible flat payload format for automation platform integration.

- AutomationHook model for webhook subscriptions
- POST /api/automation/hooks/subscribe and DELETE /hooks/{id}
- GET /api/automation/triggers/sample/{event} for Zapier field mapping
- POST /api/automation/actions/upload for incoming document uploads
- Celery task with retry for async hook delivery
- Integration with existing webhook dispatch flow
- 30 passing tests covering all new functionality

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-09 23:50:12 +00:00
parent 91e50a4441
commit d167be8274
11 changed files with 1069 additions and 10 deletions
+2
View File
@@ -8,6 +8,7 @@ from fastapi import APIRouter
from app.api.admin_users import router as admin_users_router
from app.api.api_tokens import router as api_tokens_router
from app.api.automation import router as automation_router
from app.api.azure import router as azure_router
from app.api.backup import router as backup_router
from app.api.billing import router as billing_router
@@ -82,3 +83,4 @@ router.include_router(imap_accounts_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
router.include_router(automation_router)
+306
View File
@@ -0,0 +1,306 @@
"""API endpoints for Zapier / Make.com automation integration.
Provides a REST hooks subscription interface for outgoing triggers and
incoming action endpoints that external automation platforms can call.
Outgoing triggers:
External platforms subscribe to DocuElevate events via
``POST /api/automation/hooks/subscribe``. When a subscribed event
fires, DocuElevate POSTs a flat Zapier-compatible JSON payload to the
registered ``target_url``.
Incoming actions:
``POST /api/automation/actions/upload`` allows automation platforms to
push documents into DocuElevate for processing.
Authentication:
All endpoints require a valid API token via ``Authorization: Bearer``
header.
"""
import json
import logging
import os
import tempfile
from typing import Annotated, Any
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models import AutomationHook
from app.utils.automation_hooks import SAMPLE_PAYLOADS
from app.utils.webhook import VALID_EVENTS
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/automation", tags=["automation"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper require a valid API token (Bearer)
# ---------------------------------------------------------------------------
def _require_api_user(request: Request) -> dict:
"""Ensure the caller is authenticated via session or API token.
Raises:
HTTPException: 401 if not authenticated, 403 if automation hooks are disabled.
"""
if not settings.automation_hooks_enabled:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Automation hooks are disabled",
)
# Check for API-token user first (set by auth middleware)
user = getattr(request.state, "api_token_user", None)
if user:
return user
# Fall back to session user
user = request.session.get("user")
if user:
return user
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required (Bearer token or session)",
)
AuthUser = Annotated[dict, Depends(_require_api_user)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class HookSubscribe(BaseModel):
"""Schema for subscribing to automation hook events."""
target_url: str = Field(..., min_length=1, max_length=2048, description="URL to POST event payloads to")
events: list[str] = Field(..., min_length=1, description="Event types to subscribe to")
secret: str | None = Field(default=None, max_length=512, description="Optional HMAC-SHA256 signing secret")
hook_type: str = Field(
default="generic",
max_length=50,
description="Platform identifier (zapier, make, generic)",
)
description: str | None = Field(default=None, max_length=500, description="Optional human-readable label")
class HookResponse(BaseModel):
"""Schema returned when listing or creating hooks."""
id: int
target_url: str
events: list[str]
is_active: bool
hook_type: str
description: str | None
has_secret: bool
model_config = {"from_attributes": True}
class ActionUploadResponse(BaseModel):
"""Response after an automation action uploads a document."""
status: str
filename: str
task_id: str | None = None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _validate_events(events: list[str]) -> None:
"""Raise 422 if any event name is not recognised."""
invalid = set(events) - VALID_EVENTS
if invalid:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_EVENTS))}",
)
def _hook_to_response(hook: AutomationHook) -> dict[str, Any]:
"""Convert a DB model instance to a response dict."""
try:
events = json.loads(hook.events)
except (json.JSONDecodeError, TypeError):
events = []
return {
"id": hook.id,
"target_url": hook.target_url,
"events": events,
"is_active": hook.is_active,
"hook_type": hook.hook_type,
"description": hook.description,
"has_secret": hook.secret is not None and len(hook.secret) > 0,
}
# ---------------------------------------------------------------------------
# Outgoing triggers REST hooks subscription endpoints
# ---------------------------------------------------------------------------
@router.post(
"/hooks/subscribe",
status_code=status.HTTP_201_CREATED,
summary="Subscribe to automation events (REST hooks)",
)
def subscribe_hook(body: HookSubscribe, db: DbSession, user: AuthUser) -> dict[str, Any]:
"""Register a new automation hook subscription.
Zapier and Make.com call this endpoint to subscribe to DocuElevate
events. When an event fires, a flat JSON payload is POSTed to
``target_url``.
"""
_validate_events(body.events)
hook = AutomationHook(
target_url=body.target_url,
secret=body.secret,
events=json.dumps(sorted(body.events)),
is_active=True,
hook_type=body.hook_type or "generic",
description=body.description,
)
try:
db.add(hook)
db.commit()
db.refresh(hook)
except Exception:
db.rollback()
raise
logger.info("Automation hook %d created (type=%s) for events %s", hook.id, hook.hook_type, body.events)
return _hook_to_response(hook)
@router.delete(
"/hooks/{hook_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Unsubscribe an automation hook",
)
def unsubscribe_hook(hook_id: int, db: DbSession, user: AuthUser) -> None:
"""Remove an automation hook subscription.
Zapier calls this endpoint when a Zap is turned off or deleted.
"""
hook = db.query(AutomationHook).filter(AutomationHook.id == hook_id).first()
if not hook:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hook not found")
try:
db.delete(hook)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Automation hook %d deleted", hook_id)
@router.get("/hooks", summary="List automation hook subscriptions")
def list_hooks(db: DbSession, user: AuthUser) -> list[dict[str, Any]]:
"""Return all active automation hook subscriptions."""
hooks = db.query(AutomationHook).order_by(AutomationHook.id).all()
return [_hook_to_response(h) for h in hooks]
# ---------------------------------------------------------------------------
# Outgoing triggers sample data for Zapier field mapping
# ---------------------------------------------------------------------------
@router.get("/triggers/sample/{event}", summary="Get sample trigger data")
def get_trigger_sample(event: str, user: AuthUser) -> list[dict[str, Any]]:
"""Return sample payload data for the given event type.
Zapier uses this during Zap setup to discover available fields and
provide a mapping interface. The response is wrapped in an array
as Zapier expects.
"""
if event not in VALID_EVENTS:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Unknown event: {event}. Valid: {', '.join(sorted(VALID_EVENTS))}",
)
sample = SAMPLE_PAYLOADS.get(event, {"id": "evt_sample", "event": event, "timestamp": 0})
return [sample]
# ---------------------------------------------------------------------------
# Outgoing triggers list valid events
# ---------------------------------------------------------------------------
@router.get("/events", summary="List valid automation event types")
def list_events(user: AuthUser) -> list[str]:
"""Return the list of valid event types that automation hooks can subscribe to."""
return sorted(VALID_EVENTS)
# ---------------------------------------------------------------------------
# Incoming actions endpoints that Zapier / Make.com can call
# ---------------------------------------------------------------------------
@router.post("/actions/upload", summary="Upload a document (incoming action)")
def action_upload(
request: Request,
db: DbSession,
user: AuthUser,
file: UploadFile = File(...),
) -> dict[str, Any]:
"""Accept a document upload from an automation platform.
This endpoint allows Zapier or Make.com to push a document into
DocuElevate for processing. The file is saved to the work directory
and a background processing task is queued.
"""
if not file.filename:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
owner_id = user.get("preferred_username") or user.get("email") or user.get("id", "automation")
workdir = settings.workdir or tempfile.gettempdir()
upload_dir = os.path.join(workdir, "uploads")
os.makedirs(upload_dir, exist_ok=True)
dest_path = os.path.join(upload_dir, file.filename)
try:
contents = file.file.read()
with open(dest_path, "wb") as f:
f.write(contents)
except Exception as exc:
logger.error("Failed to save uploaded file: %s", exc)
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save file")
# Queue background processing
task_id = None
try:
from app.tasks.process_document import process_document
result = process_document.delay(dest_path, owner_id)
task_id = result.id
logger.info("Automation upload queued: file=%s, task=%s, owner=%s", file.filename, task_id, owner_id)
except Exception as exc:
logger.warning("Could not queue processing task (Celery may be unavailable): %s", exc)
return {
"status": "accepted",
"filename": file.filename,
"task_id": task_id,
}
+6
View File
@@ -617,6 +617,12 @@ class Settings(BaseSettings):
description="Enable webhook delivery for document events",
)
# Automation hooks (Zapier / Make.com)
automation_hooks_enabled: bool = Field(
default=True,
description="Enable Zapier / Make.com automation hook subscriptions and delivery",
)
# ── Backup / restore settings ──────────────────────────────────────────────
backup_enabled: bool = Field(
default=True,
+22
View File
@@ -176,6 +176,28 @@ class WebhookConfig(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class AutomationHook(Base):
"""Zapier / Make.com compatible webhook subscription for automation triggers.
External automation platforms subscribe to DocuElevate events via the REST
hooks protocol. When an event fires, DocuElevate POSTs a Zapier-compatible
flat JSON payload to ``target_url``. The ``hook_type`` field records which
platform created the subscription (informational only).
"""
__tablename__ = "automation_hooks"
id = Column(Integer, primary_key=True, index=True)
target_url = Column(String, nullable=False) # URL to POST events to
secret = Column(String, nullable=True) # Optional HMAC-SHA256 signing secret
events = Column(Text, nullable=False) # JSON list of subscribed event names
is_active = Column(Boolean, default=True, nullable=False)
hook_type = Column(String(50), nullable=False, default="generic") # zapier | make | generic
description = Column(String, nullable=True) # Optional human-readable label
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class LocalUser(Base):
"""A locally-registered user authenticated by email and bcrypt password.
+44
View File
@@ -0,0 +1,44 @@
"""Celery task for asynchronous automation hook delivery with retry and backoff.
Uses :class:`~app.tasks.retry_config.BaseTaskWithRetry` so failed deliveries
are automatically retried with exponential backoff (default: 60 s, 300 s,
900 s) and ±20 % jitter.
"""
import logging
from typing import Any
from app.celery_app import celery
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils.webhook import deliver_webhook
logger = logging.getLogger(__name__)
@celery.task(base=BaseTaskWithRetry, bind=True, name="automation.deliver_hook")
def deliver_automation_hook_task(self, url: str, payload: dict[str, Any], secret: str | None = None) -> dict[str, Any]:
"""Deliver an automation hook payload to *url* with automatic retries.
Args:
url: Target webhook URL (provided by Zapier / Make.com).
payload: The flat Zapier-compatible payload.
secret: Optional shared secret for HMAC-SHA256 signing.
Returns:
A dict with ``status`` and ``url`` on success.
Raises:
RuntimeError: Re-raised to trigger Celery retry on delivery failure.
"""
logger.info(
"Delivering automation hook to %s (attempt %d/%d)",
url,
self.request.retries + 1,
self.max_retries + 1,
)
success = deliver_webhook(url, payload, secret)
if success:
return {"status": "delivered", "url": url}
raise RuntimeError(f"Automation hook delivery to {url} failed")
+188
View File
@@ -0,0 +1,188 @@
"""Automation hook utilities for Zapier / Make.com integration.
Provides helpers to build Zapier-compatible flat payloads, query active
automation hook subscriptions, and fan-out event delivery to all matching
hooks via Celery tasks.
The payload format is intentionally *flat* (no nested ``data`` key) so that
Zapier and Make.com can map fields without JSONPath expressions. An ``id``
field is included for Zapier deduplication.
"""
import json
import logging
import time
import uuid
from typing import Any
from app.config import settings
from app.database import SessionLocal
from app.models import AutomationHook
from app.utils.webhook import VALID_EVENTS
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Payload helpers
# ---------------------------------------------------------------------------
def build_zapier_payload(event: str, data: dict[str, Any]) -> dict[str, Any]:
"""Build a flat, Zapier-compatible webhook payload.
Zapier works best with flat JSON objects that include an ``id`` field
for deduplication. This function merges event metadata into the
top-level object alongside the event-specific *data*.
Args:
event: The event name (e.g. ``document.processed``).
data: Event-specific key/value pairs.
Returns:
A flat dictionary suitable for Zapier / Make.com consumption.
"""
return {
"id": f"evt_{uuid.uuid4().hex[:16]}",
"event": event,
"timestamp": time.time(),
**data,
}
# ---------------------------------------------------------------------------
# Sample payloads (used by the /triggers/sample endpoint)
# ---------------------------------------------------------------------------
#: Example payloads that Zapier uses for field-mapping during Zap creation.
SAMPLE_PAYLOADS: dict[str, dict[str, Any]] = {
"document.uploaded": {
"id": "evt_sample0001",
"event": "document.uploaded",
"timestamp": 1710000000.0,
"document_id": 42,
"filename": "invoice_2024.pdf",
"content_type": "application/pdf",
"size_bytes": 204800,
"owner_id": "user@example.com",
},
"document.processed": {
"id": "evt_sample0002",
"event": "document.processed",
"timestamp": 1710000060.0,
"document_id": 42,
"filename": "invoice_2024.pdf",
"status": "processed",
"title": "Invoice #1234",
"owner_id": "user@example.com",
},
"document.failed": {
"id": "evt_sample0003",
"event": "document.failed",
"timestamp": 1710000120.0,
"document_id": 42,
"filename": "corrupt.pdf",
"status": "failed",
"error": "Unable to extract text from document",
"owner_id": "user@example.com",
},
"user.signup": {
"id": "evt_sample0004",
"event": "user.signup",
"timestamp": 1710000180.0,
"user_id": "newuser@example.com",
"display_name": "Jane Doe",
},
"user.plan_changed": {
"id": "evt_sample0005",
"event": "user.plan_changed",
"timestamp": 1710000240.0,
"user_id": "user@example.com",
"old_tier": "free",
"new_tier": "pro",
},
"user.payment_issue": {
"id": "evt_sample0006",
"event": "user.payment_issue",
"timestamp": 1710000300.0,
"user_id": "user@example.com",
"issue": "Credit card declined",
},
}
# ---------------------------------------------------------------------------
# Database queries
# ---------------------------------------------------------------------------
def get_active_hooks_for_event(event: str) -> list[dict[str, Any]]:
"""Return all active automation hooks subscribed to *event*.
Args:
event: The event name to filter on.
Returns:
A list of dicts with ``id``, ``target_url``, ``secret``, and
``events`` keys.
"""
db = SessionLocal()
try:
hooks = db.query(AutomationHook).filter(AutomationHook.is_active.is_(True)).all()
result: list[dict[str, Any]] = []
for hook in hooks:
try:
subscribed = json.loads(hook.events)
except (json.JSONDecodeError, TypeError):
subscribed = []
if event in subscribed:
result.append(
{
"id": hook.id,
"target_url": hook.target_url,
"secret": hook.secret,
"events": subscribed,
}
)
return result
finally:
db.close()
# ---------------------------------------------------------------------------
# Dispatch
# ---------------------------------------------------------------------------
def dispatch_automation_hooks(event: str, data: dict[str, Any]) -> None:
"""Fan-out an event to all matching active automation hooks.
Builds a Zapier-compatible flat payload and queues a Celery task for
each matching hook so delivery is asynchronous with automatic retries.
Args:
event: Event name (must be in :data:`VALID_EVENTS`).
data: Event-specific payload data.
"""
if not settings.automation_hooks_enabled:
return
if event not in VALID_EVENTS:
logger.warning("Ignoring unknown automation hook event: %s", event)
return
hooks = get_active_hooks_for_event(event)
if not hooks:
logger.debug("No active automation hooks for event %s", event)
return
payload = build_zapier_payload(event, data)
from app.tasks.automation_tasks import deliver_automation_hook_task
for hook in hooks:
try:
deliver_automation_hook_task.delay(hook["target_url"], payload, hook["secret"])
logger.debug("Queued automation hook delivery to %s for event %s", hook["target_url"], event)
except Exception as exc:
logger.error("Failed to queue automation hook to %s: %s", hook["target_url"], exc)
+12
View File
@@ -1595,6 +1595,18 @@ SETTING_METADATA = {
"required": False,
"restart_required": False,
},
"automation_hooks_enabled": {
"category": "Feature Flags",
"description": (
"Enable Zapier / Make.com automation hook subscriptions and delivery. "
"When enabled, external automation platforms can subscribe to DocuElevate events "
"via the REST hooks protocol. Default: True."
),
"type": "boolean",
"sensitive": False,
"required": False,
"restart_required": False,
},
# Backup / Restore
"backup_enabled": {
"category": "Backup",
+19 -10
View File
@@ -145,6 +145,8 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
It delegates to :func:`deliver_webhook_task` (Celery) for each matching
webhook so delivery happens asynchronously with automatic retries.
Also dispatches to automation hooks (Zapier / Make.com) if enabled.
Args:
event: Event name (must be in :data:`VALID_EVENTS`).
data: Event-specific payload data.
@@ -156,16 +158,23 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
webhooks = get_active_webhooks_for_event(event)
if not webhooks:
logger.debug("No active webhooks for event %s", event)
return
else:
payload = build_payload(event, data)
payload = build_payload(event, data)
# Import here to avoid circular dependency with celery_app
from app.tasks.webhook_tasks import deliver_webhook_task
# Import here to avoid circular dependency with celery_app
from app.tasks.webhook_tasks import deliver_webhook_task
for wh in webhooks:
try:
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
except Exception as exc:
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
for wh in webhooks:
try:
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
except Exception as exc:
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
# Also fan-out to Zapier / Make.com automation hooks
try:
from app.utils.automation_hooks import dispatch_automation_hooks
dispatch_automation_hooks(event, data)
except Exception as exc:
logger.error("Failed to dispatch automation hooks for event %s: %s", event, exc)
@@ -0,0 +1,39 @@
"""Add automation_hooks table for Zapier / Make.com webhook subscriptions.
Revision ID: 027_add_automation_hooks
Revises: 026_add_scheduled_jobs
Create Date: 2026-03-09
"""
from typing import Union
import sqlalchemy as sa
from alembic import op
revision: str = "027_add_automation_hooks"
down_revision: Union[str, None] = "026_add_scheduled_jobs"
depends_on: Union[str, None] = None
def upgrade() -> None:
"""Create automation_hooks table."""
op.create_table(
"automation_hooks",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("target_url", sa.String(), nullable=False),
sa.Column("secret", sa.String(), nullable=True),
sa.Column("events", sa.Text(), nullable=False),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("hook_type", sa.String(50), nullable=False, server_default="generic"),
sa.Column("description", sa.String(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_automation_hooks_id", "automation_hooks", ["id"])
def downgrade() -> None:
"""Drop automation_hooks table."""
op.drop_index("ix_automation_hooks_id", "automation_hooks")
op.drop_table("automation_hooks")
+1
View File
@@ -61,6 +61,7 @@ from app.main import app as fastapi_app # noqa: E402
# Import models to register them with SQLAlchemy Base
from app.models import ( # noqa: F401, E402
ApiToken,
AutomationHook,
DocumentMetadata,
FileRecord,
Pipeline,
+430
View File
@@ -0,0 +1,430 @@
"""Tests for the Zapier / Make.com automation integration.
Covers:
- Automation hook utility functions (payload builder, DB queries, dispatch)
- Automation hook Celery task
- REST hooks API endpoints (subscribe, unsubscribe, list, sample, events)
- Incoming action endpoints (upload)
- Integration with existing webhook dispatch
"""
import json
import time
from unittest.mock import MagicMock
import pytest
from app.models import AutomationHook
from app.utils.automation_hooks import (
SAMPLE_PAYLOADS,
build_zapier_payload,
dispatch_automation_hooks,
get_active_hooks_for_event,
)
from app.utils.webhook import VALID_EVENTS
# ---------------------------------------------------------------------------
# Unit tests build_zapier_payload
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestBuildZapierPayload:
"""Tests for the Zapier-compatible payload builder."""
def test_contains_required_keys(self):
"""Payload must contain id, event, timestamp, plus data fields."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert "id" in payload
assert "event" in payload
assert "timestamp" in payload
assert "document_id" in payload
def test_id_starts_with_evt(self):
"""ID field must start with 'evt_' for Zapier deduplication."""
payload = build_zapier_payload("document.uploaded", {"document_id": 1})
assert payload["id"].startswith("evt_")
def test_event_matches_input(self):
"""Event field must match the event argument."""
payload = build_zapier_payload("document.processed", {"document_id": 2})
assert payload["event"] == "document.processed"
def test_timestamp_is_recent(self):
"""Timestamp should be close to current time."""
before = time.time()
payload = build_zapier_payload("document.uploaded", {})
after = time.time()
assert before <= payload["timestamp"] <= after
def test_data_is_flat(self):
"""Data fields should be merged into top level (flat, no nested 'data' key)."""
payload = build_zapier_payload("document.uploaded", {"filename": "test.pdf", "size": 1024})
assert payload["filename"] == "test.pdf"
assert payload["size"] == 1024
assert "data" not in payload
def test_unique_ids(self):
"""Each call should produce a unique ID."""
ids = {build_zapier_payload("document.uploaded", {})["id"] for _ in range(50)}
assert len(ids) == 50
# ---------------------------------------------------------------------------
# Unit tests SAMPLE_PAYLOADS
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestSamplePayloads:
"""Tests for the sample payloads used by Zapier field mapping."""
def test_all_events_have_samples(self):
"""Every valid event should have a sample payload."""
for event in VALID_EVENTS:
assert event in SAMPLE_PAYLOADS, f"Missing sample payload for {event}"
def test_samples_contain_id_and_event(self):
"""Each sample should contain id and event keys."""
for event, sample in SAMPLE_PAYLOADS.items():
assert "id" in sample, f"Sample for {event} missing 'id'"
assert sample["event"] == event
# ---------------------------------------------------------------------------
# Unit tests get_active_hooks_for_event
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestGetActiveHooksForEvent:
"""Tests for querying active automation hooks from the database."""
def test_returns_matching_hooks(self, mocker):
"""Only hooks subscribed to the event should be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret="abc",
events=json.dumps(["document.uploaded"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 1
assert result[0]["target_url"] == "https://hooks.zapier.com/1234"
def test_excludes_non_matching_hooks(self, mocker):
"""Hooks for different events should not be returned."""
hook = MagicMock(
id=1,
target_url="https://hooks.zapier.com/1234",
secret=None,
events=json.dumps(["document.processed"]),
is_active=True,
)
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = [hook]
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert len(result) == 0
def test_empty_when_no_hooks(self, mocker):
"""Empty list returned when no hooks exist."""
mock_session = MagicMock()
mock_session.query.return_value.filter.return_value.all.return_value = []
mocker.patch("app.utils.automation_hooks.SessionLocal", return_value=mock_session)
result = get_active_hooks_for_event("document.uploaded")
assert result == []
# ---------------------------------------------------------------------------
# Unit tests dispatch_automation_hooks
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDispatchAutomationHooks:
"""Tests for the automation hook dispatch function."""
def test_ignores_unknown_events(self, mocker):
"""Unknown events should be silently ignored."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("bad.event", {})
mock_get.assert_not_called()
def test_skips_when_disabled(self, mocker):
"""No hooks should fire when automation_hooks_enabled is False."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=False))
mock_get = mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event")
dispatch_automation_hooks("document.uploaded", {"file_id": 1})
mock_get.assert_not_called()
def test_queues_celery_task_for_each_hook(self, mocker):
"""A Celery task is queued for each matching hook."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch(
"app.utils.automation_hooks.get_active_hooks_for_event",
return_value=[
{"id": 1, "target_url": "https://hooks.zapier.com/a", "secret": "s", "events": ["document.uploaded"]},
{"id": 2, "target_url": "https://hooks.zapier.com/b", "secret": None, "events": ["document.uploaded"]},
],
)
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {"file_id": 42})
assert mock_task.call_count == 2
def test_no_tasks_when_no_hooks(self, mocker):
"""No tasks should be queued when there are no matching hooks."""
mocker.patch("app.utils.automation_hooks.settings", MagicMock(automation_hooks_enabled=True))
mocker.patch("app.utils.automation_hooks.get_active_hooks_for_event", return_value=[])
mock_task = mocker.patch("app.tasks.automation_tasks.deliver_automation_hook_task.delay")
dispatch_automation_hooks("document.uploaded", {})
mock_task.assert_not_called()
# ---------------------------------------------------------------------------
# Unit tests deliver_automation_hook_task
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestDeliverAutomationHookTask:
"""Tests for the automation hook Celery task."""
def test_returns_success_dict(self, mocker):
"""Successful delivery returns status dict."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=True)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
result = deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
assert result["status"] == "delivered"
assert result["url"] == "https://hooks.zapier.com/test"
def test_raises_on_failure(self, mocker):
"""Failed delivery raises RuntimeError for Celery retry."""
mocker.patch("app.tasks.automation_tasks.deliver_webhook", return_value=False)
from app.tasks.automation_tasks import deliver_automation_hook_task
deliver_automation_hook_task.request.retries = 0
with pytest.raises(RuntimeError, match="Automation hook delivery"):
deliver_automation_hook_task.__wrapped__("https://hooks.zapier.com/test", {"event": "test"}, None)
# ---------------------------------------------------------------------------
# Integration tests webhook dispatch integration
# ---------------------------------------------------------------------------
@pytest.mark.unit
class TestWebhookDispatchIntegration:
"""Test that dispatch_webhook_event also triggers automation hooks."""
def test_dispatch_triggers_automation_hooks(self, mocker):
"""dispatch_webhook_event should also call dispatch_automation_hooks."""
mocker.patch("app.utils.webhook.get_active_webhooks_for_event", return_value=[])
mock_auto = mocker.patch("app.utils.automation_hooks.dispatch_automation_hooks")
from app.utils.webhook import dispatch_webhook_event
dispatch_webhook_event("document.uploaded", {"file_id": 1})
mock_auto.assert_called_once_with("document.uploaded", {"file_id": 1})
# ---------------------------------------------------------------------------
# Integration tests API endpoints
# ---------------------------------------------------------------------------
@pytest.mark.integration
class TestAutomationAPI:
"""Tests for the /api/automation/ endpoints."""
def _with_auth(self, client):
"""Override auth dependency to simulate an authenticated user."""
from app.api.automation import _require_api_user
client.app.dependency_overrides[_require_api_user] = lambda: {
"id": "testuser",
"email": "test@example.com",
"preferred_username": "testuser",
"is_admin": False,
}
return client
# ── Subscribe / Unsubscribe ──────────────────────────────────────
def test_subscribe_hook(self, client):
"""POST /api/automation/hooks/subscribe creates a new hook."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/test",
"events": ["document.uploaded"],
"hook_type": "zapier",
"description": "My Zap",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["target_url"] == "https://hooks.zapier.com/test"
assert data["events"] == ["document.uploaded"]
assert data["is_active"] is True
assert data["hook_type"] == "zapier"
def test_subscribe_with_secret(self, client):
"""POST /api/automation/hooks/subscribe with secret masks it."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/secret",
"events": ["document.processed"],
"secret": "my-signing-secret",
},
)
assert resp.status_code == 201
data = resp.json()
assert data["has_secret"] is True
assert "secret" not in data
def test_subscribe_invalid_event(self, client):
"""POST /api/automation/hooks/subscribe rejects invalid events."""
self._with_auth(client)
resp = client.post(
"/api/automation/hooks/subscribe",
json={
"target_url": "https://hooks.zapier.com/bad",
"events": ["bad.event"],
},
)
assert resp.status_code == 422
def test_unsubscribe_hook(self, client, db_session):
"""DELETE /api/automation/hooks/{id} removes the hook."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/del",
events=json.dumps(["document.uploaded"]),
is_active=True,
hook_type="zapier",
)
db_session.add(hook)
db_session.commit()
hook_id = hook.id
resp = client.delete(f"/api/automation/hooks/{hook_id}")
assert resp.status_code == 204
def test_unsubscribe_not_found(self, client):
"""DELETE /api/automation/hooks/9999 returns 404."""
self._with_auth(client)
resp = client.delete("/api/automation/hooks/9999")
assert resp.status_code == 404
# ── List hooks ───────────────────────────────────────────────────
def test_list_hooks(self, client, db_session):
"""GET /api/automation/hooks returns all hooks."""
self._with_auth(client)
hook = AutomationHook(
target_url="https://hooks.zapier.com/list",
events=json.dumps(["document.processed"]),
is_active=True,
hook_type="make",
)
db_session.add(hook)
db_session.commit()
resp = client.get("/api/automation/hooks")
assert resp.status_code == 200
items = resp.json()
assert any(h["target_url"] == "https://hooks.zapier.com/list" for h in items)
# ── Sample trigger data ──────────────────────────────────────────
def test_trigger_sample(self, client):
"""GET /api/automation/triggers/sample/{event} returns sample data."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/document.uploaded")
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["event"] == "document.uploaded"
assert "id" in data[0]
def test_trigger_sample_unknown_event(self, client):
"""GET /api/automation/triggers/sample/bad returns 404."""
self._with_auth(client)
resp = client.get("/api/automation/triggers/sample/bad.event")
assert resp.status_code == 404
# ── Events listing ───────────────────────────────────────────────
def test_list_events(self, client):
"""GET /api/automation/events returns valid event types."""
self._with_auth(client)
resp = client.get("/api/automation/events")
assert resp.status_code == 200
events = resp.json()
assert "document.uploaded" in events
assert "document.processed" in events
assert "document.failed" in events
# ── Incoming action: upload ──────────────────────────────────────
def test_action_upload(self, client, mocker):
"""POST /api/automation/actions/upload accepts a file."""
self._with_auth(client)
mock_task = MagicMock()
mock_task.id = "task-123"
mocker.patch("app.tasks.process_document.process_document.delay", return_value=mock_task)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("test.pdf", b"fake-pdf-content", "application/pdf")},
)
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "accepted"
assert data["filename"] == "test.pdf"
assert data["task_id"] == "task-123"
def test_action_upload_no_filename(self, client):
"""POST /api/automation/actions/upload rejects empty filename."""
self._with_auth(client)
resp = client.post(
"/api/automation/actions/upload",
files={"file": ("", b"content", "application/pdf")},
)
# FastAPI/Starlette returns 422 for invalid multipart form data
assert resp.status_code in (400, 422)
# ── Auth required ────────────────────────────────────────────────
def test_requires_auth(self, client):
"""Endpoints return 401 without authentication."""
from app.api.automation import _require_api_user
client.app.dependency_overrides.pop(_require_api_user, None)
resp = client.get("/api/automation/hooks")
assert resp.status_code == 401