forked from bitcoinafterlife/bal-electrum-plugin
294 lines
9.9 KiB
Python
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")
|