"""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