AgentEvalTool/tests/unit/test_openclaw_client_and_webhook.py
sinohqb f85eca11ca
All checks were successful
CI / test (push) Successful in 3m57s
fix(alerts): webhook retry + dedupe (resolves §6.2)
_send_webhook:
- Dedupe: skip if alert.webhook_sent is already True (guards against
  repeated check_alerts ticks re-sending the same alert).
- Retry: up to 3 attempts with exponential backoff (1s, 2s) before
  giving up. webhook_sent=True is persisted only on success.

Two xfail guards in test_openclaw_client_and_webhook now pass (876/4 xfail).
2026-08-14 14:54:57 +08:00

164 lines
5.9 KiB
Python

"""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 <container> openclaw cron add --cron <expr> --name <n>
--message <m> --json [--description state:...] --token=<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