#!/usr/bin/env python3 # -*- coding: utf-8 -*- # Copyright (c) 2023-2024 tecnovert # Distributed under the MIT software license, see the accompanying # file LICENSE or http://www.opensource.org/licenses/mit-license.php. """ Start test_xmr_persistent.py python tests/basicswap/extended/test_scripts.py pytest -v -s tests/basicswap/extended/test_scripts.py::Test::test_bid_tracking """ import os import sys import json import time import math import logging import sqlite3 import unittest import threading import subprocess import http.client from http.server import BaseHTTPRequestHandler, HTTPServer from urllib import parse from tests.basicswap.util import ( read_json_api, waitForServer, ) logger = logging.getLogger() logger.level = logging.DEBUG if not len(logger.handlers): logger.addHandler(logging.StreamHandler(sys.stdout)) PORT_OFS = int(os.getenv('PORT_OFS', 1)) UI_PORT = 12700 + PORT_OFS class HttpHandler(BaseHTTPRequestHandler): def js_response(self, url_split, post_string, is_json): return bytes(json.dumps(self.server.return_data[url_split[3]]), 'UTF-8') def putHeaders(self, status_code, content_type): self.send_response(status_code) self.send_header('Content-Type', content_type) self.end_headers() def handle_http(self, status_code, path, post_string='', is_json=False): parsed = parse.urlparse(self.path) url_split = parsed.path.split('/') if post_string == '' and len(parsed.query) > 0: post_string = parsed.query if len(url_split) > 1 and url_split[1] == 'json': self.putHeaders(status_code, 'text/plain') return self.js_response(url_split, post_string, is_json) self.putHeaders(status_code, 'text/plain') return bytes('No response', 'UTF-8') def do_GET(self): response = self.handle_http(200, self.path) self.wfile.write(response) def do_POST(self): post_string = self.rfile.read(int(self.headers.get('Content-Length'))) is_json = True if 'json' in self.headers.get('Content-Type', '') else False response = self.handle_http(200, self.path, post_string, is_json) self.wfile.write(response) def do_HEAD(self): self.putHeaders(200, 'text/html') class HttpThread(threading.Thread, HTTPServer): host = '127.0.0.1' port_no = 12699 stop_event = threading.Event() return_data = {'test': 1} def __init__(self): threading.Thread.__init__(self) HTTPServer.__init__(self, (self.host, self.port_no), HttpHandler) def stop(self): self.stop_event.set() # Send fake request conn = http.client.HTTPConnection(self.host, self.port_no) conn.connect() conn.request('GET', '/none') response = conn.getresponse() data = response.read() conn.close() def serve_forever(self): while not self.stop_event.is_set(): self.handle_request() self.socket.close() def run(self): self.serve_forever() def clear_offers(delay_event, node_id) -> None: logging.info(f'clear_offers node {node_id}') offers = read_json_api(UI_PORT + node_id, 'offers') for offer in offers: read_json_api(UI_PORT + node_id, 'revokeoffer/{}'.format(offer['offer_id'])) for i in range(20): delay_event.wait(1) offers = read_json_api(UI_PORT + node_id, 'offers') if len(offers) == 0: return raise ValueError('clear_offers failed') def wait_for_offers(delay_event, node_id, num_offers) -> None: logging.info(f'Waiting for {num_offers} offers on node {node_id}') for i in range(20): delay_event.wait(1) offers = read_json_api(UI_PORT + node_id, 'offers') if len(offers) >= num_offers: return raise ValueError('wait_for_offers failed') def delete_file(filepath: str) -> None: if os.path.exists(filepath): os.remove(filepath) def get_created_offers(rv_stdout): offers = [] for line in rv_stdout: if line.startswith('New offer'): offers.append(line.split(':')[1].strip()) return offers def count_lines_with(rv_stdout, str_needle): lines_found = 0 for line in rv_stdout: if str_needle in line: lines_found += 1 return lines_found def get_created_bids(rv_stdout): bids = [] for line in rv_stdout: if line.startswith('New bid'): bids.append(line.split(':')[1].strip()) return bids def get_possible_bids(rv_stdout): bids = [] tag = 'Would create bid: ' for line in rv_stdout: if line.startswith(tag): bids.append(json.loads(line[len(tag):].replace("'", '"'))) return bids class Test(unittest.TestCase): delay_event = threading.Event() thread_http = HttpThread() @classmethod def setUpClass(cls): super(Test, cls).setUpClass() cls.thread_http.start() script_path = 'scripts/createoffers.py' datadir = '/tmp/bsx_scripts' if not os.path.isdir(datadir): os.makedirs(datadir) cls.node0_configfile = os.path.join(datadir, 'node0.json') cls.node0_statefile = os.path.join(datadir, 'node0_state.json') cls.node0_args = [script_path, '--port', str(UI_PORT), '--configfile', cls.node0_configfile, '--statefile', cls.node0_statefile, '--oneshot', '--debug'] cls.node1_configfile = os.path.join(datadir, 'node1.json') cls.node1_statefile = os.path.join(datadir, 'node1_state.json') cls.node1_args = [script_path, '--port', str(UI_PORT + 1), '--configfile', cls.node1_configfile, '--statefile', cls.node1_statefile, '--oneshot', '--debug'] @classmethod def tearDownClass(cls): logging.info('Stopping test') cls.thread_http.stop() def test_enabled(self): waitForServer(self.delay_event, UI_PORT + 0) waitForServer(self.delay_event, UI_PORT + 1) # Test no 'Processing...' messages are shown without config node0_test_config = {} with open(self.node0_configfile, 'w') as fp: json.dump(node0_test_config, fp, indent=4) result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Processing') == 0) # Test that enabled templates are processed node0_test_config = { 'test_mode': True, 'offers': [ { 'name': 'offer example 1', 'coin_from': 'Particl', 'coin_to': 'Monero', 'amount': 20, 'minrate': 0.05, 'ratetweakpercent': 5, 'amount_variable': True, 'address': -1, 'min_coin_from_amt': 20, 'max_coin_to_amt': -1 }, ], 'bids': [ { 'coin_from': 'PART', 'coin_to': 'XMR', 'amount': 10, 'maxrate': 0.04, 'amount_variable': True, 'address': -1, 'min_swap_amount': 0.1, 'max_coin_from_balance': -1, 'min_coin_to_balance': -1, }, ], 'stealthex': [ { 'coin_from': 'XMR', 'coin_to': 'BTC', 'min_balance_from': 1, 'min_amount_tx': 1, 'max_amount_tx': 5, 'min_rate': 0.01, 'refund_address': 'auto', 'receive_address': 'auto', 'api_key': 'API_KEY_HERE' } ], } with open(self.node0_configfile, 'w') as fp: json.dump(node0_test_config, fp, indent=4) result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Processing 1 offer template') == 1) assert (count_lines_with(rv_stdout, 'Processing 1 bid template') == 1) assert (count_lines_with(rv_stdout, 'Processing 1 stealthex template') == 1) # Test that disabled templates are not processed node0_test_config['offers'][0]['enabled'] = False node0_test_config['bids'][0]['enabled'] = False node0_test_config['stealthex'][0]['enabled'] = False with open(self.node0_configfile, 'w') as fp: json.dump(node0_test_config, fp, indent=4) result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Processing 0 offer templates') == 1) assert (count_lines_with(rv_stdout, 'Processing 0 bid templates') == 1) assert (count_lines_with(rv_stdout, 'Processing 0 stealthex templates') == 1) def test_offers(self): waitForServer(self.delay_event, UI_PORT + 0) waitForServer(self.delay_event, UI_PORT + 1) # Reset test clear_offers(self.delay_event, 0) delete_file(self.node0_statefile) delete_file(self.node1_statefile) wait_for_offers(self.delay_event, 1, 0) node0_test1_config = { 'offers': [ { 'name': 'offer example 1', 'coin_from': 'Particl', 'coin_to': 'Monero', 'amount': 20, 'minrate': 0.05, 'ratetweakpercent': 5, 'amount_variable': True, 'address': -1, 'min_coin_from_amt': 20, 'max_coin_to_amt': -1, 'min_swap_amount': 0.11, }, { 'name': 'offer example 1_2', 'coin_from': 'Particl', 'coin_to': 'Monero', 'amount': 21, 'minrate': 0.07, 'ratetweakpercent': 5, 'amount_variable': True, 'address': -1, 'min_coin_from_amt': 21, 'max_coin_to_amt': -1, 'min_swap_amount': 0.12, } ], } with open(self.node0_configfile, 'w') as fp: json.dump(node0_test1_config, fp, indent=4) logging.info('Test that an offer is created') result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (len(get_created_offers(rv_stdout)) == 1) offers = read_json_api(UI_PORT, 'offers') assert (len(offers) == 1) offer_min_bid_amount = float(offers[0]['min_bid_amount']) assert (offer_min_bid_amount == 0.11 or offer_min_bid_amount == 0.12) logging.info('Test that an offer is not created while delaying') result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (len(get_created_offers(rv_stdout)) == 0) with open(self.node0_statefile) as fs: node0_state = json.load(fs) node0_state['delay_next_offer_before'] = 0 with open(self.node0_statefile, 'w') as fp: json.dump(node0_state, fp, indent=4) logging.info('Test that the second offer is created when not delaying') result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (len(get_created_offers(rv_stdout)) == 1) with open(self.node0_statefile) as fs: node0_state = json.load(fs) assert (len(node0_state['offers']['offer example 1']) == 1) assert (len(node0_state['offers']['offer example 1_2']) == 1) offers = read_json_api(UI_PORT, 'offers') assert (len(offers) == 2) addr_bid_from = read_json_api(UI_PORT + 1, 'smsgaddresses/new')['new_address'] node1_test1_config = { 'bids': [ { 'name': 'bid example 1', 'coin_from': 'PART', 'coin_to': 'XMR', 'amount': 10, 'maxrate': 0.06, 'amount_variable': True, 'address': addr_bid_from, 'min_swap_amount': 0.1, 'max_coin_from_balance': -1, 'min_coin_to_balance': -1, 'max_concurrent': 4, }, { 'coin_from': 'PART', 'coin_to': 'XMR', 'amount': 10, 'maxrate': 0.04, 'amount_variable': True, 'address': -1, 'min_swap_amount': 0.1, 'max_coin_from_balance': -1, 'min_coin_to_balance': -1, } ], } with open(self.node1_configfile, 'w') as fp: json.dump(node1_test1_config, fp, indent=4) wait_for_offers(self.delay_event, 1, 2) logging.info('Test that a bid is created') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (len(get_created_bids(rv_stdout)) == 1) logging.info('Test no bids are created while delaying') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Delaying bids until') == 1) with open(self.node1_statefile) as fs: node1_state = json.load(fs) node1_state['delay_next_bid_before'] = 0 with open(self.node1_statefile, 'w') as fp: json.dump(node1_state, fp, indent=4) logging.info('Test that a bid is not created if one already exists on that offer') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Bid rate too low for offer') == 3) assert (count_lines_with(rv_stdout, 'Already bidding on offer') == 1) logging.info('Modifying node1 config') node1_test1_config['bids'][0]['maxrate'] = 0.07 node1_test1_config['bids'][0]['max_coin_from_balance'] = 100 node1_test1_config['bids'][0]['min_coin_to_balance'] = 100 node1_test1_config['bids'][0]['min_swap_amount'] = 9 node1_test1_config['wallet_port_override'] = 12699 node1_test1_config['test_mode'] = True with open(self.node1_configfile, 'w') as fp: json.dump(node1_test1_config, fp, indent=4) self.thread_http.return_data = { 'PART': { 'balance': '0.0', 'unconfirmed': '0.0', 'expected_seed': True, 'encrypted': False, 'locked': False, 'anon_balance': 0.0, 'anon_pending': 0.0, 'blind_balance': 0.0, 'blind_unconfirmed': 0.0, 'version': 23000300, 'name': 'Particl', 'blocks': 3556, 'synced': '100.00' }, 'XMR': { 'balance': '362299.12', 'unconfirmed': '0.0', 'expected_seed': True, 'encrypted': False, 'locked': False, 'main_address': '', 'version': 65562, 'name': 'Monero', 'blocks': 10470, 'synced': '100.00', 'known_block_count': 10470 } } logging.info('Check max bid value') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') possible_bids = get_possible_bids(rv_stdout) assert (len(possible_bids) == 1) assert (float(possible_bids[0]['amount_from']) == 10.0) logging.info('Raise node1 bid0 value') node1_test1_config['bids'][0]['amount'] = 50 with open(self.node1_configfile, 'w') as fp: json.dump(node1_test1_config, fp, indent=4) delete_file(self.node1_statefile) # Check max_coin_from_balance (bids increase coin_from) result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') possible_bids = get_possible_bids(rv_stdout) assert (len(possible_bids) == 1) assert (float(possible_bids[0]['amount_from']) == 21.0) # Test multiple bids are delayed result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Delaying bids until') == 1) delete_file(self.node1_statefile) self.thread_http.return_data['PART']['balance'] = 100.0 result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Bid amount would exceed maximum wallet total') == 1) self.thread_http.return_data['PART']['balance'] = 90.0 result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') possible_bids = get_possible_bids(rv_stdout) assert (len(possible_bids) == 1) assert (math.isclose(float(possible_bids[0]['amount_from']), 10.0)) # Check min_swap_amount delete_file(self.node1_statefile) self.thread_http.return_data['PART']['balance'] = 95.0 result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') possible_bids = get_possible_bids(rv_stdout) assert (count_lines_with(rv_stdout, 'Bid amount would exceed maximum wallet total') == 1) # Check min_coin_to_balance (bids decrease coin_to) self.thread_http.return_data['PART']['balance'] = 0.0 self.thread_http.return_data['XMR']['balance'] = 101.0 result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') possible_bids = get_possible_bids(rv_stdout) possible_bids = get_possible_bids(rv_stdout) assert (len(possible_bids) == 1) assert (float(possible_bids[0]['amount_from']) < 20.0) logging.info('Adding mock data to node1 db for tests') rows = [] offers = read_json_api(UI_PORT, 'offers') now = int(time.time()) for offer in offers: rows.append((1, offer['addr_from'], 5, 5, now, now)) db_path = '/tmp/test_persistent/client1/db_regtest.sqlite' with sqlite3.connect(db_path) as dbc: c = dbc.cursor() c.executemany('INSERT INTO knownidentities (active_ind, address, num_sent_bids_failed, num_recv_bids_failed, updated_at, created_at) VALUES (?,?,?,?,?,?)', rows) dbc.commit() delete_file(self.node1_statefile) self.thread_http.return_data['XMR']['balance'] = 10000.0 result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (len(get_possible_bids(get_possible_bids(rv_stdout))) == 0) assert (count_lines_with(rv_stdout, 'too many failed bids') == 1) ''' TODO node0_test1_config['stealthex'] = [ { 'coin_from': 'XMR', 'coin_to': 'BTC', 'min_balance_from': 1, 'min_amount_tx': 1, 'max_amount_tx': 5, 'min_rate': 0.01, 'refund_address': 'auto', 'receive_address': 'auto', 'api_key': 'API_KEY_HERE' } ] node0_test1_config['wallet_port_override'] = 12699 node0_test1_config['test_mode'] = True with open(self.node0_configfile, 'w') as fp: json.dump(node0_test1_config, fp, indent=4) result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') ''' def test_error_messages(self): waitForServer(self.delay_event, UI_PORT + 0) waitForServer(self.delay_event, UI_PORT + 1) # Reset test clear_offers(self.delay_event, 0) delete_file(self.node0_statefile) delete_file(self.node1_statefile) wait_for_offers(self.delay_event, 1, 0) node0_test1_config = { 'offers': [ { 'name': 'offer should fail', 'coin_from': 'Particl', 'coin_to': 'XMR', 'amount': 20, 'minrate': 0.05, 'ratetweakpercent': 50000000, 'amount_variable': True, 'address': -1, 'min_coin_from_amt': 20, 'max_coin_to_amt': -1 } ], } with open(self.node0_configfile, 'w') as fp: json.dump(node0_test1_config, fp, indent=4) logging.info('Test that an offer is created') result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Error: Server failed to create offer: To amount above max') == 1) def test_bid_tracking(self): waitForServer(self.delay_event, UI_PORT + 0) waitForServer(self.delay_event, UI_PORT + 1) # Reset test clear_offers(self.delay_event, 0) delete_file(self.node0_statefile) delete_file(self.node1_statefile) wait_for_offers(self.delay_event, 1, 0) addrs = [] for i in range(2): addrs.append(read_json_api(UI_PORT, 'smsgaddresses/new')['new_address']) node0_test2_config = { 'offers': [ { 'name': 'offer example 1', 'coin_from': 'Particl', 'coin_to': 'Monero', 'amount': 20, 'minrate': 0.04, 'ratetweakpercent': 5, 'amount_variable': True, 'address': addrs[0], 'min_coin_from_amt': 20, 'max_coin_to_amt': -1 }, { 'name': 'offer example 1_2', 'coin_from': 'Particl', 'coin_to': 'Monero', 'amount': 21, 'minrate': 0.05, 'ratetweakpercent': 5, 'amount_variable': True, 'address': addrs[1], 'min_coin_from_amt': 21, 'max_coin_to_amt': -1 }, { 'name': 'offer example 1_3', 'coin_from': 'Particl', 'coin_to': 'Monero', 'amount': 22, 'minrate': 0.06, 'ratetweakpercent': 5, 'amount_variable': True, 'address': 'auto', 'min_coin_from_amt': 22, 'max_coin_to_amt': -1 } ], } with open(self.node0_configfile, 'w') as fp: json.dump(node0_test2_config, fp, indent=4) offer_ids = [] logging.info('Create three offers') for i in range(3): if i > 0: with open(self.node0_statefile) as fs: node0_state = json.load(fs) node0_state['delay_next_offer_before'] = 0 with open(self.node0_statefile, 'w') as fp: json.dump(node0_state, fp, indent=4) result = subprocess.run(self.node0_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') created_offers = get_created_offers(rv_stdout) assert (len(get_created_offers(rv_stdout)) == 1) offer_ids.append(created_offers[0]) found_addrs = {} for offer_id in offer_ids: offer = read_json_api(UI_PORT, f'offers/{offer_id}')[0] found_addrs[offer['addr_from']] = found_addrs.get(offer['addr_from'], 0) + 1 for addr in addrs: assert (found_addrs[addr] == 1) addr_bid_from = read_json_api(UI_PORT + 1, 'smsgaddresses/new')['new_address'] node1_test1_config = { 'bids': [ { 'name': 'bid example 1', 'coin_from': 'PART', 'coin_to': 'XMR', 'amount': 50, 'maxrate': 0.08, 'amount_variable': False, 'address': addr_bid_from, 'min_swap_amount': 1, 'max_coin_from_balance': -1, 'min_coin_to_balance': -1 } ], } with open(self.node1_configfile, 'w') as fp: json.dump(node1_test1_config, fp, indent=4) wait_for_offers(self.delay_event, 1, 3) logging.info('Check that no bids are created (offer values too low)') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (len(get_created_bids(rv_stdout)) == 0) assert (count_lines_with(rv_stdout, 'Bid amount too high for offer') == 3) node1_test1_config['bids'][0]['amount_variable'] = True with open(self.node1_configfile, 'w') as fp: json.dump(node1_test1_config, fp, indent=4) logging.info('Check that one bid is created at the best rate') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') created_bids = get_created_bids(rv_stdout) assert (len(created_bids) == 1) bid_id = created_bids[0].split(' ')[0] bid = read_json_api(UI_PORT + 1, f'bids/{bid_id}') assert (math.isclose(float(bid['bid_rate']), 0.04)) assert (math.isclose(float(bid['amt_from']), 20.0)) assert (bid['addr_from'] == addr_bid_from) logging.info('Check that bids are delayed') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (count_lines_with(rv_stdout, 'Delaying bids until') == 1) assert (len(get_created_bids(rv_stdout)) == 0) with open(self.node1_statefile) as fs: node1_state = json.load(fs) node1_state['delay_next_bid_before'] = 0 with open(self.node1_statefile, 'w') as fp: json.dump(node1_state, fp, indent=4) logging.info('Test that a bid is not created while one is active') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') assert (len(get_created_bids(rv_stdout)) == 0) assert (count_lines_with(rv_stdout, 'Max concurrent bids') == 1) logging.info('Waiting for bid to complete') bid_complete: bool = False for i in range(60): self.delay_event.wait(5) bid = read_json_api(UI_PORT + 1, f'bids/{bid_id}') print('bid_state', bid['bid_state']) if bid['bid_state'] == 'Completed': bid_complete = True break assert bid_complete logging.info('Test that a bid is created after one expires') result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') created_bids = get_created_bids(rv_stdout) assert (len(created_bids) == 1) assert (count_lines_with(rv_stdout, 'Marking bid inactive') == 1) logging.info('Test that two bids are created if max concurrent is raised') node1_test1_config['bids'][0]['max_concurrent'] = 2 with open(self.node1_configfile, 'w') as fp: json.dump(node1_test1_config, fp, indent=4) with open(self.node1_statefile) as fs: node1_state = json.load(fs) node1_state['delay_next_bid_before'] = 0 with open(self.node1_statefile, 'w') as fp: json.dump(node1_state, fp, indent=4) result = subprocess.run(self.node1_args, stdout=subprocess.PIPE) rv_stdout = result.stdout.decode().split('\n') created_bids = get_created_bids(rv_stdout) assert (len(created_bids) == 1) bid_id = created_bids[0].split(' ')[0] bid = read_json_api(UI_PORT + 1, f'bids/{bid_id}') assert (math.isclose(float(bid['bid_rate']), 0.05)) assert (math.isclose(float(bid['amt_from']), 21.0)) assert (bid['addr_from'] == addr_bid_from) if __name__ == '__main__': unittest.main()