diff --git a/explorer/puller/puller/db/NemDatabase.py b/explorer/puller/puller/db/NemDatabase.py index 7a27b2cf9..fcca6c56a 100644 --- a/explorer/puller/puller/db/NemDatabase.py +++ b/explorer/puller/puller/db/NemDatabase.py @@ -230,7 +230,6 @@ def create_tables(self): timestamp timestamp NOT NULL, deadline timestamp NOT NULL, signature bytea, - amount bigint, is_inner boolean DEFAULT false, payload jsonb ) @@ -493,11 +492,10 @@ def insert_transaction(cursor, transaction): timestamp, deadline, signature, - amount, is_inner, payload ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id ''', ( @@ -511,7 +509,6 @@ def insert_transaction(cursor, transaction): transaction.timestamp, transaction.deadline, unhexlify(transaction.signature) if transaction.signature else None, - transaction.amount, transaction.is_inner, json.dumps(transaction.payload) if transaction.payload else None ) diff --git a/explorer/puller/puller/facade/NemPuller.py b/explorer/puller/puller/facade/NemPuller.py index 27061ea73..f88b5f433 100644 --- a/explorer/puller/puller/facade/NemPuller.py +++ b/explorer/puller/puller/facade/NemPuller.py @@ -10,6 +10,7 @@ from symbolchain.nem.Network import Address, Network from symbollightapi.connector.NemConnector import NemConnector from symbollightapi.model.Exceptions import NodeException +from symbollightapi.model.Transaction import Mosaic from zenlog import log from puller.db.NemDatabase import NemDatabase @@ -71,7 +72,6 @@ 'timestamp', 'deadline', 'signature', - 'amount', 'transaction_type', 'is_inner', 'sender_address', @@ -368,7 +368,6 @@ def _build_transaction_record(self, transaction, is_inner): payload = None recipient_address = None - amount = None if transaction.transaction_type == TransactionType.TRANSFER.value: payload = { 'message': { @@ -377,7 +376,6 @@ def _build_transaction_record(self, transaction, is_inner): } if transaction.message else None } recipient_address = transaction.recipient - amount = transaction.amount elif transaction.transaction_type == TransactionType.ACCOUNT_KEY_LINK.value: payload = { 'mode': transaction.mode, @@ -451,7 +449,6 @@ def _build_transaction_record(self, transaction, is_inner): fee=transaction.fee, timestamp=self._convert_timestamp_to_datetime(transaction.timestamp), deadline=self._convert_timestamp_to_datetime(transaction.deadline), - amount=amount, signature=transaction.signature, transaction_type=transaction.transaction_type, is_inner=is_inner, @@ -467,9 +464,22 @@ def _process_transaction(self, cursor, transaction, block_height, is_inner): transaction_record = self._build_transaction_record(transaction, is_inner) transaction_id = self.nem_db.insert_transaction(cursor, transaction_record) - if transaction.transaction_type == TransactionType.TRANSFER.value and transaction.mosaics: - for mosaic in transaction.mosaics: - self.nem_db.insert_transaction_mosaic(cursor, transaction_id, mosaic) + if transaction.transaction_type == TransactionType.TRANSFER.value: + if transaction.mosaics: + for mosaic in transaction.mosaics: + mosaic_amount = mosaic.quantity * transaction.amount // (10 ** 6) + self.nem_db.insert_transaction_mosaic( + cursor, + transaction_id, + Mosaic(mosaic.namespace_name, mosaic_amount) + ) + else: + # handle v1 transfer transaction + self.nem_db.insert_transaction_mosaic( + cursor, + transaction_id, + Mosaic('nem.xem', transaction.amount) + ) elif transaction.transaction_type == TransactionType.NAMESPACE_REGISTRATION.value: self._process_namespace(cursor, transaction, block_height) elif transaction.transaction_type == TransactionType.MOSAIC_DEFINITION.value: diff --git a/explorer/puller/tests/db/test_NemDatabase.py b/explorer/puller/tests/db/test_NemDatabase.py index 661a1763f..0f9fa619e 100644 --- a/explorer/puller/tests/db/test_NemDatabase.py +++ b/explorer/puller/tests/db/test_NemDatabase.py @@ -84,7 +84,6 @@ fee=150000, timestamp='2015-03-29 00:06:25+00:00', deadline='2015-03-29 20:34:19+00:00', - amount=2000000, signature=( '1b81379847241e45da86b27911e5c9a9192ec04f644d98019657d32838b49c14' '3eaa4815a3028b80f9affdbf0b94cd620f7a925e02783dda67b8627b69ddf70e' @@ -688,7 +687,6 @@ def test_can_insert_transactions(self): timestamp, deadline, encode(signature, 'hex'), - amount, is_inner, payload FROM transactions @@ -711,7 +709,6 @@ def test_can_insert_transactions(self): datetime.datetime(2015, 3, 29, 0, 6, 25), datetime.datetime(2015, 3, 29, 20, 34, 19), TRANSACTIONS[0].signature, - 2000000, False, '{}' )) diff --git a/explorer/puller/tests/facade/test_NemPuller.py b/explorer/puller/tests/facade/test_NemPuller.py index 0bca72f9d..1082d4edc 100644 --- a/explorer/puller/tests/facade/test_NemPuller.py +++ b/explorer/puller/tests/facade/test_NemPuller.py @@ -918,7 +918,7 @@ def test_can_process_mosaic_supply_change_increase(self, mock_update_mosaic_tota expected_supply_change=500000 ) - def _assert_transaction_record(self, transaction, payload, amount=None, recipient_address=None): + def _assert_transaction_record(self, transaction, payload, recipient_address=None): # Act: record = self.puller._build_transaction_record(transaction, False) # pylint: disable=protected-access @@ -930,7 +930,6 @@ def _assert_transaction_record(self, transaction, payload, amount=None, recipien fee=transaction.fee, timestamp='2015-03-29 20:29:42+00:00', deadline='2015-03-29 23:16:22+00:00', - amount=amount, signature=transaction.signature, transaction_type=transaction.transaction_type, is_inner=False, @@ -950,7 +949,6 @@ def test_can_build_transaction_record_transfer(self): 'is_plain': 1 } }, - amount=180000040000000, recipient_address=transaction.recipient ) @@ -963,7 +961,6 @@ def test_can_build_transaction_record_transfer_without_message(self): self._assert_transaction_record( transaction, {'message': None}, - amount=180000040000000, recipient_address=transaction.recipient ) @@ -1123,11 +1120,11 @@ def test_can_process_transaction_transfer(self, mock_insert_transaction_mosaic, transfer.timestamp, transfer.deadline, transfer.signature, - transfer.amount, + 1999999, transfer.recipient, transfer.message, [ - Mosaic('namespace.test', 1000000), + Mosaic('namespace.test', 20), Mosaic('nem.xem', 8000000) ] ) @@ -1141,7 +1138,11 @@ def test_can_process_transaction_transfer(self, mock_insert_transaction_mosaic, mock_build_transaction_record.assert_called_once() mock_insert_transaction.assert_called_once() insert_transaction_mosaic_calls = mock_insert_transaction_mosaic.call_args_list - for index, mosaic in enumerate(transaction.mosaics): + expected_mosaics = [ + Mosaic('namespace.test', 39), + Mosaic('nem.xem', 15999992) + ] + for index, mosaic in enumerate(expected_mosaics): self.assertEqual(insert_transaction_mosaic_calls[index][0], ( cursor, 1, # transaction_id from insert_transaction mock @@ -1168,7 +1169,11 @@ def test_can_process_transaction_transfer_without_mosaic( # Assert: mock_build_transaction_record.assert_called_once() mock_insert_transaction.assert_called_once() - mock_insert_transaction_mosaic.assert_not_called() + mock_insert_transaction_mosaic.assert_called_once_with( + cursor, + mock_insert_transaction.return_value, + Mosaic('nem.xem', transaction.amount) + ) @patch('puller.facade.NemPuller.NemPuller._build_transaction_record') @patch('puller.facade.NemPuller.NemDatabase.insert_transaction') diff --git a/explorer/rest/rest/__init__.py b/explorer/rest/rest/__init__.py index ff3ca1189..b1cbae729 100644 --- a/explorer/rest/rest/__init__.py +++ b/explorer/rest/rest/__init__.py @@ -5,10 +5,12 @@ from flask import Flask, abort, jsonify, request from flask_cors import CORS from symbolchain.CryptoTypes import PublicKey +from symbolchain.nc import TransactionType from zenlog import log from rest.facade.NemRestFacade import NemRestFacade from rest.model.common import DatabaseConfig, Pagination, RestConfig, Sorting +from rest.model.Transaction import TransactionQuery def create_app(): @@ -277,6 +279,80 @@ def api_get_nem_transaction_statistics_by_date_range(): # pylint: disable=inval return jsonify(nem_api_facade.get_transaction_statistics_by_date_range(start_date, end_date, period_type)) + @app.route('/api/nem/transactions') + def api_get_nem_transactions(): # pylint: disable=too-many-branches,too-many-statements + try: + limit = int(request.args.get('limit', 10)) + offset = int(request.args.get('offset', 0)) + sort = request.args.get('sort', 'DESC') + height = request.args.get('height', None) + transaction_types = request.args.get('transactionTypes', None) + address = request.args.get('address', None) + sender_address = request.args.get('senderAddress', None) + recipient_address = request.args.get('recipientAddress', None) + sender = request.args.get('senderPublicKey', None) + mosaic = request.args.get('mosaic', None) + + if limit < 0 or offset < 0: + raise ValueError('Limit and offset must be greater than or equal to 0') + + if sort.upper() not in ['ASC', 'DESC']: + raise ValueError('Sort must be either ASC or DESC') + + if height is not None: + height = int(height) + if height < 1: + raise ValueError('Height must be greater than or equal to 1') + + if address is not None: + if not nem_api_facade.nem_db.network.is_valid_address_string(address): + raise ValueError('Invalid address format') + else: + if sender_address is not None and sender is not None: + raise ValueError('Only one of senderAddress or senderPublicKey can be provided') + + if sender_address is not None: + if not nem_api_facade.nem_db.network.is_valid_address_string(sender_address): + raise ValueError('Invalid sender address format') + else: + if sender is not None: + try: + PublicKey(sender) + except ValueError: + abort(400, 'Invalid sender public key format') + + if recipient_address is not None: + if not nem_api_facade.nem_db.network.is_valid_address_string(recipient_address): + raise ValueError('Invalid recipient address format') + + if transaction_types: + transaction_types = [tx_type.upper() for tx_type in transaction_types.split(',')] + + for tx_type in transaction_types: + if tx_type not in TransactionType.__members__: + raise ValueError('Invalid transaction types') + + transaction_types = [TransactionType[t].value for t in transaction_types] + + except ValueError as error: + abort(400, error) + + results = nem_api_facade.get_transactions( + pagination=Pagination(limit, offset), + sort=sort, + transaction_query=TransactionQuery( + height=height, + transaction_types=transaction_types, + sender=sender, + address=address, + sender_address=sender_address, + recipient_address=recipient_address, + mosaic=mosaic + ) + ) + + return jsonify(results) + def setup_error_handlers(app): @app.errorhandler(404) diff --git a/explorer/rest/rest/db/NemDatabase.py b/explorer/rest/rest/db/NemDatabase.py index 0bcbb2005..070b953cc 100644 --- a/explorer/rest/rest/db/NemDatabase.py +++ b/explorer/rest/rest/db/NemDatabase.py @@ -212,7 +212,7 @@ def _create_mosaic_rich_list_view(result): ) def _create_transaction_view(self, transaction, inner_transaction=None): - value = self._build_transaction_payload(transaction.transaction_type, transaction.payload, transaction.amount, transaction.mosaics) + value = self._build_transaction_payload(transaction.transaction_type, transaction.payload, transaction.mosaics) embedded_transaction = [] from_address = _format_address_bytes_to_string(transaction.from_address) to_address = _format_address_bytes_to_string(transaction.to_address) if transaction.to_address else None @@ -232,7 +232,6 @@ def _create_transaction_view(self, transaction, inner_transaction=None): 'value': self._build_transaction_payload( inner_transaction.transaction_type, inner_transaction.payload, - inner_transaction.amount, inner_transaction.mosaics ), }) @@ -245,7 +244,7 @@ def _create_transaction_view(self, transaction, inner_transaction=None): from_address=from_address, to_address=to_address, value=value, - embedded_transactions=embedded_transaction if embedded_transaction else None, + embedded_transactions=embedded_transaction or None, fee=_format_xem_relative(transaction.fee), height=transaction.height, timestamp=str(transaction.timestamp), @@ -392,10 +391,10 @@ def _generate_mosaic_query(where_condition='', order_condition='', limit_conditi ''' @staticmethod - def _generate_transaction_sql_query(): + def _generate_transaction_sql_query(where_condition='', order_condition='', limit_condition=''): """Base transaction query.""" - return ''' + return f''' SELECT t.transaction_hash, t.transaction_type, @@ -407,7 +406,6 @@ def _generate_transaction_sql_query(): t.signature, t.recipient_address as to_address, t.payload, - t.amount, COALESCE(m.mosaics, '[]'::json) AS mosaics FROM transactions t LEFT JOIN LATERAL ( @@ -420,6 +418,9 @@ def _generate_transaction_sql_query(): ON mo.namespace_name = tm.namespace_name WHERE tm.transaction_id = t.id ) m ON true + {where_condition} + {order_condition} + {limit_condition} ''' def _get_account(self, where_condition, query_bytes): @@ -623,7 +624,7 @@ def get_mosaic_rich_list(self, pagination, namespace_name): return [self._create_mosaic_rich_list_view(result) for result in results] - def _build_transaction_payload(self, transaction_type, payload, amount, mosaics): + def _build_transaction_payload(self, transaction_type, payload, mosaics): """Builds transaction payload based on transaction type.""" value = [] @@ -637,19 +638,10 @@ def _build_transaction_payload(self, transaction_type, payload, amount, mosaics) }, }) - if mosaics: - multiplier = 0 if amount == 0 else _format_xem_relative(amount) - for mosaic in mosaics: - mosaic_amount = mosaic['quantity'] * multiplier - - value.append({ - 'namespace': mosaic['namespace_name'], - 'amount': _format_relative(mosaic_amount, mosaic['divisibility']) - }) - else: + for mosaic in mosaics: value.append({ - 'namespace': 'nem.xem', - 'amount': _format_xem_relative(amount) + 'namespace': mosaic['namespace_name'], + 'amount': _format_relative(mosaic['quantity'], mosaic['divisibility']) }) elif transaction_type == TransactionType.ACCOUNT_KEY_LINK.value: value.append({ @@ -687,9 +679,10 @@ def _build_transaction_payload(self, transaction_type, payload, amount, mosaics) def _get_transaction_query(self, transaction_hash, is_inner=False): """Gets transaction by where clause.""" - sql = self._generate_transaction_sql_query() - sql += ' WHERE t.transaction_hash = %s AND t.is_inner = %s' - params = ('\\x' + transaction_hash, is_inner) + where_condition = ' WHERE t.transaction_hash = %s AND t.is_inner = %s' + sql = self._generate_transaction_sql_query(where_condition=where_condition) + + params = ['\\x' + transaction_hash, is_inner] with self.connection() as connection: cursor = connection.cursor() @@ -764,3 +757,103 @@ def get_transaction_statistics_by_date_range(self, start_date, end_date, period_ results = cursor.fetchall() return self._create_transaction_date_range_statistic_view(results, period_type) + + def _get_transactions(self, params, where_condition='', order_condition='', limit_condition=''): + """Gets transactions by where clause.""" + + sql = self._generate_transaction_sql_query( + where_condition=where_condition, + order_condition=order_condition, + limit_condition=limit_condition + ) + + with self.connection() as connection: + cursor = connection.cursor() + cursor.execute(sql, params) + results = cursor.fetchall() + + return [TransactionRecord(*result) for result in results] + + def _get_inner_transactions(self, transaction_hashes): + """Gets inner transactions by transaction hashes.""" + + if not transaction_hashes: + return [] + + where_condition = ' WHERE t.transaction_hash IN %s AND t.is_inner = true' + order_condition = ' ORDER BY t.height DESC' + params = [tuple('\\x' + tx_hash for tx_hash in transaction_hashes)] + + return self._get_transactions( + params, + where_condition=where_condition, + order_condition=order_condition + ) + + def get_transactions(self, pagination, sort, transaction_query): + """Gets transactions pagination.""" + + where_condition = ' WHERE t.is_inner = False ' + order_condition = f' ORDER BY t.height {sort}' + limit_condition = ' LIMIT %s OFFSET %s' + + filter_params = [] + + if transaction_query.height: + where_condition += ' AND t.height = %s' + filter_params.append(transaction_query.height) + + if transaction_query.transaction_types: + where_condition += ' AND t.transaction_type IN %s' + filter_params.append(tuple(transaction_query.transaction_types)) + + if transaction_query.address: + where_condition += ' AND (t.sender_address = %s OR t.recipient_address = %s)' + filter_params.extend([transaction_query.address.bytes, transaction_query.address.bytes]) + else: + if transaction_query.sender_address: + where_condition += ' AND t.sender_address = %s' + filter_params.append(transaction_query.sender_address.bytes) + + if transaction_query.recipient_address: + where_condition += ' AND t.recipient_address = %s' + filter_params.append(transaction_query.recipient_address.bytes) + + if transaction_query.mosaic: + where_condition += ( + ' AND t.transaction_type = %s' + ' AND EXISTS (SELECT 1 FROM transactions_mosaic tm WHERE tm.transaction_id = t.id AND tm.namespace_name = %s) ' + ) + + filter_params.extend([TransactionType.TRANSFER.value, transaction_query.mosaic]) + + params = filter_params + [pagination.limit, pagination.offset] + + transactions = self._get_transactions( + params, + where_condition=where_condition, + order_condition=order_condition, + limit_condition=limit_condition + ) + + inner_transaction_hashes = [ + transaction.payload['inner_hash'] + for transaction in transactions + if transaction.transaction_type == TransactionType.MULTISIG.value + ] + + if inner_transaction_hashes: + inner_transactions = self._get_inner_transactions(inner_transaction_hashes) + inner_transaction_map = { + _format_bytes(inner_transaction.transaction_hash).lower(): inner_transaction for inner_transaction in inner_transactions + } + + return [ + self._create_transaction_view( + transaction, + inner_transaction_map.get(transaction.payload.get('inner_hash')) + ) + for transaction in transactions + ] + + return [self._create_transaction_view(transaction) for transaction in transactions] diff --git a/explorer/rest/rest/facade/NemRestFacade.py b/explorer/rest/rest/facade/NemRestFacade.py index f0d5a22c1..0b2a71873 100644 --- a/explorer/rest/rest/facade/NemRestFacade.py +++ b/explorer/rest/rest/facade/NemRestFacade.py @@ -153,6 +153,25 @@ def get_transaction_by_hash(self, transaction_hash): return transaction.to_dict() if transaction else None + def get_transactions(self, pagination, sort, transaction_query): + """Gets transactions pagination.""" + + sender_address = ( + self.network.public_key_to_address(PublicKey(transaction_query.sender)) + if transaction_query.sender + else Address(transaction_query.sender_address) if transaction_query.sender_address else None + ) + + transaction_query = transaction_query._replace( + address=Address(transaction_query.address) if transaction_query.address else None, + sender_address=sender_address, + recipient_address=Address(transaction_query.recipient_address) if transaction_query.recipient_address else None, + ) + + transactions = self.nem_db.get_transactions(pagination, sort, transaction_query) + + return [transaction.to_dict() for transaction in transactions] + def get_transaction_statistics(self): """Gets transaction statistics.""" diff --git a/explorer/rest/rest/model/Transaction.py b/explorer/rest/rest/model/Transaction.py index b673340ba..30325b28b 100644 --- a/explorer/rest/rest/model/Transaction.py +++ b/explorer/rest/rest/model/Transaction.py @@ -1,5 +1,16 @@ from collections import namedtuple +TransactionQuery = namedtuple('TransactionQuery', [ + 'height', + 'transaction_types', + 'sender', + 'address', + 'sender_address', + 'recipient_address', + 'mosaic' +]) + + TransactionRecord = namedtuple('TransactionRecord', [ 'transaction_hash', 'transaction_type', @@ -11,7 +22,6 @@ 'signature', 'to_address', 'payload', - 'amount', 'mosaics' ]) diff --git a/explorer/rest/tests/db/test_NemDatabase.py b/explorer/rest/tests/db/test_NemDatabase.py index 326a86b96..8f45e5724 100644 --- a/explorer/rest/tests/db/test_NemDatabase.py +++ b/explorer/rest/tests/db/test_NemDatabase.py @@ -12,6 +12,7 @@ TRANSACTION_DAILY_STATISTIC_VIEW, TRANSACTION_MONTH_STATISTIC_VIEW, TRANSACTION_STATISTIC_VIEW, + TRANSACTIONS, TRANSACTIONS_VIEWS, DatabaseTestBase ) @@ -239,8 +240,6 @@ def test_can_query_mosaics_sorted_by_registered_height_desc(self): def _assert_can_query_mosaic_rich_list_with_filter(self, pagination, namespace_name, expected_mosaic_rich_list): # Act: mosaic_rich_list_view = self.nem_db.get_mosaic_rich_list(pagination, namespace_name) - print(mosaic_rich_list_view) - print(expected_mosaic_rich_list) # Assert: self.assertEqual(expected_mosaic_rich_list, mosaic_rich_list_view) @@ -349,5 +348,94 @@ def test_can_query_transaction_statistics_grouped_month(self): # Assert: self.assertEqual(TRANSACTION_MONTH_STATISTIC_VIEW, transaction_statistics) + # region transactions + + def _assert_can_query_transactions_with_filter(self, pagination, sort, transaction_query, expected_transactions): + # Act: + transactions_view = self.nem_db.get_transactions(pagination, sort, transaction_query) + + # Assert: + self.assertEqual(expected_transactions, transactions_view) + + def test_can_query_transactions_filtered_limit_offset_0(self): + self._assert_can_query_transactions_with_filter( + Pagination(1, 0), 'desc', self._make_transaction_query(), [TRANSACTIONS_VIEWS[2]] + ) + + def test_can_query_transactions_filtered_limit_offset_1(self): + self._assert_can_query_transactions_with_filter( + Pagination(2, 1), 'desc', self._make_transaction_query(), [TRANSACTIONS_VIEWS[2], TRANSACTIONS_VIEWS[4]] + ) + + def test_can_query_transactions_sorted_by_height_asc(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'asc', self._make_transaction_query(), list(TRANSACTIONS_VIEWS) + ) + + def test_can_query_transactions_sorted_by_height_desc(self): + self._assert_can_query_transactions_with_filter( + Pagination(3, 0), 'desc', self._make_transaction_query(), + [TRANSACTIONS_VIEWS[3], TRANSACTIONS_VIEWS[2], TRANSACTIONS_VIEWS[4]] + ) + + def test_can_query_transactions_filtered_by_height(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(height=1), + [TRANSACTIONS_VIEWS[0], TRANSACTIONS_VIEWS[1]] + ) + + def test_can_query_transactions_filtered_by_nonexistent_height(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(height=999), [] + ) + + def test_can_query_transactions_filtered_by_multiple_transaction_types(self): + # TRANSFER (257) + ACCOUNT_KEY_LINK (2049) + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', + self._make_transaction_query(transaction_types=[257, 2049]), + [TRANSACTIONS_VIEWS[2], TRANSACTIONS_VIEWS[0], TRANSACTIONS_VIEWS[1]] + ) + + def test_can_query_transactions_filtered_by_address_as_sender(self): + self._assert_can_query_transactions_with_filter( + Pagination(2, 0), 'desc', self._make_transaction_query(address=TRANSACTIONS[2].sender_address), + [TRANSACTIONS_VIEWS[2]] + ) + + def test_can_query_transactions_filtered_by_address_as_recipient(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(address=TRANSACTIONS[0].recipient_address), + [TRANSACTIONS_VIEWS[5], TRANSACTIONS_VIEWS[6], TRANSACTIONS_VIEWS[0], TRANSACTIONS_VIEWS[1]] + ) + + def test_can_query_transactions_filtered_by_sender_address(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(sender_address=TRANSACTIONS[2].sender_address), + [TRANSACTIONS_VIEWS[2]] + ) + + def test_can_query_transactions_filtered_by_recipient_address(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(recipient_address=TRANSACTIONS[0].recipient_address), + [TRANSACTIONS_VIEWS[5], TRANSACTIONS_VIEWS[6], TRANSACTIONS_VIEWS[0], TRANSACTIONS_VIEWS[1]] + ) + + def test_can_query_transactions_filtered_by_mosaic_nem_xem(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(mosaic='nem.xem'), + [TRANSACTIONS_VIEWS[0], TRANSACTIONS_VIEWS[1]] + ) + + def test_can_query_transactions_filtered_by_mosaic_other(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(mosaic='root.mosaic'), + [TRANSACTIONS_VIEWS[1]] + ) + + def test_can_query_transactions_filtered_by_nonexistent_mosaic(self): + self._assert_can_query_transactions_with_filter( + Pagination(10, 0), 'desc', self._make_transaction_query(mosaic='nonexistent.mosaic'), [] + ) # endregion diff --git a/explorer/rest/tests/facade/test_NemRestFacade.py b/explorer/rest/tests/facade/test_NemRestFacade.py index c0f913a52..75ecc5839 100644 --- a/explorer/rest/tests/facade/test_NemRestFacade.py +++ b/explorer/rest/tests/facade/test_NemRestFacade.py @@ -56,6 +56,16 @@ EXPECTED_TRANSACTION_1 = TRANSACTIONS_VIEWS[0].to_dict() +EXPECTED_TRANSACTION_2 = TRANSACTIONS_VIEWS[1].to_dict() + +EXPECTED_TRANSACTION_3 = TRANSACTIONS_VIEWS[2].to_dict() + +EXPECTED_TRANSACTION_4 = TRANSACTIONS_VIEWS[3].to_dict() + +EXPECTED_TRANSACTION_7 = TRANSACTIONS_VIEWS[5].to_dict() + +EXPECTED_TRANSACTION_8 = TRANSACTIONS_VIEWS[6].to_dict() + # endregion @@ -447,5 +457,105 @@ def test_can_retrieve_transaction_statistics_by_month(self): # Assert: self.assertEqual(EXPECTED_TRANSACTION_MONTH_STATISTICS, transaction_statistics) + # region transactions + + def _assert_can_retrieve_transactions(self, pagination, sort, transaction_query, expected_transactions): + # Act: + transactions = self.nem_rest_facade.get_transactions(pagination, sort, transaction_query) + + # Assert: + self.assertEqual(expected_transactions, transactions) + + def test_can_retrieve_transactions_filtered_by_limit(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(1, 0), + sort='DESC', + transaction_query=self._make_transaction_query(), + expected_transactions=[EXPECTED_TRANSACTION_3] + ) + + def test_can_retrieve_transactions_filtered_by_offset(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(1, 1), + sort='DESC', + transaction_query=self._make_transaction_query(), + expected_transactions=[EXPECTED_TRANSACTION_4] + ) + + def test_can_retrieve_transactions_filtered_by_height(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(2, 0), + sort='DESC', + transaction_query=self._make_transaction_query( + height=1 + ), + expected_transactions=[EXPECTED_TRANSACTION_1, EXPECTED_TRANSACTION_2] + ) + + def test_can_retrieve_transactions_sorted_by_height_desc(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(2, 0), + sort='DESC', + transaction_query=self._make_transaction_query(), + expected_transactions=[EXPECTED_TRANSACTION_3, EXPECTED_TRANSACTION_4] + ) + + def test_can_retrieve_transactions_sorted_by_height_asc(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(2, 0), + sort='ASC', + transaction_query=self._make_transaction_query(), + expected_transactions=[EXPECTED_TRANSACTION_2, EXPECTED_TRANSACTION_1] + ) + + def test_can_retrieve_transactions_filtered_by_sender_public_key(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(10, 0), + sort='DESC', + transaction_query=self._make_transaction_query( + sender='9ca54cd15edf88a9df9173375d4a0d706f7a9ddcf57d7547dff8110ddd2adeb9' + ), + expected_transactions=[EXPECTED_TRANSACTION_3] + ) + + def test_can_retrieve_transactions_filtered_by_recipient_address(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(10, 0), + sort='DESC', + transaction_query=self._make_transaction_query( + recipient_address='NBFWZ4IVRHEIBRCGHLYDS62FSFTBM3VDFA7E6LSQ' + ), + expected_transactions=[EXPECTED_TRANSACTION_7, EXPECTED_TRANSACTION_8, EXPECTED_TRANSACTION_1, EXPECTED_TRANSACTION_2] + ) + + def test_can_retrieve_transactions_filtered_by_sender_address(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(10, 0), + sort='DESC', + transaction_query=self._make_transaction_query( + sender_address='NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V' + ), + expected_transactions=[EXPECTED_TRANSACTION_3] + ) + + def test_can_retrieve_transactions_filtered_by_transaction_types(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(10, 0), + sort='DESC', + transaction_query=self._make_transaction_query( + transaction_types=[257, 2049] + ), + expected_transactions=[EXPECTED_TRANSACTION_3, EXPECTED_TRANSACTION_1, EXPECTED_TRANSACTION_2] + ) + + def test_can_retrieve_transactions_filtered_by_mosaic(self): + self._assert_can_retrieve_transactions( + pagination=Pagination(10, 0), + sort='DESC', + transaction_query=self._make_transaction_query( + mosaic='root.mosaic' + ), + expected_transactions=[EXPECTED_TRANSACTION_2] + ) # endregion diff --git a/explorer/rest/tests/test/DatabaseTestUtils.py b/explorer/rest/tests/test/DatabaseTestUtils.py index 7c4e143cb..19f2f74ad 100644 --- a/explorer/rest/tests/test/DatabaseTestUtils.py +++ b/explorer/rest/tests/test/DatabaseTestUtils.py @@ -19,7 +19,7 @@ StatisticTransactionDateRangeView, StatisticTransactionView ) -from rest.model.Transaction import TransactionView +from rest.model.Transaction import TransactionQuery, TransactionView Block = namedtuple( 'Block', @@ -83,7 +83,6 @@ 'timestamp', 'deadline', 'signature', - 'amount', 'transaction_type', 'is_inner', 'sender_address', @@ -246,16 +245,15 @@ TRANSACTIONS = [ Transaction( # Transfer transaction v1 transaction_hash='0' * 63 + '1', - height=2, + height=1, sender_public_key=PublicKey('f9bd190dd0c364261f5c8a74870cc7f7374e631352293c62ecc437657e5de2cd'), fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=5000000, signature='0' * 128, transaction_type=257, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=Address('NBFWZ4IVRHEIBRCGHLYDS62FSFTBM3VDFA7E6LSQ'), payload={ 'message': None @@ -263,16 +261,15 @@ ), Transaction( # Transfer transaction v2 transaction_hash='0' * 63 + '2', - height=2, + height=1, sender_public_key=PublicKey('f9bd190dd0c364261f5c8a74870cc7f7374e631352293c62ecc437657e5de2cd'), fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=2000000, signature='0' * 128, transaction_type=257, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=Address('NBFWZ4IVRHEIBRCGHLYDS62FSFTBM3VDFA7E6LSQ'), payload={ 'message': { @@ -284,15 +281,14 @@ Transaction( # Account Link transaction_hash='0' * 63 + '3', height=2, - sender_public_key=PublicKey('f9bd190dd0c364261f5c8a74870cc7f7374e631352293c62ecc437657e5de2cd'), + sender_public_key=PublicKey('9ca54cd15edf88a9df9173375d4a0d706f7a9ddcf57d7547dff8110ddd2adeb9'), fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=None, signature='0' * 128, transaction_type=2049, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V'), recipient_address=None, payload={ 'mode': 1, @@ -306,11 +302,10 @@ fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=None, signature='0' * 128, transaction_type=4097, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=None, payload={ 'min_cosignatories': 1, @@ -329,11 +324,10 @@ fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=None, signature='0' * 128, transaction_type=4100, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=None, payload={ 'inner_hash': '0' * 63 + '6', @@ -358,11 +352,10 @@ fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=5000000, signature='0' * 128, transaction_type=257, is_inner=True, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=Address('NBFWZ4IVRHEIBRCGHLYDS62FSFTBM3VDFA7E6LSQ'), payload={ 'message': None @@ -375,11 +368,10 @@ fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=None, signature='0' * 128, transaction_type=8193, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=Address('NBFWZ4IVRHEIBRCGHLYDS62FSFTBM3VDFA7E6LSQ'), payload={ 'rental_fee': 100000000, @@ -394,11 +386,10 @@ fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=None, signature='0' * 128, transaction_type=16385, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=Address('NBFWZ4IVRHEIBRCGHLYDS62FSFTBM3VDFA7E6LSQ'), payload={ 'creation_fee': 200000000, @@ -426,11 +417,10 @@ fee=150000, timestamp='2015-03-29 00:06:25', deadline='2015-03-29 20:34:19', - amount=None, signature='0' * 128, transaction_type=16386, is_inner=False, - sender_address=Address('NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO'), + sender_address=Address('NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3'), recipient_address=None, payload={ 'namespace_name': 'root.mosaic', @@ -441,15 +431,25 @@ ] TRANSACTIONS_MOSAIC = [ + TransactionMosaic( # use for transaction v1 + transaction_id=1, + namespace_name='nem.xem', + quantity=5000000 + ), TransactionMosaic( # use for transaction v2 transaction_id=2, namespace_name='nem.xem', - quantity=2000000 + quantity=4000000 ), TransactionMosaic( transaction_id=2, namespace_name='root.mosaic', - quantity=2000000 + quantity=4000000 + ), + TransactionMosaic( # use for inner transfer transaction + transaction_id=6, + namespace_name='nem.xem', + quantity=5000000 ) ] @@ -738,7 +738,7 @@ to_address=str(TRANSACTIONS[5].recipient_address), value=None, embedded_transactions=[{ - 'initiator': 'NAGHXD63C4V6REWGXCVKJ2SBS3GUAXGTRQZQXPRO', + 'initiator': 'NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3', 'transactionHash': '0' * 63 + '6', 'transactionType': 'TRANSFER', 'signatures': [{ @@ -919,7 +919,6 @@ def initialize_database(db_config, network_name): timestamp timestamp NOT NULL, deadline timestamp NOT NULL, signature bytea, - amount bigint, is_inner boolean DEFAULT false, payload jsonb ) @@ -932,7 +931,7 @@ def initialize_database(db_config, network_name): id serial PRIMARY KEY, transaction_id int NOT NULL, namespace_name varchar(146), - quantity bigint + quantity numeric ) ''' ) @@ -1079,11 +1078,10 @@ def initialize_database(db_config, network_name): timestamp, deadline, signature, - amount, is_inner, payload ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) ''', ( unhexlify(transaction.transaction_hash), @@ -1096,7 +1094,6 @@ def initialize_database(db_config, network_name): transaction.timestamp, transaction.deadline, unhexlify(transaction.signature) if transaction.signature else None, - transaction.amount, transaction.is_inner, json.dumps(transaction.payload) if transaction.payload else None ) @@ -1132,3 +1129,16 @@ def setUp(self): def tearDown(self): self.postgresql.stop() + + @staticmethod + def _make_transaction_query(**kwargs): + defaults = TransactionQuery( + height=None, + transaction_types=None, + sender=None, + address=None, + sender_address=None, + recipient_address=None, + mosaic=None + ) + return defaults._replace(**kwargs) diff --git a/explorer/rest/tests/test_rest.py b/explorer/rest/tests/test_rest.py index 37b4f7e67..5204aa8b3 100644 --- a/explorer/rest/tests/test_rest.py +++ b/explorer/rest/tests/test_rest.py @@ -104,7 +104,7 @@ def _get_api(client, endpoint, **query_params): # pylint: disable=redefined-out def test_invalid_pagination_params(client): # pylint: disable=redefined-outer-name - for module in ['blocks', 'accounts', 'namespaces', 'mosaics', 'mosaic/rich/list']: + for module in ['blocks', 'accounts', 'namespaces', 'mosaics', 'mosaic/rich/list', 'transactions']: # Act: response = client.get(f'/api/nem/{module}', query_string={'limit': -1}) @@ -118,7 +118,7 @@ def test_invalid_pagination_params(client): # pylint: disable=redefined-outer-n def test_invalid_sort_params(client): # pylint: disable=redefined-outer-name - for module in ['blocks', 'namespaces', 'mosaics']: + for module in ['blocks', 'namespaces', 'mosaics', 'transactions']: # Act: response = client.get(f'/api/nem/{module}', query_string={'sort': 'INVALID'}) @@ -699,6 +699,160 @@ def test_api_nem_transaction_statistics_with_invalid_date_range(client): # pyli 'periodType': 'MONTH' } ) +# region /transactions + + +def _assert_get_api_nem_transactions(client, expected_status_code, expected_result, **query_params): # pylint: disable=redefined-outer-name + # Act: + response = _get_api(client, 'transactions', **query_params) + + print(response.json) + + # Assert: + _assert_status_code_and_headers(response, expected_status_code) + assert expected_result == response.json + + +def test_api_transactions_without_params(client): # pylint: disable=redefined-outer-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[7].to_dict(), + TRANSACTIONS_VIEWS[5].to_dict(), + TRANSACTIONS_VIEWS[6].to_dict(), + TRANSACTIONS_VIEWS[2].to_dict(), + TRANSACTIONS_VIEWS[3].to_dict(), + TRANSACTIONS_VIEWS[4].to_dict(), + TRANSACTIONS_VIEWS[1].to_dict(), + TRANSACTIONS_VIEWS[0].to_dict() + ]) + + +def test_api_transactions_applies_limit(client): # pylint: disable=redefined-outer-name + _assert_get_api_nem_transactions(client, 200, [TRANSACTIONS_VIEWS[2].to_dict()], limit=1) + + +def test_api_transactions_applies_offset(client): # pylint: disable=redefined-outer-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[5].to_dict(), + TRANSACTIONS_VIEWS[6].to_dict(), + TRANSACTIONS_VIEWS[2].to_dict(), + TRANSACTIONS_VIEWS[3].to_dict(), + TRANSACTIONS_VIEWS[4].to_dict(), + TRANSACTIONS_VIEWS[1].to_dict(), + TRANSACTIONS_VIEWS[0].to_dict() + ], offset=1) + + +def test_api_transactions_applies_sorted_by_height_asc(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[0].to_dict(), + TRANSACTIONS_VIEWS[1].to_dict(), + TRANSACTIONS_VIEWS[2].to_dict(), + TRANSACTIONS_VIEWS[3].to_dict(), + TRANSACTIONS_VIEWS[4].to_dict(), + TRANSACTIONS_VIEWS[5].to_dict(), + TRANSACTIONS_VIEWS[6].to_dict(), + TRANSACTIONS_VIEWS[7].to_dict(), + ], sort='ASC') + + +def test_api_transactions_applies_sorted_by_height_desc(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[7].to_dict(), + TRANSACTIONS_VIEWS[5].to_dict(), + TRANSACTIONS_VIEWS[6].to_dict(), + TRANSACTIONS_VIEWS[2].to_dict(), + TRANSACTIONS_VIEWS[3].to_dict(), + TRANSACTIONS_VIEWS[4].to_dict(), + TRANSACTIONS_VIEWS[1].to_dict(), + TRANSACTIONS_VIEWS[0].to_dict() + ], sort='DESC') + + +def test_api_transactions_applies_height(client): # pylint: disable=redefined-outer-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[0].to_dict(), + TRANSACTIONS_VIEWS[1].to_dict() + ], height=1) + + +def test_api_transactions_applies_transaction_types(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[2].to_dict(), + TRANSACTIONS_VIEWS[0].to_dict(), + TRANSACTIONS_VIEWS[1].to_dict() + ], transactionTypes='transfer,account_key_link') + + +def test_api_transactions_applies_address(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[2].to_dict() + ], address='NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V') + + +def test_api_transactions_applies_sender_address(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[2].to_dict() + ], senderAddress='NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V') + + +def test_api_transactions_applies_recipient_address(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[5].to_dict(), + TRANSACTIONS_VIEWS[6].to_dict(), + TRANSACTIONS_VIEWS[0].to_dict(), + TRANSACTIONS_VIEWS[1].to_dict() + ], recipientAddress='NBFWZ4IVRHEIBRCGHLYDS62FSFTBM3VDFA7E6LSQ') + + +def test_api_transactions_applies_mosaic(client): # pylint: disable=redefined-outer-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[1].to_dict() + ], mosaic='root.mosaic') + + +def test_api_transactions_applies_address_ignore_other(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[2].to_dict() + ], address='NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V', senderAddress='NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3') + + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[2].to_dict() + ], address='NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V', recipientAddress='NALICEPFLZQRZGPRIJTMJOCPWDNECXTNNG7QLSG3') + + _assert_get_api_nem_transactions(client, 200, [ + TRANSACTIONS_VIEWS[2].to_dict() + ], address='NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V', senderPublicKey='f9bd190dd0c364261f5c8a74870cc7f7374e631352293c62ecc437657e5de2cd') + + +def _assert_transaction_invalid_params(client, expected_message, **query_params): # pylint: disable=redefined-outer-name + _assert_get_api_nem_transactions(client, 400, { + 'message': expected_message, + 'status': 400 + }, **query_params) + + +def test_api_transactions_invalid_height(client): # pylint: disable=redefined-outer-name + _assert_transaction_invalid_params(client, 'Height must be greater than or equal to 1', height=-1) + + +def test_api_transactions_invalid_address(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_transaction_invalid_params(client, 'Invalid address format', address='INVALIDADDRESS') + _assert_transaction_invalid_params(client, 'Invalid sender address format', senderAddress='INVALIDADDRESS') + _assert_transaction_invalid_params(client, 'Invalid recipient address format', recipientAddress='INVALIDADDRESS') + _assert_transaction_invalid_params(client, 'Invalid sender public key format', senderPublicKey='INVALIDADDRESS') + + +def test_api_transactions_invalid_sender_filter_combination(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_transaction_invalid_params( + client, + 'Only one of senderAddress or senderPublicKey can be provided', + senderAddress='NBNR6XNZQIGQVXII6L3FPJTUGF6NFGLZHBN52R3V', + senderPublicKey='9ca54cd15edf88a9df9173375d4a0d706f7a9ddcf57d7547dff8110ddd2adeb9' + ) + + +def test_api_transactions_invalid_transaction_types(client): # pylint: disable=redefined-outer-name, invalid-name + _assert_transaction_invalid_params(client, 'Invalid transaction types', transactionTypes='INVALID') # endregion