Files
Danglewood 869cfed125 [Release] openbb-v5.0.0 (#7741)
* merge branch develop with main

* bump desktop version

* fix the merge

* sync Cargo.lock to desktop 2.0.0

* fix cli conftest.py

---------

Co-authored-by: deeleeramone <>
2026-09-29 10:58:44 -07:00

440 lines
15 KiB
Python

"""Tests for the HTTP transport."""
import asyncio
import json
from urllib.parse import urlparse
import pytest
from openbb_core.app.model.abstract.error import OpenBBError
from openbb_finra.utils import client
def _run(coroutine):
"""Run a coroutine to completion."""
return asyncio.run(coroutine)
def _trace_body(rows, total=None, status="success"):
"""Return a TRACE envelope carrying the rows."""
return json.dumps(
{
"status": status,
"returnBody": {
"headers": {
"Record-Total": [str(len(rows) if total is None else total)]
},
"data": json.dumps(rows),
},
}
)
class TestOpenSession:
"""The real session factory applies the core request settings."""
def test_opens_a_core_session(self, real_open_session):
"""A core client session is returned and can be closed."""
from openbb_core.provider.utils.client import ClientSession
async def run():
session = await real_open_session()
try:
return isinstance(session, ClientSession)
finally:
await session.close()
assert _run(run())
class TestDecode:
"""Bodies decode to JSON when they are JSON."""
def test_empty(self):
"""A blank body is None."""
assert client._decode(" ") is None
def test_json(self):
"""JSON decodes."""
assert client._decode('{"a": 1}') == {"a": 1}
def test_text(self):
"""Anything else is kept as text."""
assert client._decode("<html>") == "<html>"
class TestCollectPages:
"""Paged datasets are read completely, including short pages."""
@staticmethod
def _fetcher(total, cap=None, calls=None, empty_at=None):
rows = [{"n": index} for index in range(total)]
async def fetch(offset, limit):
if calls is not None:
calls.append((offset, limit))
if empty_at is not None and offset >= empty_at:
return [], total
size = min(limit, cap) if cap else limit
return rows[offset : offset + size], total
return fetch
def test_single_page(self):
"""A dataset within one page is read with one request."""
calls: list = []
rows = _run(client.collect_pages(self._fetcher(3, calls=calls), 10))
assert [row["n"] for row in rows] == [0, 1, 2]
assert calls == [(0, 10)]
def test_empty(self):
"""An empty dataset returns no rows."""
assert _run(client.collect_pages(self._fetcher(0), 10)) == []
def test_many_pages(self):
"""Every page is read, in offset order."""
rows = _run(client.collect_pages(self._fetcher(25), 10, concurrency=3))
assert [row["n"] for row in rows] == list(range(25))
def test_short_pages_are_filled(self):
"""A page cut short by a payload cap is completed with more requests."""
calls: list = []
rows = _run(client.collect_pages(self._fetcher(25, cap=7, calls=calls), 10))
assert [row["n"] for row in rows] == list(range(25))
assert (7, 3) in calls
assert (17, 3) in calls
def test_max_rows(self):
"""Reading stops at the row cap."""
rows = _run(client.collect_pages(self._fetcher(25), 10, max_rows=12))
assert [row["n"] for row in rows] == list(range(12))
def test_max_rows_within_first_page(self):
"""A cap below one page is read with one request."""
calls: list = []
rows = _run(
client.collect_pages(self._fetcher(25, calls=calls), 10, max_rows=4)
)
assert [row["n"] for row in rows] == [0, 1, 2, 3]
assert calls == [(0, 4)]
def test_page_that_runs_dry(self):
"""A page that comes back empty ends that block."""
rows = _run(client.collect_pages(self._fetcher(25, empty_at=10), 10))
assert [row["n"] for row in rows] == list(range(10))
class TestQueryApiSession:
"""The Query API pages, totals, and errors are handled."""
@staticmethod
def _query(fake_session, response, method="page"):
fake_session(responder=lambda call: response)
async def run():
async with client.query_api_session() as api:
if method == "partitions":
return await api.partitions("otcMarket", "weeklySummary")
return await api.page("otcMarket", "weeklySummary", {}, 0, 10)
return _run(run())
def test_no_content(self, fake_session, response):
"""HTTP 204 is an empty result."""
assert self._query(fake_session, response(204)) == ([], 0)
def test_rows_and_total(self, fake_session, response):
"""The record-total header gives the total."""
response = response(200, '[{"a": 1}]', {"record-total": "42"})
assert self._query(fake_session, response) == ([{"a": 1}], 42)
def test_rows_without_total(self, fake_session, response):
"""Without the header the page size is the total."""
assert self._query(fake_session, response(200, '[{"a": 1}]')) == (
[{"a": 1}],
1,
)
def test_error_message(self, fake_session, response):
"""The FINRA error message is raised."""
response = response(400, '{"message": "Sorting is not allowed"}')
with pytest.raises(OpenBBError, match="HTTP 400: Sorting is not allowed"):
self._query(fake_session, response)
def test_error_text(self, fake_session, response):
"""A non-JSON error body is raised as text."""
with pytest.raises(OpenBBError, match="HTTP 503: unavailable"):
self._query(fake_session, response(503, "unavailable"))
def test_unexpected_body(self, fake_session, response):
"""A body that is not a list is rejected."""
with pytest.raises(OpenBBError, match="unexpected weeklySummary"):
self._query(fake_session, response(200, '{"a": 1}'))
def test_partitions(self, fake_session, response):
"""Partitions are returned as lists of key values."""
body = json.dumps(
{
"availablePartitions": [
{"partitions": ["2026-08-31", "T1"]},
{"partitions": None},
]
}
)
result = self._query(fake_session, response(200, body), "partitions")
assert result == [["2026-08-31", "T1"], []]
def test_partitions_error(self, fake_session, response):
"""A failed partitions request raises."""
with pytest.raises(OpenBBError, match="partitions with HTTP 500"):
self._query(fake_session, response(500, "error"), "partitions")
def test_session_is_closed(self, fake_session, response):
"""The session closes when the block exits."""
session = fake_session(responder=lambda call: response(204))
async def run():
async with client.query_api_session() as api:
await api.query("otcMarket", "weeklySummary", {})
_run(run())
assert session.closed
assert session.calls[0]["json"] == {"offset": 0, "limit": 5000}
class TestTraceSession:
"""The TRACE envelope, token, retries, and errors are handled."""
@staticmethod
def _page(fake_session, response, responses, cookies=None):
answers = iter(responses)
def responder(call):
if call["method"] == "GET":
return response(400, '{"status": "failure"}')
return next(answers)
session = fake_session(responder=responder, cookies=cookies)
async def run():
async with client.trace_session() as trace:
return await trace.page("corporateAndAgencySecurities", {}, 0, 5)
return _run(run()), session
def test_rows_and_total(self, fake_session, response):
"""Rows come from the encoded data string."""
result, session = self._page(
fake_session, response, [response(200, _trace_body([{"cusip": "X"}], 9))]
)
assert result == ([{"cusip": "X"}], 9)
assert session.closed
def test_null_data(self, fake_session, response):
"""A null data block is an empty page."""
body = json.dumps({"status": "success", "returnBody": {"data": None}})
result, _ = self._page(fake_session, response, [response(200, body)])
assert result == ([], 0)
def test_data_list(self, fake_session, response):
"""A data block that is already a list is used as is."""
body = json.dumps({"status": "success", "returnBody": {"data": [{"a": 1}]}})
result, _ = self._page(fake_session, response, [response(200, body)])
assert result == ([{"a": 1}], 1)
def test_http_error(self, fake_session, response):
"""A non-200 answer raises."""
with pytest.raises(OpenBBError, match="HTTP 403"):
self._page(fake_session, response, [response(403, "Invalid CORS request")])
def test_login_page(self, fake_session, response):
"""A login page served with HTTP 200 raises."""
with pytest.raises(OpenBBError, match="HTTP 200"):
self._page(fake_session, response, [response(200, "<html>Login</html>")])
def test_failure_status(self, fake_session, response):
"""A failure envelope raises its message."""
body = json.dumps({"status": "failure", "statusMessage": "Bad Request."})
with pytest.raises(OpenBBError, match="Bad Request"):
self._page(fake_session, response, [response(200, body)])
def test_retries_gateway_errors(self, fake_session, response, monkeypatch):
"""A gateway error is retried."""
monkeypatch.setattr("openbb_finra.utils.constants.TRACE_BACKOFF", 0)
gateway = response(502, "<html>Bad gateway</html>")
result, _ = self._page(
fake_session, response, [gateway, response(200, _trace_body([{"a": 1}]))]
)
assert result == ([{"a": 1}], 1)
assert gateway.released
def test_gives_up_after_retries(self, fake_session, response, monkeypatch):
"""Gateway errors on every attempt raise."""
monkeypatch.setattr("openbb_finra.utils.constants.TRACE_BACKOFF", 0)
with pytest.raises(OpenBBError, match="HTTP 524"):
self._page(fake_session, response, [response(524, "timeout")] * 3)
def test_missing_token(self, fake_session, response):
"""Without the XSRF cookie the session cannot open."""
with pytest.raises(OpenBBError, match="XSRF"):
self._page(fake_session, response, [], cookies={"XSRF-TOKEN": ""})
def test_query_pages(self, fake_session, response):
"""A query collects every page."""
def responder(call):
if call["method"] == "GET":
return response(400, "{}")
offset = call["json"]["offset"]
return response(200, _trace_body([{"n": offset}], 2))
fake_session(responder=responder)
async def run():
async with client.trace_session() as trace:
return await trace.query("x", {}, max_rows=None)
assert _run(run()) == [{"n": 0}, {"n": 1}]
class TestMarketDataSession:
"""The Market Data Center session, lookups, and errors are handled."""
@staticmethod
def _call(fake_session, response, answer, method, *args):
def responder(call):
if urlparse(call["url"]).path == "/finralogin.jsp":
return response(200, "\n")
return answer
session = fake_session(responder=responder)
async def run():
async with client.market_data_session() as market_data:
return await getattr(market_data, method)(*args)
return _run(run()), session
def test_search(self, fake_session, response):
"""Search records are returned."""
answer = response(200, '{"result": [{"OS001": "AAPL"}], "id": "1"}')
result, session = self._call(
fake_session, response, answer, "search", "AAPL", "ST"
)
assert result == [{"OS001": "AAPL"}]
assert session.calls[1]["params"] == {
"condition": "ST",
"kw": "AAPL",
"id": "1",
}
assert session.closed
def test_search_without_hits(self, fake_session, response):
"""No hits is an empty list."""
result, _ = self._call(
fake_session, response, response(200, '{"id": "1"}'), "search", "ZZZ", "ST"
)
assert result == []
def test_search_error(self, fake_session, response):
"""A search error message is raised."""
body = (
'{"result": [], "errorMessage": "The parameter [kw] is not allowed empty."}'
)
with pytest.raises(OpenBBError, match="not allowed empty"):
self._call(fake_session, response, response(200, body), "search", " ", "ST")
def test_search_unreadable(self, fake_session, response):
"""A search body that is not JSON raises."""
with pytest.raises(OpenBBError, match="no search data"):
self._call(
fake_session, response, response(200, "<html>"), "search", "A", "ST"
)
def test_invalid_session(self, fake_session, response):
"""A rejected session raises."""
body = '{"status": {"errorCode": "-1", "errorMsg": "Invalid session"}, "data": null}'
with pytest.raises(OpenBBError, match="rejected the session"):
self._call(fake_session, response, response(200, body), "search", "A", "ST")
def test_lookup(self, fake_session, response):
"""Lookup records are returned."""
body = '{"errorCode": "0", "Records": [{"Ticker": "AAPL"}]}'
result, session = self._call(
fake_session, response, response(200, body), "lookup", ["AAPL", "MSFT"]
)
assert result == [{"Ticker": "AAPL"}]
assert session.calls[1]["params"] == {"symbol": "AAPL,MSFT"}
def test_lookup_unreadable(self, fake_session, response):
"""A lookup body that is not JSON raises."""
with pytest.raises(OpenBBError, match="not readable"):
self._call(
fake_session, response, response(200, "finraLookup("), "lookup", ["A"]
)
def test_static_data(self, fake_session, response):
"""Static data rows are returned, skipping anything that is not a row."""
body = '{"status": {"errorMsg": "Succeed"}, "data": [{"secId": "X"}, "junk"]}'
result, _ = self._call(
fake_session, response, response(200, body), "static_data", ["X"]
)
assert result == [{"secId": "X"}]
def test_static_data_empty(self, fake_session, response):
"""A session-less empty body gives no rows."""
result, _ = self._call(
fake_session, response, response(200, "\n{}"), "static_data", ["X"]
)
assert result == []
def test_http_error(self, fake_session, response):
"""A non-200 answer raises."""
with pytest.raises(OpenBBError, match="HTTP 503"):
self._call(
fake_session, response, response(503, "unavailable"), "lookup", ["A"]
)
def test_throttled(self, fake_session, response):
"""A rate refusal raises the throttle error."""
with pytest.raises(client.MarketDataThrottledError, match="throttled"):
self._call(
fake_session,
response,
response(429, "Just a moment..."),
"lookup",
["A"],
)