""" 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")