4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
352 lines
14 KiB
Python
352 lines
14 KiB
Python
"""Tests for ``utils/delivery_transport.py``.
|
|
|
|
The transport is the shared HTTP-POST plumbing used by the Slack, Discord,
|
|
and Telegram delivery helpers. These tests pin down the contract:
|
|
|
|
- It never re-raises; every transport-level failure becomes ``ok=False``.
|
|
- Status code and parsed JSON body are surfaced unchanged on success.
|
|
- Non-JSON / empty / non-dict response bodies degrade gracefully — ``data``
|
|
always falls back to ``{}`` so callers can chain ``.get(...)``.
|
|
- Optional ``headers``, ``timeout``, and ``follow_redirects`` arguments
|
|
reach ``httpx.post`` correctly.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from platform.notifications.delivery_transport import DeliveryResponse, post_form, post_json
|
|
|
|
|
|
def _mock_response(status_code: int, json_body: Any = None, text: str = "") -> MagicMock:
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.text = text
|
|
if isinstance(json_body, Exception):
|
|
|
|
def _raise() -> Any:
|
|
raise json_body
|
|
|
|
resp.json.side_effect = _raise
|
|
else:
|
|
resp.json.return_value = json_body
|
|
return resp
|
|
|
|
|
|
class TestPostJsonHappyPath:
|
|
def test_returns_ok_with_status_data_text(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
body = {"ok": True, "id": "msg-1"}
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(200, body, '{"ok":true,"id":"msg-1"}'),
|
|
)
|
|
result = post_json("https://example.test/api", {"hello": "world"})
|
|
assert result.ok is True
|
|
assert result.status_code == 200
|
|
assert result.data == body
|
|
assert result.text == '{"ok":true,"id":"msg-1"}'
|
|
assert result.error == ""
|
|
|
|
def test_returns_status_for_4xx_5xx_without_raising(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Provider-level errors return ``ok=True`` (the request succeeded
|
|
at the transport layer); callers interpret status_code/data."""
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(403, {"error": "forbidden"}, "forbidden"),
|
|
)
|
|
result = post_json("https://example.test", {})
|
|
assert result.ok is True
|
|
assert result.status_code == 403
|
|
assert result.data == {"error": "forbidden"}
|
|
assert result.error == ""
|
|
|
|
def test_passes_headers_and_timeout_through(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _capture(url: str, **kwargs: Any) -> MagicMock:
|
|
captured["url"] = url
|
|
captured.update(kwargs)
|
|
return _mock_response(200, {})
|
|
|
|
monkeypatch.setattr("platform.notifications.delivery_transport.httpx.post", _capture)
|
|
post_json(
|
|
"https://example.test/api",
|
|
{"k": "v"},
|
|
headers={"Authorization": "Bearer abc"},
|
|
timeout=4.5,
|
|
)
|
|
assert captured["url"] == "https://example.test/api"
|
|
assert captured["json"] == {"k": "v"}
|
|
assert captured["headers"] == {"Authorization": "Bearer abc"}
|
|
assert captured["timeout"] == 4.5
|
|
assert captured["follow_redirects"] is False
|
|
|
|
def test_default_headers_are_empty_dict(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **kw: captured.update(kw) or _mock_response(200, {}),
|
|
)
|
|
post_json("https://example.test", {})
|
|
assert captured["headers"] == {}
|
|
|
|
def test_follow_redirects_can_be_enabled(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **kw: captured.update(kw) or _mock_response(200, {}),
|
|
)
|
|
post_json("https://example.test", {}, follow_redirects=True)
|
|
assert captured["follow_redirects"] is True
|
|
|
|
|
|
class TestPostJsonTransportFailures:
|
|
"""The helper must catch every transport-level error and never re-raise."""
|
|
|
|
@pytest.mark.parametrize(
|
|
"exc",
|
|
[
|
|
httpx.ConnectError("connection refused"),
|
|
httpx.ReadTimeout("timed out"),
|
|
httpx.RequestError("generic transport error"),
|
|
OSError("network down"),
|
|
RuntimeError("unexpected"),
|
|
],
|
|
)
|
|
def test_request_exceptions_become_ok_false(
|
|
self, exc: Exception, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def _raise(*_a: Any, **_kw: Any) -> Any:
|
|
raise exc
|
|
|
|
monkeypatch.setattr("platform.notifications.delivery_transport.httpx.post", _raise)
|
|
result = post_json("https://example.test", {})
|
|
assert result.ok is False
|
|
assert result.status_code == 0
|
|
assert result.data == {}
|
|
assert result.text == ""
|
|
assert str(exc).split(":")[0] in result.error or str(exc) in result.error
|
|
|
|
|
|
class TestPostJsonResponseDecoding:
|
|
"""Non-JSON / non-dict / empty bodies must degrade gracefully."""
|
|
|
|
def test_non_json_body_yields_empty_data_and_keeps_text(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(
|
|
200, ValueError("not json"), text="<html>oops</html>"
|
|
),
|
|
)
|
|
result = post_json("https://example.test", {})
|
|
assert result.ok is True
|
|
assert result.status_code == 200
|
|
assert result.data == {} # callers can still .get(...) safely
|
|
assert result.text == "<html>oops</html>"
|
|
|
|
def test_json_array_response_is_treated_as_no_data(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""A JSON list at the top level isn't a provider-style envelope; we
|
|
fall back to ``data={}`` so callers don't accidentally subscript a list."""
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(200, [1, 2, 3], text="[1,2,3]"),
|
|
)
|
|
result = post_json("https://example.test", {})
|
|
assert result.ok is True
|
|
assert result.data == {}
|
|
assert result.text == "[1,2,3]"
|
|
|
|
def test_empty_body_yields_empty_data_and_text(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(204, ValueError("empty"), text=""),
|
|
)
|
|
result = post_json("https://example.test", {})
|
|
assert result.ok is True
|
|
assert result.status_code == 204
|
|
assert result.data == {}
|
|
assert result.text == ""
|
|
|
|
|
|
class TestDeliveryResponseShape:
|
|
def test_default_construction_is_safe(self) -> None:
|
|
"""Defaults must allow callers to chain ``.data.get(...)`` and
|
|
access ``.status_code`` even when the helper hasn't populated them."""
|
|
result = DeliveryResponse(ok=False)
|
|
assert result.ok is False
|
|
assert result.status_code == 0
|
|
assert result.data == {}
|
|
assert result.text == ""
|
|
assert result.error == ""
|
|
assert result.exc_type == ""
|
|
|
|
def test_response_is_frozen(self) -> None:
|
|
"""``DeliveryResponse`` is a frozen dataclass — callers cannot mutate
|
|
the result after the fact."""
|
|
from dataclasses import FrozenInstanceError
|
|
|
|
result = DeliveryResponse(ok=True)
|
|
with pytest.raises(FrozenInstanceError):
|
|
result.ok = False # type: ignore[misc]
|
|
|
|
def test_data_is_read_only(self) -> None:
|
|
"""``data`` is wrapped in ``MappingProxyType`` so the frozen
|
|
dataclass stays fully immutable end-to-end. Mutating it must
|
|
raise ``TypeError`` rather than silently succeeding."""
|
|
result = DeliveryResponse(ok=True, data={"a": 1})
|
|
with pytest.raises(TypeError):
|
|
result.data["b"] = 2 # type: ignore[index]
|
|
|
|
def test_data_is_isolated_from_caller_dict(self) -> None:
|
|
"""Mutating the dict the caller passed in must not bleed through
|
|
to the response — the proxy must wrap a copy, not the original."""
|
|
original: dict[str, Any] = {"a": 1}
|
|
result = DeliveryResponse(ok=True, data=original)
|
|
original["a"] = 999
|
|
original["b"] = "leaked"
|
|
assert dict(result.data) == {"a": 1}
|
|
|
|
def test_data_compares_equal_to_plain_dict(self) -> None:
|
|
"""``MappingProxyType`` must still compare equal to a regular dict
|
|
so existing assertions in delivery test files keep working."""
|
|
body = {"ok": True, "id": "msg-1"}
|
|
result = DeliveryResponse(ok=True, status_code=200, data=body)
|
|
assert result.data == body
|
|
|
|
def test_default_data_instances_are_independent(self) -> None:
|
|
"""Each default ``data`` must be its own mapping — no shared state
|
|
between instances (a classic mutable-default-argument bug)."""
|
|
a = DeliveryResponse(ok=True)
|
|
b = DeliveryResponse(ok=True)
|
|
assert a.data is not b.data
|
|
|
|
|
|
class TestPostJsonErrorType:
|
|
"""The transport surfaces the exception class name on failure so callers
|
|
can include it in triage logs without parsing the error string."""
|
|
|
|
def test_exc_type_populated_on_transport_exception(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def _raise(*_a: Any, **_kw: Any) -> Any:
|
|
raise TimeoutError("read timeout")
|
|
|
|
monkeypatch.setattr("platform.notifications.delivery_transport.httpx.post", _raise)
|
|
result = post_json("https://example.test", {})
|
|
assert result.ok is False
|
|
assert result.exc_type == "TimeoutError"
|
|
assert "read timeout" in result.error
|
|
|
|
@pytest.mark.parametrize(
|
|
("exc", "expected_name"),
|
|
[
|
|
(httpx.ConnectError("refused"), "ConnectError"),
|
|
(httpx.ReadTimeout("timed out"), "ReadTimeout"),
|
|
(OSError("network down"), "OSError"),
|
|
(RuntimeError("oops"), "RuntimeError"),
|
|
],
|
|
)
|
|
def test_exc_type_matches_exception_class(
|
|
self, exc: Exception, expected_name: str, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def _raise(*_a: Any, **_kw: Any) -> Any:
|
|
raise exc
|
|
|
|
monkeypatch.setattr("platform.notifications.delivery_transport.httpx.post", _raise)
|
|
result = post_json("https://example.test", {})
|
|
assert result.exc_type == expected_name
|
|
|
|
def test_exc_type_empty_on_success(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(200, {"ok": True}),
|
|
)
|
|
result = post_json("https://example.test", {})
|
|
assert result.ok is True
|
|
assert result.exc_type == ""
|
|
|
|
|
|
class TestPostFormHappyPath:
|
|
"""post_form sends form-encoded data with optional HTTP Basic Auth."""
|
|
|
|
def test_returns_ok_with_status_data_text(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
body = {"sid": "SM123"}
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(201, body, '{"sid":"SM123"}'),
|
|
)
|
|
result = post_form("https://api.twilio.com/test", {"To": "+1", "Body": "hi"})
|
|
assert result.ok is True
|
|
assert result.status_code == 201
|
|
assert result.data == body
|
|
assert result.error == ""
|
|
|
|
def test_passes_auth_and_data_through(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
|
|
def _capture(url: str, **kwargs: Any) -> MagicMock:
|
|
captured["url"] = url
|
|
captured.update(kwargs)
|
|
return _mock_response(201, {"sid": "SM1"})
|
|
|
|
monkeypatch.setattr("platform.notifications.delivery_transport.httpx.post", _capture)
|
|
post_form(
|
|
"https://api.twilio.com/test",
|
|
{"To": "+1", "Body": "hi"},
|
|
auth=("AC1", "secret"),
|
|
timeout=10.0,
|
|
)
|
|
assert captured["url"] == "https://api.twilio.com/test"
|
|
assert captured["data"] == {"To": "+1", "Body": "hi"}
|
|
assert captured["auth"] == ("AC1", "secret")
|
|
assert captured["timeout"] == 10.0
|
|
assert captured["follow_redirects"] is False
|
|
# post_form must NOT send json=
|
|
assert "json" not in captured
|
|
|
|
def test_auth_none_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, Any] = {}
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **kw: captured.update(kw) or _mock_response(200, {}),
|
|
)
|
|
post_form("https://example.test", {"k": "v"})
|
|
assert captured["auth"] is None
|
|
|
|
|
|
class TestPostFormTransportFailures:
|
|
"""post_form must catch transport errors like post_json."""
|
|
|
|
def test_request_exception_becomes_ok_false(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _raise(*_a: Any, **_kw: Any) -> Any:
|
|
raise ConnectionError("refused")
|
|
|
|
monkeypatch.setattr("platform.notifications.delivery_transport.httpx.post", _raise)
|
|
result = post_form("https://example.test", {"k": "v"})
|
|
assert result.ok is False
|
|
assert result.status_code == 0
|
|
assert "refused" in result.error
|
|
assert result.exc_type == "ConnectionError"
|
|
|
|
def test_non_json_body_yields_empty_data(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(
|
|
"platform.notifications.delivery_transport.httpx.post",
|
|
lambda *_a, **_kw: _mock_response(
|
|
200, ValueError("not json"), text="<html>oops</html>"
|
|
),
|
|
)
|
|
result = post_form("https://example.test", {})
|
|
assert result.ok is True
|
|
assert result.data == {}
|
|
assert result.text == "<html>oops</html>"
|