Files
bal-electrum-plugin/tests/test_connectivity.py
2026-06-20 09:49:39 -04:00

294 lines
9.9 KiB
Python

"""
Connectivity / network-error handling tests for the will-executor client.
Simulates every realistic network failure mode and asserts the plugin handles
each one gracefully (no crash, sane fallback) across all the requests it makes
to will-executor servers and to the welist:
* offline (no Electrum network instance)
* connection refused / reset (``OSError`` / ``ConnectionError``)
* DNS resolution failure (``socket.gaierror``)
* request timeout (``TimeoutError``) -> retried, then gives up
* HTTP / server errors (generic ``Exception`` from the transport)
* malformed / empty response bodies
All tests mock the Electrum transport (``Network.send_http_on_proxy`` /
``Network.get_instance``) so they never touch the real network.
"""
import os
import socket
import sys
import pytest
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _online():
"""Patch ``Network.get_instance`` to look online."""
return patch(
"bal.core.willexecutors.Network.get_instance",
return_value=MagicMock(),
)
def _transport(side_effect=None, return_value=None):
"""Patch the low-level HTTP transport used by ``send_request``."""
return patch(
"bal.core.willexecutors.Network.send_http_on_proxy",
side_effect=side_effect,
return_value=return_value,
)
def _we(url="https://we.bitcoin-after.life"):
return {
"url": url,
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"base_fee": 100000,
"selected": True,
"status": "New",
}
# ------------------------------------------------------------------ #
# Offline (no network instance)
# ------------------------------------------------------------------ #
def test_send_request_offline_raises():
with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
with pytest.raises(Exception) as exc:
Willexecutors.send_request("get", "https://we.example/info")
assert "offline" in str(exc.value).lower()
def test_get_info_task_offline_marks_ko():
we = _we()
with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
out = Willexecutors.get_info_task(we["url"], we)
assert out["status"] == "KO"
def test_download_list_offline_returns_empty():
with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life/")
assert out == {}
# ------------------------------------------------------------------ #
# Connection refused / reset
# ------------------------------------------------------------------ #
@pytest.mark.parametrize("exc", [
ConnectionRefusedError("refused"),
ConnectionResetError("reset"),
OSError("network unreachable"),
])
def test_get_info_task_connection_errors(exc):
we = _we()
with _online(), _transport(side_effect=exc):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
@pytest.mark.parametrize("exc", [
ConnectionRefusedError("refused"),
OSError("unreachable"),
])
def test_download_list_connection_errors(exc):
with _online(), _transport(side_effect=exc):
out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life")
assert out == {}
@pytest.mark.parametrize("exc", [
ConnectionRefusedError("refused"),
OSError("unreachable"),
])
def test_push_connection_errors_marks_failed(exc):
we = _we()
we["txs"] = "rawtxhex\n"
with _online(), _transport(side_effect=exc):
ok = Willexecutors.push_transactions_to_willexecutor(
we, timeout=1, max_retries=0, retry_sleep=0
)
assert ok is False
assert we["broadcast_status"] == "Failed"
def test_check_transaction_connection_error_raises():
with _online(), _transport(side_effect=ConnectionRefusedError("refused")):
with pytest.raises(Exception):
Willexecutors.check_transaction(
"txid", "https://we.example", timeout=1, max_retries=0,
retry_sleep=0,
)
# ------------------------------------------------------------------ #
# DNS resolution failure
# ------------------------------------------------------------------ #
def test_get_info_task_dns_failure():
we = _we("https://nonexistent.invalid")
with _online(), _transport(side_effect=socket.gaierror("name resolution")):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
def test_download_list_dns_failure():
with _online(), _transport(side_effect=socket.gaierror("name resolution")):
out = Willexecutors.download_list({}, "https://nonexistent.invalid")
assert out == {}
# ------------------------------------------------------------------ #
# Timeout (retried, then handled)
# ------------------------------------------------------------------ #
def test_send_request_timeout_retries_then_gives_up():
calls = {"n": 0}
def boom(*a, **k):
calls["n"] += 1
raise TimeoutError("timed out")
with _online(), _transport(side_effect=boom):
# max_retries=2 + initial attempt = 3 calls, then returns None.
result = Willexecutors.send_request(
"get", "https://we.example/info", max_retries=2, retry_sleep=0,
)
assert result is None
assert calls["n"] == 3 # 1 initial + 2 retries
def test_send_request_timeout_then_success():
seq = [TimeoutError("t1"), "ok"]
def maybe(*a, **k):
v = seq.pop(0)
if isinstance(v, Exception):
raise v
return v
with _online(), _transport(side_effect=maybe):
result = Willexecutors.send_request(
"get", "https://we.example/info", max_retries=3, retry_sleep=0,
)
assert result == "ok"
def test_get_info_task_timeout_marks_ko():
we = _we()
with _online(), _transport(side_effect=TimeoutError("timeout")):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
# ------------------------------------------------------------------ #
# HTTP / server errors
# ------------------------------------------------------------------ #
def test_send_request_http_error_propagates():
with _online(), _transport(side_effect=Exception("HTTP 500")):
with pytest.raises(Exception):
Willexecutors.send_request("get", "https://we.example/info")
def test_get_info_task_http_error_marks_ko():
we = _we()
with _online(), _transport(side_effect=Exception("HTTP 503")):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
# ------------------------------------------------------------------ #
# Malformed / unexpected responses
# ------------------------------------------------------------------ #
def test_get_info_task_non_dict_reply_marks_ko():
"""A non-dict reply (e.g. plain string / empty) marks the server KO."""
we = _we()
with _online(), _transport(return_value="not-a-dict"):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
def test_get_info_task_empty_reply_marks_ko():
we = _we()
with _online(), _transport(return_value=None):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
def test_get_info_task_valid_reply_updates_fields():
"""A well-formed info reply updates the will-executor's fee/address/info."""
we = _we()
reply = {
"base_fee": 123456,
"address": "bcrt1qnewaddressxxxxxxxxxxxxxxxxxxxxxxxxxx",
"info": "Test Will Executor",
}
with _online(), _transport(return_value=reply):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == 200
assert out["base_fee"] == 123456
assert out["info"] == "Test Will Executor"
def test_download_list_malformed_json_returns_empty():
"""A reply that is not an iterable mapping is handled without crashing."""
# send_request returns a bare int -> iterating it raises -> caught -> {}.
with _online(), _transport(return_value=12345):
out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life")
assert out == {}
# ------------------------------------------------------------------ #
# Parallel helpers degrade gracefully under failure
# ------------------------------------------------------------------ #
def test_ping_servers_parallel_all_fail():
wes = {
"https://a.example": _we("https://a.example"),
"https://b.example": _we("https://b.example"),
}
with _online(), _transport(side_effect=OSError("down")):
out = Willexecutors.ping_servers_parallel(wes, timeout=1)
assert all(v["status"] == "KO" for v in out.values())
def test_check_transactions_parallel_all_fail():
items = [("wid1", "https://a.example"), ("wid2", "https://b.example")]
with _online(), _transport(side_effect=ConnectionRefusedError("no")):
results = Willexecutors.check_transactions_parallel(
items, deadline=5,
)
# Each target reports an exception rather than crashing the batch.
for wid, (res, exc) in results.items():
assert res is None
assert exc is not None
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
try:
obj()
print(f" [OK] {name}")
except TypeError:
# parametrized tests need pytest; skip in __main__ mode.
print(f" [SKIP __main__] {name}")
print("[done] connectivity tests")