blockstream-satellite-api/server/tests/test_orders.py
Blockstream Satellite 50df236d6b Support multiple parallel logical message channels
The same server now can handle multiple logical channels, on which the
transmitter logic runs independently. That is, while previously a single
message would be in transmitting state at a time, now multiple messages
can be in transmitting state as long as they belong to distinct logical
channels.

The supported channels each have different permissions. The user channel
is where users can post, get, and delete messages as needed. In
contrast, the other channels do not grant all permissions to users. Some
are read-only (users can get but not post) and there is a channel (the
auth channel) on which users have no permissions (neither get nor post).

For the channels on which users do not have all permissions (get, post,
and delete), this patch adds admin-specific routes, which are prefixed
by /admin/. The /admin/ route is protected via SSL in production and
allows the admin host to send GET/POST/DELETE requests normally. Hence,
for instance, the admin host can post a message on the auth channel
(with POST /admin/order) and read it (with GET /admin/order) for
transmission over satellite, whereas regulars cannot. With this scheme,
the auth channel messages are accessible exclusively over satellite (and
not over the internet).

The admin routes were added to the following endpoints:
- /order/<uuid> (GET and DELETE requests)
- /order (POST request)
- /orders/<state> (GET request)
- /message/<tx_seq_num> (GET request)

The messages posted by the admin are not paid, so this patch removes the
requirement of invoice generation and payment. Only paid orders now
generate an invoice. Thus, the POST request to the /order/ endpoint does
not return an invoice for non-paid (admin-only) messages.

Also, this patch updates the queue page to display the orders separately
for each channel. The query string channel parameter determines which
channel the page shows.

Finally, this patch updates the events published into the Redis db on
transmission. The event includes the corresponding logical channel so
that SSE events can be subscribed independently for each channel.
2023-02-02 17:16:12 -03:00

415 lines
17 KiB
Python

