"""OpenClaw client + alert webhook tests (Gitea issue #5 / P0). T5 — OpenClawClient subprocess args (token placement, cron subcommand params, JSON parse). T6 — Alert webhook send/skip/retry/dedupe semantics. """ import asyncio import json from unittest.mock import AsyncMock, MagicMock, patch import pytest from sqlmodel import Session from agenteval.intelligent_eval import openclaw_client as oc_mod from agenteval.intelligent_eval.alerts import AlertHistoryDB, AlertManager from agenteval.intelligent_eval.openclaw_client import OpenClawClient from agenteval.storage.db import utc_now # --------------------------------------------------------------------------- # T5 — OpenClawClient subprocess args # --------------------------------------------------------------------------- def _make_process(stdout: bytes, stderr: bytes = b"", returncode: int = 0): p = MagicMock() p.communicate = AsyncMock(return_value=(stdout, stderr)) p.returncode = returncode return p def test_create_cron_subprocess_args(monkeypatch: pytest.MonkeyPatch): """create_cron builds the documented docker exec invocation. Asserts: `docker exec openclaw cron add --cron --name --message --json [--description state:...] --token=`. Token is appended LAST (documented requirement). """ captured = {} async def fake_exec(*cmd, **kwargs): captured["cmd"] = list(cmd) return _make_process(b'{"id":"cron-1"}') monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) client = OpenClawClient(gateway_token="tok-xyz") asyncio.run( client.create_cron( name="n", schedule="* * * * *", skill="the-skill", state={"k": "v"}, ) ) cmd = captured["cmd"] # prefix assert cmd[0] == "docker" assert cmd[1:4] == ["exec", "openclaw-eval", "openclaw"] # subcommand + add assert "cron" in cmd and "add" in cmd # key flags assert any(s.startswith("--cron=") or cmd[cmd.index("--cron") + 1] == "* * * * *" for s in cmd) # token is last assert cmd[-1] == "--token=tok-xyz" # --message holds the skill (current implementation quirk: message, not --skill) assert any(s.startswith("--message=the-skill") for s in cmd) # description carries state assert any(s.startswith("--description=state:") for s in cmd) def test_list_crons_parses_json(monkeypatch: pytest.MonkeyPatch): async def fake_exec(*cmd, **kwargs): payload = json.dumps([{"id": "a", "name": "n", "schedule": "* * * * *", "enabled": True}]).encode() return _make_process(payload) monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) crons = asyncio.run(OpenClawClient().list_crons()) assert [c.id for c in crons] == ["a"] def test_list_crons_invalid_json_raises(monkeypatch: pytest.MonkeyPatch): async def fake_exec(*cmd, **kwargs): return _make_process(b"not json {") monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) with pytest.raises(RuntimeError, match="parse"): asyncio.run(OpenClawClient().list_crons()) def test_delete_cron_command_uses_rm(monkeypatch: pytest.MonkeyPatch): captured = {} async def fake_exec(*cmd, **kwargs): captured["cmd"] = list(cmd) return _make_process(b"") monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) asyncio.run(OpenClawClient().delete_cron("cron-99")) assert "cron" in captured["cmd"] assert "rm" in captured["cmd"] assert "cron-99" in captured["cmd"] assert captured["cmd"][-1].startswith("--token=") # --------------------------------------------------------------------------- # T6 — Alert webhook semantics # --------------------------------------------------------------------------- def _make_alert(db_session: Session) -> AlertHistoryDB: import uuid a = AlertHistoryDB( id=str(uuid.uuid4()), alert_type="test", severity="warning", rule_name="r", message="m", metric_value=1.0, threshold=0.5, created_at=utc_now(), ) db_session.add(a) db_session.commit() db_session.refresh(a) return a def test_webhook_marked_sent_on_2xx(db_session: Session): alert = _make_alert(db_session) fake_resp = MagicMock(); fake_resp.raise_for_status = MagicMock() with patch("agenteval.intelligent_eval.alerts.httpx.post", return_value=fake_resp) as p: AlertManager(db_session, webhook_url="http://x")._send_webhook(alert) assert p.called db_session.refresh(alert) assert alert.webhook_sent is True def test_webhook_failure_does_not_raise_or_mark_sent(db_session: Session): alert = _make_alert(db_session) with patch("agenteval.intelligent_eval.alerts.httpx.post", side_effect=RuntimeError("boom")): # Must not raise. AlertManager(db_session, webhook_url="http://x")._send_webhook(alert) db_session.refresh(alert) assert alert.webhook_sent is False def test_webhook_retries_on_failure(db_session: Session): alert = _make_alert(db_session) fake_resp = MagicMock(); fake_resp.raise_for_status = MagicMock() with patch( "agenteval.intelligent_eval.alerts.httpx.post", side_effect=[RuntimeError("transient"), fake_resp], ) as p: AlertManager(db_session, webhook_url="http://x")._send_webhook(alert) assert p.call_count >= 2 db_session.refresh(alert) assert alert.webhook_sent is True def test_webhook_dedupes_repeat_triggers(db_session: Session): """Calling _send_webhook twice on the same alert must POST at most once.""" alert = _make_alert(db_session) fake_resp = MagicMock(); fake_resp.raise_for_status = MagicMock() with patch("agenteval.intelligent_eval.alerts.httpx.post", return_value=fake_resp) as p: mgr = AlertManager(db_session, webhook_url="http://x") mgr._send_webhook(alert) mgr._send_webhook(alert) assert p.call_count == 1