7 changed files with 595 additions and 118 deletions
@ -0,0 +1,489 @@ |
|||||
|
import aiohttp |
||||
|
import asyncio |
||||
|
import json |
||||
|
import logging |
||||
|
|
||||
|
import pytest |
||||
|
|
||||
|
from aiorpcx import ( |
||||
|
JSONRPCv1, JSONRPCLoose, RPCError, ignore_after, |
||||
|
Request, Batch, |
||||
|
) |
||||
|
from electrumx.lib.coins import BitcoinCash, CoinError, Bitzeny |
||||
|
from electrumx.server.daemon import ( |
||||
|
Daemon, FakeEstimateFeeDaemon, DaemonError |
||||
|
) |
||||
|
|
||||
|
|
||||
|
coin = BitcoinCash |
||||
|
|
||||
|
# These should be full, canonical URLs |
||||
|
urls = ['http://rpc_user:rpc_pass@127.0.0.1:8332/', |
||||
|
'http://rpc_user:rpc_pass@192.168.0.1:8332/'] |
||||
|
|
||||
|
|
||||
|
@pytest.fixture(params=[BitcoinCash, Bitzeny]) |
||||
|
def daemon(request): |
||||
|
coin = request.param |
||||
|
return coin.DAEMON(coin, ','.join(urls)) |
||||
|
|
||||
|
|
||||
|
class ResponseBase(object): |
||||
|
|
||||
|
def __init__(self, headers, status): |
||||
|
self.headers = headers |
||||
|
self.status = status |
||||
|
|
||||
|
async def __aenter__(self): |
||||
|
return self |
||||
|
|
||||
|
async def __aexit__(self, exc_type, exc_value, traceback): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class JSONResponse(ResponseBase): |
||||
|
|
||||
|
def __init__(self, result, msg_id, status=200): |
||||
|
super().__init__({'Content-Type': 'application/json'}, status) |
||||
|
self.result = result |
||||
|
self.msg_id = msg_id |
||||
|
|
||||
|
async def json(self): |
||||
|
if isinstance(self.msg_id, int): |
||||
|
message = JSONRPCv1.response_message(self.result, self.msg_id) |
||||
|
else: |
||||
|
parts = [JSONRPCv1.response_message(item, msg_id) |
||||
|
for item, msg_id in zip(self.result, self.msg_id)] |
||||
|
message = JSONRPCv1.batch_message_from_parts(parts) |
||||
|
return json.loads(message.decode()) |
||||
|
|
||||
|
|
||||
|
class HTMLResponse(ResponseBase): |
||||
|
|
||||
|
def __init__(self, text, reason, status): |
||||
|
super().__init__({'Content-Type': 'text/html; charset=ISO-8859-1'}, |
||||
|
status) |
||||
|
self._text = text |
||||
|
self.reason = reason |
||||
|
|
||||
|
async def text(self): |
||||
|
return self._text |
||||
|
|
||||
|
|
||||
|
class ClientSessionBase(object): |
||||
|
|
||||
|
def __enter__(self): |
||||
|
self.prior_class = aiohttp.ClientSession |
||||
|
aiohttp.ClientSession = lambda: self |
||||
|
|
||||
|
def __exit__(self, exc_type, exc_value, traceback): |
||||
|
aiohttp.ClientSession = self.prior_class |
||||
|
|
||||
|
async def __aenter__(self): |
||||
|
return self |
||||
|
|
||||
|
async def __aexit__(self, exc_type, exc_value, traceback): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
class ClientSessionGood(ClientSessionBase): |
||||
|
'''Imitate aiohttp for testing purposes.''' |
||||
|
|
||||
|
def __init__(self, *triples): |
||||
|
self.triples = triples # each a (method, args, result) |
||||
|
self.count = 0 |
||||
|
self.expected_url = urls[0] |
||||
|
|
||||
|
def post(self, url, data=""): |
||||
|
assert url == self.expected_url |
||||
|
request, request_id = JSONRPCLoose.message_to_item(data.encode()) |
||||
|
method, args, result = self.triples[self.count] |
||||
|
self.count += 1 |
||||
|
if isinstance(request, Request): |
||||
|
assert request.method == method |
||||
|
assert request.args == args |
||||
|
return JSONResponse(result, request_id) |
||||
|
else: |
||||
|
assert isinstance(request, Batch) |
||||
|
for request, args in zip(request, args): |
||||
|
assert request.method == method |
||||
|
assert request.args == args |
||||
|
return JSONResponse(result, request_id) |
||||
|
|
||||
|
|
||||
|
class ClientSessionBadAuth(ClientSessionBase): |
||||
|
|
||||
|
def post(self, url, data=""): |
||||
|
return HTMLResponse('', 'Unauthorized', 401) |
||||
|
|
||||
|
|
||||
|
class ClientSessionWorkQueueFull(ClientSessionGood): |
||||
|
|
||||
|
def post(self, url, data=""): |
||||
|
self.post = super().post |
||||
|
return HTMLResponse('Work queue depth exceeded', |
||||
|
'Internal server error', 500) |
||||
|
|
||||
|
|
||||
|
class ClientSessionNoConnection(ClientSessionGood): |
||||
|
|
||||
|
def __init__(self, *args): |
||||
|
self.args = args |
||||
|
|
||||
|
async def __aenter__(self): |
||||
|
aiohttp.ClientSession = lambda: ClientSessionGood(*self.args) |
||||
|
raise aiohttp.ClientConnectionError |
||||
|
|
||||
|
|
||||
|
class ClientSessionPostError(ClientSessionGood): |
||||
|
|
||||
|
def __init__(self, exception, *args): |
||||
|
self.exception = exception |
||||
|
self.args = args |
||||
|
|
||||
|
def post(self, url, data=""): |
||||
|
aiohttp.ClientSession = lambda: ClientSessionGood(*self.args) |
||||
|
raise self.exception |
||||
|
|
||||
|
|
||||
|
class ClientSessionFailover(ClientSessionGood): |
||||
|
|
||||
|
def post(self, url, data=""): |
||||
|
# If not failed over; simulate disconnecting |
||||
|
if url == self.expected_url: |
||||
|
raise aiohttp.ServerDisconnectedError |
||||
|
else: |
||||
|
self.expected_url = urls[1] |
||||
|
return super().post(url, data) |
||||
|
|
||||
|
|
||||
|
def in_caplog(caplog, message, count=1): |
||||
|
return sum(message in record.message |
||||
|
for record in caplog.records) == count |
||||
|
|
||||
|
# |
||||
|
# Tests |
||||
|
# |
||||
|
|
||||
|
def test_set_urls_bad(): |
||||
|
with pytest.raises(CoinError): |
||||
|
Daemon(coin, '') |
||||
|
with pytest.raises(CoinError): |
||||
|
Daemon(coin, 'a') |
||||
|
|
||||
|
|
||||
|
def test_set_urls_one(caplog): |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
daemon = Daemon(coin, urls[0]) |
||||
|
assert daemon.current_url() == urls[0] |
||||
|
assert len(daemon.urls) == 1 |
||||
|
logged_url = daemon.logged_url() |
||||
|
assert logged_url == '127.0.0.1:8332/' |
||||
|
assert in_caplog(caplog, f'daemon #1 at {logged_url} (current)') |
||||
|
|
||||
|
|
||||
|
def test_set_urls_two(caplog): |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
daemon = Daemon(coin, ','.join(urls)) |
||||
|
assert daemon.current_url() == urls[0] |
||||
|
assert len(daemon.urls) == 2 |
||||
|
logged_url = daemon.logged_url() |
||||
|
assert logged_url == '127.0.0.1:8332/' |
||||
|
assert in_caplog(caplog, f'daemon #1 at {logged_url} (current)') |
||||
|
assert in_caplog(caplog, 'daemon #2 at 192.168.0.1:8332') |
||||
|
|
||||
|
|
||||
|
def test_set_urls_short(): |
||||
|
no_prefix_urls = ['/'.join(part for part in url.split('/')[2:]) |
||||
|
for url in urls] |
||||
|
daemon = Daemon(coin, ','.join(no_prefix_urls)) |
||||
|
assert daemon.current_url() == urls[0] |
||||
|
assert len(daemon.urls) == 2 |
||||
|
|
||||
|
no_slash_urls = [url[:-1] for url in urls] |
||||
|
daemon = Daemon(coin, ','.join(no_slash_urls)) |
||||
|
assert daemon.current_url() == urls[0] |
||||
|
assert len(daemon.urls) == 2 |
||||
|
|
||||
|
no_port_urls = [url[:url.rfind(':')] for url in urls] |
||||
|
daemon = Daemon(coin, ','.join(no_port_urls)) |
||||
|
assert daemon.current_url() == urls[0] |
||||
|
assert len(daemon.urls) == 2 |
||||
|
|
||||
|
|
||||
|
def test_failover_good(caplog): |
||||
|
daemon = Daemon(coin, ','.join(urls)) |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
result = daemon.failover() |
||||
|
assert result is True |
||||
|
assert daemon.current_url() == urls[1] |
||||
|
logged_url = daemon.logged_url() |
||||
|
assert in_caplog(caplog, f'failing over to {logged_url}') |
||||
|
# And again |
||||
|
result = daemon.failover() |
||||
|
assert result is True |
||||
|
assert daemon.current_url() == urls[0] |
||||
|
|
||||
|
|
||||
|
def test_failover_fail(caplog): |
||||
|
daemon = Daemon(coin, urls[0]) |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
result = daemon.failover() |
||||
|
assert result is False |
||||
|
assert daemon.current_url() == urls[0] |
||||
|
assert not in_caplog(caplog, f'failing over') |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_height(daemon): |
||||
|
assert daemon.cached_height() is None |
||||
|
height = 300 |
||||
|
with ClientSessionGood(('getblockcount', [], height)): |
||||
|
assert await daemon.height() == height |
||||
|
assert daemon.cached_height() == height |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_broadcast_transaction(daemon): |
||||
|
raw_tx = 'deadbeef' |
||||
|
tx_hash = 'hash' |
||||
|
with ClientSessionGood(('sendrawtransaction', [raw_tx], tx_hash)): |
||||
|
assert await daemon.broadcast_transaction(raw_tx) == tx_hash |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_relayfee(daemon): |
||||
|
response = {"relayfee": sats, "other:": "cruft"} |
||||
|
with ClientSessionGood(('getnetworkinfo', [], response)): |
||||
|
assert await daemon.getnetworkinfo() == response |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_relayfee(daemon): |
||||
|
if isinstance(daemon, FakeEstimateFeeDaemon): |
||||
|
sats = daemon.coin.ESTIMATE_FEE |
||||
|
else: |
||||
|
sats = 2 |
||||
|
response = {"relayfee": sats, "other:": "cruft"} |
||||
|
with ClientSessionGood(('getnetworkinfo', [], response)): |
||||
|
assert await daemon.relayfee() == sats |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_mempool_hashes(daemon): |
||||
|
hashes = ['hex_hash1', 'hex_hash2'] |
||||
|
with ClientSessionGood(('getrawmempool', [], hashes)): |
||||
|
assert await daemon.mempool_hashes() == hashes |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_deserialised_block(daemon): |
||||
|
block_hash = 'block_hash' |
||||
|
result = {'some': 'mess'} |
||||
|
with ClientSessionGood(('getblock', [block_hash, True], result)): |
||||
|
assert await daemon.deserialised_block(block_hash) == result |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_estimatefee(daemon): |
||||
|
method_not_found = RPCError(JSONRPCv1.METHOD_NOT_FOUND, 'nope') |
||||
|
if isinstance(daemon, FakeEstimateFeeDaemon): |
||||
|
result = daemon.coin.ESTIMATE_FEE |
||||
|
else: |
||||
|
result = -1 |
||||
|
with ClientSessionGood( |
||||
|
('estimatesmartfee', [], method_not_found), |
||||
|
('estimatefee', [2], result) |
||||
|
): |
||||
|
assert await daemon.estimatefee(2) == result |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_estimatefee_smart(daemon): |
||||
|
bad_args = RPCError(JSONRPCv1.INVALID_ARGS, 'bad args') |
||||
|
if isinstance(daemon, FakeEstimateFeeDaemon): |
||||
|
return |
||||
|
rate = 0.0002 |
||||
|
result = {'feerate': rate} |
||||
|
with ClientSessionGood( |
||||
|
('estimatesmartfee', [], bad_args), |
||||
|
('estimatesmartfee', [2], result) |
||||
|
): |
||||
|
assert await daemon.estimatefee(2) == rate |
||||
|
|
||||
|
# Test the rpc_available_cache is used |
||||
|
with ClientSessionGood(('estimatesmartfee', [2], result)): |
||||
|
assert await daemon.estimatefee(2) == rate |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_getrawtransaction(daemon): |
||||
|
hex_hash = 'deadbeef' |
||||
|
simple = 'tx_in_hex' |
||||
|
verbose = {'hex': hex_hash, 'other': 'cruft'} |
||||
|
# Test False is converted to 0 - old daemon's reject False |
||||
|
with ClientSessionGood(('getrawtransaction', [hex_hash, 0], simple)): |
||||
|
assert await daemon.getrawtransaction(hex_hash) == simple |
||||
|
|
||||
|
# Test True is converted to 1 |
||||
|
with ClientSessionGood(('getrawtransaction', [hex_hash, 1], verbose)): |
||||
|
assert await daemon.getrawtransaction( |
||||
|
hex_hash, True) == verbose |
||||
|
|
||||
|
|
||||
|
# Batch tests |
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_empty_send(daemon): |
||||
|
first = 5 |
||||
|
count = 0 |
||||
|
with ClientSessionGood(('getblockhash', [], [])): |
||||
|
assert await daemon.block_hex_hashes(first, count) == [] |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_block_hex_hashes(daemon): |
||||
|
first = 5 |
||||
|
count = 3 |
||||
|
hashes = [f'hex_hash{n}' for n in range(count)] |
||||
|
with ClientSessionGood(('getblockhash', |
||||
|
[[n] for n in range(first, first + count)], |
||||
|
hashes)): |
||||
|
assert await daemon.block_hex_hashes(first, count) == hashes |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_raw_blocks(daemon): |
||||
|
count = 3 |
||||
|
hex_hashes = [f'hex_hash{n}' for n in range(count)] |
||||
|
args_list = [[hex_hash, False] for hex_hash in hex_hashes] |
||||
|
iterable = (hex_hash for hex_hash in hex_hashes) |
||||
|
blocks = ["00", "019a", "02fe"] |
||||
|
blocks_raw = [bytes.fromhex(block) for block in blocks] |
||||
|
with ClientSessionGood(('getblock', args_list, blocks)): |
||||
|
assert await daemon.raw_blocks(iterable) == blocks_raw |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_get_raw_transactions(daemon): |
||||
|
hex_hashes = ['deadbeef0', 'deadbeef1'] |
||||
|
args_list = [[hex_hash, 0] for hex_hash in hex_hashes] |
||||
|
raw_txs_hex = ['fffefdfc', '0a0b0c0d'] |
||||
|
raw_txs = [bytes.fromhex(raw_tx) for raw_tx in raw_txs_hex] |
||||
|
# Test 0 - old daemon's reject False |
||||
|
with ClientSessionGood(('getrawtransaction', args_list, raw_txs_hex)): |
||||
|
assert await daemon.getrawtransactions(hex_hashes) == raw_txs |
||||
|
|
||||
|
# Test one error |
||||
|
tx_not_found = RPCError(-1, 'some error message') |
||||
|
results = ['ff0b7d', tx_not_found] |
||||
|
raw_txs = [bytes.fromhex(results[0]), None] |
||||
|
with ClientSessionGood(('getrawtransaction', args_list, results)): |
||||
|
assert await daemon.getrawtransactions(hex_hashes) == raw_txs |
||||
|
|
||||
|
|
||||
|
# Other tests |
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_bad_auth(daemon, caplog): |
||||
|
with pytest.raises(DaemonError) as e: |
||||
|
with ClientSessionBadAuth(): |
||||
|
await daemon.height() |
||||
|
|
||||
|
assert "Unauthorized" in e.value.args[0] |
||||
|
assert in_caplog(caplog, "Unauthorized") |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_workqueue_depth(daemon, caplog): |
||||
|
daemon.init_retry = 0.01 |
||||
|
height = 125 |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
with ClientSessionWorkQueueFull(('getblockcount', [], height)): |
||||
|
await daemon.height() == height |
||||
|
|
||||
|
assert in_caplog(caplog, "work queue full") |
||||
|
assert in_caplog(caplog, "running normally") |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_connection_error(daemon, caplog): |
||||
|
height = 100 |
||||
|
daemon.init_retry = 0.01 |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
with ClientSessionNoConnection(('getblockcount', [], height)): |
||||
|
await daemon.height() == height |
||||
|
|
||||
|
assert in_caplog(caplog, "connection problem - is your daemon running?") |
||||
|
assert in_caplog(caplog, "connection restored") |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_timeout_error(daemon, caplog): |
||||
|
height = 100 |
||||
|
daemon.init_retry = 0.01 |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
with ClientSessionPostError(asyncio.TimeoutError, |
||||
|
('getblockcount', [], height)): |
||||
|
await daemon.height() == height |
||||
|
|
||||
|
assert in_caplog(caplog, "timeout error") |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_disconnected(daemon, caplog): |
||||
|
height = 100 |
||||
|
daemon.init_retry = 0.01 |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
with ClientSessionPostError(aiohttp.ServerDisconnectedError, |
||||
|
('getblockcount', [], height)): |
||||
|
await daemon.height() == height |
||||
|
|
||||
|
assert in_caplog(caplog, "disconnected") |
||||
|
assert in_caplog(caplog, "connection restored") |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_warming_up(daemon, caplog): |
||||
|
warming_up = RPCError(-28, 'reading block index') |
||||
|
height = 100 |
||||
|
daemon.init_retry = 0.01 |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
with ClientSessionGood( |
||||
|
('getblockcount', [], warming_up), |
||||
|
('getblockcount', [], height) |
||||
|
): |
||||
|
assert await daemon.height() == height |
||||
|
|
||||
|
assert in_caplog(caplog, "starting up checking blocks") |
||||
|
assert in_caplog(caplog, "running normally") |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_warming_up_batch(daemon, caplog): |
||||
|
warming_up = RPCError(-28, 'reading block index') |
||||
|
first = 5 |
||||
|
count = 1 |
||||
|
daemon.init_retry = 0.01 |
||||
|
hashes = ['hex_hash5'] |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
with ClientSessionGood(('getblockhash', [[first]], [warming_up]), |
||||
|
('getblockhash', [[first]], hashes)): |
||||
|
assert await daemon.block_hex_hashes(first, count) == hashes |
||||
|
|
||||
|
assert in_caplog(caplog, "starting up checking blocks") |
||||
|
assert in_caplog(caplog, "running normally") |
||||
|
|
||||
|
|
||||
|
@pytest.mark.asyncio |
||||
|
async def test_failover(daemon, caplog): |
||||
|
height = 100 |
||||
|
daemon.init_retry = 0.01 |
||||
|
daemon.max_retry = 0.04 |
||||
|
with caplog.at_level(logging.INFO): |
||||
|
with ClientSessionFailover(('getblockcount', [], height)): |
||||
|
await daemon.height() == height |
||||
|
|
||||
|
assert in_caplog(caplog, "disconnected", 3) |
||||
|
assert in_caplog(caplog, "failing over") |
||||
|
assert in_caplog(caplog, "connection restored") |
Loading…
Reference in new issue