import asyncio import json from app.electrum.client import ElectrumClient, ElectrumError class FakeWriter: def __init__(self): self.written = b"" def write(self, data: bytes) -> None: self.written += data async def drain(self) -> None: pass def close(self) -> None: pass async def wait_closed(self) -> None: pass async def _client_with_fake_transport() -> tuple[ElectrumClient, asyncio.StreamReader, FakeWriter]: client = ElectrumClient("localhost", 1234) reader = asyncio.StreamReader() writer = FakeWriter() client._reader = reader client._writer = writer client._read_task = asyncio.create_task(client._read_loop()) return client, reader, writer async def test_request_resolves_on_matching_response(): client, reader, writer = await _client_with_fake_transport() task = asyncio.create_task(client.request("blockchain.headers.subscribe")) await asyncio.sleep(0) # let request() write the payload sent = json.loads(writer.written.decode()) assert sent["method"] == "blockchain.headers.subscribe" reader.feed_data((json.dumps({"id": sent["id"], "result": {"height": 100}}) + "\n").encode()) result = await task assert result == {"height": 100} client._read_task.cancel() async def test_error_response_raises_electrum_error(): client, reader, writer = await _client_with_fake_transport() task = asyncio.create_task(client.request("blockchain.transaction.broadcast", ["deadbeef"])) await asyncio.sleep(0) sent = json.loads(writer.written.decode()) reader.feed_data((json.dumps({"id": sent["id"], "error": "bad tx"}) + "\n").encode()) try: await task assert False, "expected ElectrumError" except ElectrumError: pass client._read_task.cancel() async def test_notification_delivered_to_subscription_queue(): client, reader, writer = await _client_with_fake_transport() queue = client.notifications("blockchain.scripthash.subscribe") push = {"method": "blockchain.scripthash.subscribe", "params": ["abcd", "newstatus"]} reader.feed_data((json.dumps(push) + "\n").encode()) await asyncio.sleep(0) params = await asyncio.wait_for(queue.get(), timeout=1) assert params == ["abcd", "newstatus"] client._read_task.cancel() async def test_request_times_out_instead_of_hanging_forever(monkeypatch): """B-01: a server that owes us a reply and never sends one used to hang the caller permanently — which meant a POST /bets could hang while holding the per-user lock, and the confirmation poller could stop polling for good.""" from app.electrum import client as client_module monkeypatch.setattr(client_module, "_REQUEST_TIMEOUT_SECONDS", 0.05) client, reader, writer = await _client_with_fake_transport() try: await client.request("blockchain.transaction.get", ["deadbeef"]) assert False, "expected ElectrumError" except ElectrumError as exc: assert "timed out" in str(exc) # The connection is torn down, so callers stop reusing a server that owes us. assert client._closed.is_set() client._read_task.cancel() async def test_wait_closed_resolves_when_the_read_loop_dies(): """B-01: the read loop dying used to be invisible — the listener sat on its notification queues forever and never reconnected.""" client, reader, writer = await _client_with_fake_transport() waiter = asyncio.create_task(client.wait_closed()) reader.feed_eof() # peer closed the connection await asyncio.wait_for(waiter, timeout=1) client._read_task.cancel() async def test_pending_request_fails_when_the_connection_drops(): client, reader, writer = await _client_with_fake_transport() task = asyncio.create_task(client.request("server.ping")) await asyncio.sleep(0) reader.feed_eof() try: await asyncio.wait_for(task, timeout=1) assert False, "expected ElectrumError" except ElectrumError: pass def test_parse_endpoints_puts_the_primary_first_and_dedupes(): from app.electrum.client import ElectrumEndpoint, parse_endpoints endpoints = parse_endpoints( "primary.example", 50002, True, "second.example:50002, third.example:50001:notls, primary.example:50002" ) assert endpoints == [ ElectrumEndpoint("primary.example", 50002, True), ElectrumEndpoint("second.example", 50002, True), ElectrumEndpoint("third.example", 50001, False), ] def test_parse_endpoints_handles_an_empty_fallback_list(): from app.electrum.client import ElectrumEndpoint, parse_endpoints assert parse_endpoints("only.example", 50002, True, "") == [ElectrumEndpoint("only.example", 50002, True)] assert parse_endpoints("only.example", 50002, True, " , ") == [ ElectrumEndpoint("only.example", 50002, True) ] def test_parse_endpoints_rejects_a_malformed_entry(): """A typo in a fallback server must fail at startup, not during the outage when the fallback is the thing that's needed.""" import pytest from app.electrum.client import parse_endpoints for bad in ["nohost:", "host:notaport", "host", "host:50002:weird"]: with pytest.raises(ValueError): parse_endpoints("primary.example", 50002, True, bad)