fischer-agentkit/tests/unit/bitable/test_automation.py

437 lines
14 KiB
Python

"""Unit tests for the automation engine (U7, R13-R17).
Tests the trigger evaluation, action dispatch, retry logic, and webhook
handling. These tests do NOT require PostgreSQL — they mock the repo/service
interfaces to test the engine logic in isolation.
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from agentkit.bitable.automation import AutomationEngine, generate_webhook_token
from agentkit.bitable.models import (
AutomationRule,
AutomationStatus,
AutomationTriggerType,
)
def _make_rule(
rule_id: str = "a1",
table_id: str = "t1",
trigger_type: str = "record_created",
action_type: str = "send_webhook",
enabled: bool = True,
webhook_token: str | None = None,
field_ids: list[str] | None = None,
action_config: dict | None = None,
) -> AutomationRule:
"""Build an AutomationRule for testing."""
trig_cfg: dict = {"type": trigger_type}
if field_ids:
trig_cfg["field_ids"] = field_ids
act_cfg = action_config or {"type": action_type, "url": "https://example.com/hook"}
return AutomationRule(
id=rule_id,
table_id=table_id,
name=f"Rule {rule_id}",
trigger_config=trig_cfg,
action_config=act_cfg,
enabled=enabled,
webhook_token=webhook_token,
)
def _make_mock_service() -> tuple[MagicMock, MagicMock]:
"""Create a mock service with a mock repo for the engine."""
service = MagicMock()
repo = MagicMock()
service.repo = repo
# Async methods on repo
repo.list_automations_for_trigger = AsyncMock(return_value=[])
repo.get_automation_by_webhook_token = AsyncMock(return_value=None)
repo.create_automation_log = AsyncMock(return_value="log1")
repo.update_automation_log = AsyncMock(return_value=None)
# Async methods on service
service.create_record = AsyncMock(return_value=MagicMock(id="r_new"))
service.update_record_values = AsyncMock(return_value=None)
return service, repo
# ---------------------------------------------------------------------------
# Trigger evaluation (R13)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_fire_trigger_matches_record_created() -> None:
"""record_created trigger fires matching rules."""
service, repo = _make_mock_service()
rule = _make_rule(trigger_type="record_created")
repo.list_automations_for_trigger = AsyncMock(return_value=[rule])
engine = AutomationEngine(service)
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_created,
record_id="r1",
record_values={"f1": "value"},
)
assert matched == 1
repo.list_automations_for_trigger.assert_awaited_once_with(
table_id="t1", trigger_type="record_created"
)
@pytest.mark.asyncio
async def test_fire_trigger_skips_disabled_rules() -> None:
"""Disabled rules don't fire."""
service, repo = _make_mock_service()
rule = _make_rule(enabled=False)
repo.list_automations_for_trigger = AsyncMock(return_value=[rule])
engine = AutomationEngine(service)
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_created,
)
assert matched == 0
@pytest.mark.asyncio
async def test_fire_trigger_record_updated_field_filter() -> None:
"""record_updated with field_ids filter only fires on overlap."""
service, repo = _make_mock_service()
rule_watching_f1 = _make_rule(trigger_type="record_updated", field_ids=["f1"])
repo.list_automations_for_trigger = AsyncMock(return_value=[rule_watching_f1])
engine = AutomationEngine(service)
# Changed f2 only — rule watches f1 → no match
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_updated,
changed_field_ids=["f2"],
)
assert matched == 0
# Changed f1 → match
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_updated,
changed_field_ids=["f1", "f2"],
)
assert matched == 1
@pytest.mark.asyncio
async def test_fire_trigger_no_field_filter_matches_any() -> None:
"""record_updated without field_ids fires on any update."""
service, repo = _make_mock_service()
rule_no_filter = _make_rule(trigger_type="record_updated", field_ids=[])
repo.list_automations_for_trigger = AsyncMock(return_value=[rule_no_filter])
engine = AutomationEngine(service)
matched = await engine.fire_trigger(
table_id="t1",
trigger_type=AutomationTriggerType.record_updated,
changed_field_ids=["f_anything"],
)
assert matched == 1
# ---------------------------------------------------------------------------
# Webhook inbound (R16)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_handle_webhook_matches_token() -> None:
"""Inbound webhook fires the rule with matching token."""
service, repo = _make_mock_service()
rule = _make_rule(webhook_token="whk_test123")
repo.get_automation_by_webhook_token = AsyncMock(return_value=rule)
engine = AutomationEngine(service)
matched = await engine.handle_webhook("whk_test123", {"event": "test"})
assert matched is True
@pytest.mark.asyncio
async def test_handle_webhook_unknown_token() -> None:
"""Unknown webhook token returns False (no match)."""
service, repo = _make_mock_service()
repo.get_automation_by_webhook_token = AsyncMock(return_value=None)
engine = AutomationEngine(service)
matched = await engine.handle_webhook("whk_unknown", {"event": "test"})
assert matched is False
@pytest.mark.asyncio
async def test_handle_webhook_disabled_rule() -> None:
"""Disabled rules don't fire via webhook."""
service, repo = _make_mock_service()
rule = _make_rule(enabled=False, webhook_token="whk_test")
repo.get_automation_by_webhook_token = AsyncMock(return_value=rule)
engine = AutomationEngine(service)
matched = await engine.handle_webhook("whk_test", {})
assert matched is False
# ---------------------------------------------------------------------------
# Action execution (R14)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_action_create_record_resolves_trigger_placeholders() -> None:
"""create_record action resolves @trigger.X placeholders."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r_trigger",
"record_values": {"f_name": "Alice"},
"action_config": {
"type": "create_record",
"target_table_id": "t2",
"values": {
"f_target_name": "@trigger.f_name",
"f_static": "static_value",
},
},
}
await engine._execute_action(job)
service.create_record.assert_awaited_once()
args, kwargs = service.create_record.call_args
assert args[0] == "t2" # target_table_id
resolved = args[1]
assert resolved["f_target_name"] == "Alice"
assert resolved["f_static"] == "static_value"
@pytest.mark.asyncio
async def test_action_update_record_defaults_to_trigger_record() -> None:
"""update_record defaults target_record_id to the trigger record."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_updated",
"record_id": "r_trigger",
"record_values": {"f_status": "done"},
"action_config": {
"type": "update_record",
"values": {"f_log": "completed"},
},
}
await engine._execute_action(job)
service.update_record_values.assert_awaited_once_with("r_trigger", {"f_log": "completed"})
@pytest.mark.asyncio
async def test_action_send_webhook_posts_payload(httpx_mock, monkeypatch) -> None:
"""send_webhook POSTs JSON to the configured URL."""
# ponytail: uses httpx_mock fixture from pytest-httpx if available;
# otherwise this test is skipped.
pytest.importorskip("pytest_httpx")
# Bypass SSRF host check — this test validates the POST behavior, not the guard.
monkeypatch.setattr("agentkit.bitable.automation._assert_safe_host", lambda _host: None)
httpx_mock.add_response(url="https://example.com/hook", status_code=200)
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {"f1": "val"},
"action_config": {
"type": "send_webhook",
"url": "https://example.com/hook",
},
}
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_action_unknown_type_raises() -> None:
"""Unknown action type raises ValueError."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {"type": "invalid_action"},
}
with pytest.raises(ValueError, match="Unknown action type"):
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_webhook_ssrf_guard_blocks_loopback() -> None:
"""send_webhook rejects loopback URLs (SSRF protection, P1)."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {"f1": "val"},
"action_config": {
"type": "send_webhook",
"url": "http://127.0.0.1:8080/internal",
},
}
with pytest.raises(ValueError, match="Disallowed|unsafe|private|loopback"):
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_webhook_ssrf_guard_blocks_private_ip() -> None:
"""send_webhook rejects RFC1918 private IPs (SSRF protection, P1)."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a2",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {
"type": "send_webhook",
"url": "http://10.0.0.1/admin",
},
}
with pytest.raises(ValueError, match="Disallowed|unsafe|private"):
await engine._execute_action(job)
@pytest.mark.asyncio
async def test_webhook_ssrf_guard_blocks_non_http_scheme() -> None:
"""send_webhook rejects non-HTTP(S) schemes (file://, gopher://, etc.)."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
job = {
"automation_id": "a3",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {
"type": "send_webhook",
"url": "file:///etc/passwd",
},
}
with pytest.raises(ValueError, match="scheme"):
await engine._execute_action(job)
# ---------------------------------------------------------------------------
# Retry logic (R15)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_retry_succeeds_on_second_attempt(monkeypatch) -> None:
"""Action that fails once then succeeds is logged correctly."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
# Make _execute_action fail once, then succeed
call_count = [0]
async def _flaky(job):
call_count[0] += 1
if call_count[0] == 1:
raise RuntimeError("transient failure")
monkeypatch.setattr(engine, "_execute_action", _flaky)
# Speed up retries
monkeypatch.setattr("agentkit.bitable.automation._RETRY_DELAYS", [0.0, 0.0, 0.0])
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {"type": "send_webhook", "url": "x"},
}
await engine._execute_with_retry(job)
assert call_count[0] == 2 # Failed once, succeeded on second
# Two log entries created (one per attempt)
assert repo.create_automation_log.await_count == 2
# Final log status is success
repo.update_automation_log.assert_awaited_with(
log_id="log1",
status=AutomationStatus.success,
completed_at=repo.update_automation_log.await_args.kwargs["completed_at"],
)
@pytest.mark.asyncio
async def test_retry_exhausted_after_3_attempts(monkeypatch) -> None:
"""Action that always fails is retried 3 times then logged as error."""
service, repo = _make_mock_service()
engine = AutomationEngine(service)
async def _always_fail(job):
raise RuntimeError("permanent failure")
monkeypatch.setattr(engine, "_execute_action", _always_fail)
monkeypatch.setattr("agentkit.bitable.automation._RETRY_DELAYS", [0.0, 0.0, 0.0])
job = {
"automation_id": "a1",
"table_id": "t1",
"trigger_type": "record_created",
"record_id": "r1",
"record_values": {},
"action_config": {"type": "send_webhook", "url": "x"},
}
await engine._execute_with_retry(job)
assert repo.create_automation_log.await_count == 3 # 3 attempts
# Last log status is error
last_call = repo.update_automation_log.await_args_list[-1]
assert last_call.kwargs["status"] == AutomationStatus.error
assert "permanent failure" in last_call.kwargs["error_message"]
# ---------------------------------------------------------------------------
# Webhook token generation (R16)
# ---------------------------------------------------------------------------
def test_generate_webhook_token_format() -> None:
"""Webhook tokens have the whk_ prefix and sufficient entropy."""
token = generate_webhook_token()
assert token.startswith("whk_")
# token_urlsafe(32) produces ~43 chars; with prefix, ~47
assert len(token) > 40
def test_generate_webhook_token_uniqueness() -> None:
"""Two generated tokens are different (non-deterministic)."""
tokens = {generate_webhook_token() for _ in range(10)}
assert len(tokens) == 10