import pytest
import random
from datetime import timedelta
from http import HTTPStatus
from time import sleep
from unittest.mock import patch
from constants import InvoiceStatus, OrderStatus
from database import db
from models import Order
from invoice_helpers import pay_invoice
import bidding
import constants
import server
from common import new_invoice, place_order, generate_test_order
from error import assert_error
@pytest.fixture
def client(mockredis):
app = server.create_app(from_test=True)
app.app_context().push()
with app.test_client() as client:
yield client
server.teardown_app(app)
@pytest.mark.parametrize("state", ['pending', 'queued', 'sent'])
def test_get_orders_invalid_before_parameter(client, state):
get_rv = client.get(f'/orders/{state}?before=')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?before=sometext')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?before=2021-13-11')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?before=2021.05.10')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?before=2021-05-1a0T19:51:45')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?before=2021.05.10T19:51:45')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?before=2021-05-10T25:51:45')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
@pytest.mark.parametrize("state", ['pending', 'queued', 'sent'])
def test_get_orders_invalid_limit(client, state):
get_rv = client.get(f'/orders/{state}?limit=')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?limit=sometext')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?limit=1a2')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?limit=-1')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?limit=1.2')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
def test_try_to_get_invalid_order_state(client):
for state in ['pendiing', 'someendpoint', 'Pending']:
get_rv = client.get(f'/orders/{state}')
assert get_rv.status_code == HTTPStatus.NOT_FOUND
@patch('constants.PAGE_SIZE', 3) # change PAGE_SIZE to run this test faster
@patch('orders.new_invoice')
@pytest.mark.parametrize("state", ['pending', 'queued', 'sent'])
def test_get_orders_before_parameter(mock_new_invoice, client, state):
# Create PAGE_SIZE orders with the target state
n_orders = constants.PAGE_SIZE
n_bytes = 500
mock_new_invoice.return_value = (True,
new_invoice(1, InvoiceStatus.pending,
bidding.get_min_bid(n_bytes)))
order_uuids = []
for i in range(n_orders):
post_rv = place_order(client, n_bytes)
assert post_rv.status_code == HTTPStatus.OK
uuid = post_rv.get_json()['uuid']
order_uuids.append(uuid)
db_order = Order.query.filter_by(uuid=uuid).first()
db_order.status = OrderStatus.transmitting.value if \
state == 'queued' else OrderStatus[state].value
db.session.commit()
sleep(1.0) # to have different created_at
# Fetch orders excluding the last
last_db_order = Order.query.filter_by(uuid=order_uuids[-1]).first()
last_created_at = last_db_order.created_at.isoformat()
get_rv = client.get(f'/orders/{state}?before={last_created_at}')
assert get_rv.status_code == HTTPStatus.OK
fetched_uuids = [order['uuid'] for order in get_rv.get_json()]
# The last order should be filtered out by the before filter
expected_uuids = order_uuids[:-1]
# Expected sorting: /orders/pending endpoint sorts by the created_at
# timestamp, /orders/queued by the bid_per_byte ratio, and /orders/sent by
# the transmission_at timestamp.
if (state == 'pending'):
expected_uuids.reverse()
assert len(fetched_uuids) == n_orders - 1
assert fetched_uuids == expected_uuids
@patch('orders.new_invoice')
@pytest.mark.parametrize("state", ['pending', 'queued', 'sent'])
def test_get_orders_limit_parameter(mock_new_invoice, client, state):
n_bytes = 500
mock_new_invoice.return_value = (True,
new_invoice(1, InvoiceStatus.pending,
bidding.get_min_bid(n_bytes)))
for i in range(constants.MAX_PAGE_SIZE + 1):
post_rv = place_order(client, n_bytes)
assert post_rv.status_code == HTTPStatus.OK
uuid = post_rv.get_json()['uuid']
db_order = Order.query.filter_by(uuid=uuid).first()
db_order.status = OrderStatus.transmitting.value if \
state == 'queued' else OrderStatus[state].value
db.session.commit()
# no limit parameter, max PAGE_SIZE should be returned
get_rv = client.get(f'/orders/{state}')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
assert len(get_json_resp) == constants.PAGE_SIZE
# with limit parameter present
get_rv = client.get(f'/orders/{state}?limit=10')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
assert len(get_json_resp) == 10
# the limit should be within [1, MAX_PAGE_SIZE]
get_rv = client.get(f'/orders/{state}?limit={constants.MAX_PAGE_SIZE}')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
assert len(get_json_resp) == constants.MAX_PAGE_SIZE
get_rv = client.get(f'/orders/{state}?limit={constants.MAX_PAGE_SIZE + 1}')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
get_rv = client.get(f'/orders/{state}?limit=0')
assert get_rv.status_code == HTTPStatus.BAD_REQUEST
@patch('orders.new_invoice')
@pytest.mark.parametrize("state", ['pending', 'queued', 'sent'])
@pytest.mark.parametrize("channel", constants.CHANNELS)
def test_get_orders_channel_parameter(mock_new_invoice, client, state,
channel):
n_bytes = 500
mock_new_invoice.return_value = (True,
new_invoice(1, InvoiceStatus.pending,
bidding.get_min_bid(n_bytes)))
# Place all orders as the admin
for i in range(constants.MAX_PAGE_SIZE + 1):
post_rv = place_order(client, n_bytes, channel=channel, admin=True)
assert post_rv.status_code == HTTPStatus.OK
uuid = post_rv.get_json()['uuid']
db_order = Order.query.filter_by(uuid=uuid).first()
db_order.status = OrderStatus.transmitting.value if \
state == 'queued' else OrderStatus[state].value
db.session.commit()
# Get the orders of each channel as a regular user
for _channel in constants.CHANNELS:
get_rv = client.get(f'/orders/{state}?channel={_channel}')
if 'get' in constants.CHANNEL_INFO[_channel].user_permissions:
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
n_expected_res = constants.PAGE_SIZE if _channel == channel else 0
assert len(get_json_resp) == n_expected_res
else:
assert get_rv.status_code == HTTPStatus.UNAUTHORIZED
assert_error(get_rv.get_json(), 'ORDER_CHANNEL_UNAUTHORIZED_OP')
# Get the orders of each channel as an admin user
for _channel in constants.CHANNELS:
get_rv = client.get(f'/admin/orders/{state}?channel={_channel}')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
n_expected_res = constants.PAGE_SIZE if _channel == channel else 0
assert len(get_json_resp) == n_expected_res
@patch('orders.new_invoice')
def test_get_pending_orders(mock_new_invoice, client):
# make some orders
uuid_order1 = generate_test_order(mock_new_invoice, client)['uuid']
uuid_order2 = generate_test_order(mock_new_invoice, client)['uuid']
# manipulate order status for testing the GET /orders endpoint
db_order = Order.query.filter_by(uuid=uuid_order2).first()
pay_invoice(db_order.invoices[0])
db.session.commit()
sleep(1.0) # to have different created_at
uuid_order3 = generate_test_order(mock_new_invoice, client)['uuid']
get_rv = client.get('/orders/pending')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
assert len(get_json_resp) == 2 # paid order should be filtered out
assert get_json_resp[0]['uuid'] == uuid_order3
assert get_json_resp[1]['uuid'] == uuid_order1
@patch('orders.new_invoice')
@patch('constants.PAGE_SIZE', 3) # change PAGE_SIZE to run this test faster
def test_get_pending_orders_paging(mock_new_invoice, client):
# make more orders than PAGE_SIZE
n_orders = constants.PAGE_SIZE + 2
n_bytes = 500
mock_new_invoice.return_value = (True,
new_invoice(1, InvoiceStatus.pending,
bidding.get_min_bid(n_bytes)))
order_uuids = []
for i in range(n_orders):
post_rv = place_order(client, n_bytes)
assert post_rv.status_code == HTTPStatus.OK
order_uuids.append(post_rv.get_json()['uuid'])
sleep(1.0) # to have different created_at
# Check all orders were created
all_orders = Order.query.filter_by(status=OrderStatus.pending.value).all()
assert len(all_orders) == n_orders
# Fetch the pending orders with paging
get_rv = client.get('/orders/pending')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
# Only the last PAGE_SIZE orders should be returned
assert len(get_json_resp) == constants.PAGE_SIZE
expected_uuids = order_uuids[-constants.PAGE_SIZE:]
expected_uuids.reverse() # match the sorting from newest to oldest
for i in range(constants.PAGE_SIZE):
assert get_json_resp[i]['uuid'] == expected_uuids[i]
@patch('orders.new_invoice')
def test_get_queued_orders(mock_new_invoice, client):
# Create some orders with different states
order = {}
for state in [
'pending', 'paid', 'transmitting', 'sent', 'received', 'confirming'
]:
order[state] = generate_test_order(mock_new_invoice,
client,
order_status=OrderStatus[state])
# Request queued orders
get_rv = client.get('/orders/queued')
assert get_rv.status_code == HTTPStatus.OK
queued_uuids = [order['uuid'] for order in get_rv.get_json()]
# The expectation is that only paid, transmitting and confirming
# orders are returned
assert len(queued_uuids) == 3
assert order['pending']['uuid'] not in queued_uuids
assert order['paid']['uuid'] in queued_uuids
assert order['transmitting']['uuid'] in queued_uuids
assert order['sent']['uuid'] not in queued_uuids
assert order['received']['uuid'] not in queued_uuids
assert order['confirming']['uuid'] in queued_uuids
@patch('orders.new_invoice')
def test_get_queued_orders_sorting(mock_new_invoice, client):
n_bytes = 500
mock_new_invoice.return_value = (True,
new_invoice(1, InvoiceStatus.pending,
bidding.get_min_bid(n_bytes)))
n_orders = 10
bid_per_byte_map = {}
for i in range(n_orders):
post_rv = place_order(client, n_bytes)
assert post_rv.status_code == HTTPStatus.OK
order_uuid = post_rv.get_json()['uuid']
db_order = Order.query.filter_by(uuid=order_uuid).first()
db_order.status = OrderStatus.transmitting.value
bid_per_byte = random.randint(1, 10000)
bid_per_byte_map[order_uuid] = bid_per_byte
db_order.bid_per_byte = bid_per_byte
db.session.commit()
expected_sorted_orders = sorted(bid_per_byte_map.items(),
key=lambda x: x[1],
reverse=True)
get_rv = client.get('/orders/queued')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
assert len(get_json_resp) == n_orders
for i in range(n_orders):
assert get_json_resp[i]['uuid'] == expected_sorted_orders[i][0]
assert get_json_resp[i]['bid_per_byte'] == expected_sorted_orders[i][1]
@patch('orders.new_invoice')
def test_get_sent_orders(mock_new_invoice, client):
# Create some orders with different states
order = {}
for state in ['pending', 'transmitting', 'sent', 'received']:
order[state] = generate_test_order(mock_new_invoice,
client,
order_status=OrderStatus[state])
# Request sent orders
get_rv = client.get('/orders/sent')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
sent_uuids = [order['uuid'] for order in get_rv.get_json()]
# The expectation is that only sent and received orders are returned
assert len(get_json_resp) == 2 # pending order should be filtered
assert order['pending']['uuid'] not in sent_uuids
assert order['transmitting']['uuid'] not in sent_uuids
assert order['sent']['uuid'] in sent_uuids
assert order['received']['uuid'] in sent_uuids
@patch('orders.new_invoice')
def test_get_sent_orders_sorting(mock_new_invoice, client):
n_bytes = 500
mock_new_invoice.return_value = (True,
new_invoice(1, InvoiceStatus.pending,
bidding.get_min_bid(n_bytes)))
order_uuids = []
for i in range(5):
post_rv = place_order(client, n_bytes)
assert post_rv.status_code == HTTPStatus.OK
post_json_resp = post_rv.get_json()
order_uuid = post_json_resp['uuid']
order_uuids.append(order_uuid)
db_order = Order.query.filter_by(uuid=order_uuid).first()
db_order.status = OrderStatus.sent.value
order_created_at = db_order.created_at
db_order.ended_transmission_at = order_created_at + timedelta(0, 100)
db.session.commit()
sleep(1.0) # to have different created_at
order_uuids.reverse() # expected uuid order in the response
get_rv = client.get('/orders/sent')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
assert len(get_json_resp) == 5
for i in range(5):
assert get_json_resp[i]['uuid'] == order_uuids[i]
@patch('orders.new_invoice')
@patch('constants.PAGE_SIZE', 3) # change PAGE_SIZE to run this test faster
def test_get_sent_orders_paging(mock_new_invoice, client):
# make more orders than PAGE_SIZE
n_orders = constants.PAGE_SIZE + 2
n_bytes = 500
mock_new_invoice.return_value = (True,
new_invoice(1, InvoiceStatus.pending,
bidding.get_min_bid(n_bytes)))
order_uuids = []
for i in range(n_orders):
post_rv = place_order(client, n_bytes)
assert post_rv.status_code == HTTPStatus.OK
order_uuid = post_rv.get_json()['uuid']
order_uuids.append(order_uuid)
db_order = Order.query.filter_by(uuid=order_uuid).first()
db_order.status = OrderStatus.received.value
order_created_at = db_order.created_at
db_order.ended_transmission_at = order_created_at + timedelta(0, 100)
db.session.commit()
sleep(1.0) # to have different created_at
# Check all orders were created
all_orders = Order.query.filter_by(status=OrderStatus.received.value).all()
assert len(all_orders) == n_orders
# Fetch the sent orders with paging
get_rv = client.get('/orders/sent')
assert get_rv.status_code == HTTPStatus.OK
get_json_resp = get_rv.get_json()
# Only the last PAGE_SIZE orders should be returned
assert len(get_json_resp) == constants.PAGE_SIZE
expected_uuids = order_uuids[-constants.PAGE_SIZE:]
expected_uuids.reverse() # match the sorting from newest to oldest
for i in range(constants.PAGE_SIZE):
assert get_json_resp[i]['uuid'] == expected_uuids[i]
@patch('orders.new_invoice')
@patch('constants.FORCE_PAYMENT', True)
def test_create_order_with_force_payment_enabled(mock_new_invoice, client):
uuid_order1 = generate_test_order(mock_new_invoice, client)['uuid']
uuid_order2 = generate_test_order(mock_new_invoice, client)['uuid']
db_order1 = Order.query.filter_by(uuid=uuid_order1).first()
db_invoice1 = db_order1.invoices[0]
db_order2 = Order.query.filter_by(uuid=uuid_order2).first()
db_invoice2 = db_order2.invoices[0]
# Since FORCE_PAYMENT is set and both orders have only one invoice, both
# orders change their statuses to paid. Furthermore, the payment triggers a
# Tx start verification and, since the transmit queue is empty, order1
# immediately changes to transmitting state. In contrast, order2 stays in
# paid state as it needs to wait until the transmission of order1 finishes.
assert db_order1.status == OrderStatus.transmitting.value
assert db_invoice1.status == InvoiceStatus.paid.value
assert db_invoice1.paid_at is not None
assert db_order2.status == OrderStatus.paid.value
assert db_invoice2.status == InvoiceStatus.paid.value
assert db_invoice2.paid_at is not None