mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-11-12 12:22:35 +00:00
* 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 <>
960 lines
35 KiB
Python
960 lines
35 KiB
Python
"""Unit tests for the SEC router (``openbb_sec.sec_router``).
|
|
|
|
The router commands are otherwise only exercised by the live ``integration``
|
|
suite, so these tests cover registration and the command bodies without any
|
|
network access by mocking ``Query`` and ``OBBject.from_query``.
|
|
|
|
The SEC provider implements a number of standard models normally surfaced
|
|
through the ``openbb-equity`` and ``openbb-etf`` routers. When either extension
|
|
is not installed, those models are registered under SEC-prefixed names and
|
|
exposed through the SEC router instead. The fallback commands are therefore
|
|
registered conditionally, which the tests below account for.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from openbb_sec import sec_router
|
|
|
|
# Commands that are always registered on the SEC router.
|
|
_BASE_COMMANDS = [
|
|
"filing_headers",
|
|
"htm_file",
|
|
"cik_map",
|
|
"institutions_search",
|
|
"adviser_firms",
|
|
"adviser_individuals",
|
|
"adviser_documents",
|
|
"adviser_universe",
|
|
"schema_files",
|
|
"symbol_map",
|
|
"rss_litigation",
|
|
"sic_search",
|
|
"financial_statements",
|
|
"company_overview",
|
|
"disclosures",
|
|
"risk_factors",
|
|
"segment_revenue",
|
|
"legal_proceedings",
|
|
"exhibit",
|
|
"executive_compensation",
|
|
"beneficial_ownership",
|
|
"management_profiles",
|
|
"pay_versus_performance",
|
|
"company_filings",
|
|
"latest_financial_reports",
|
|
"full_text_search",
|
|
"nport_fund_metrics",
|
|
]
|
|
|
|
# Non-command routes mounted on the router's APIRouter (raw GET handlers that
|
|
# back the OpenBB Workspace widgets, not OBBject commands).
|
|
_EXTRA_ROUTES = [
|
|
"filing_options",
|
|
"exhibits",
|
|
"companies",
|
|
"form_types",
|
|
"report_types",
|
|
"nport_funds",
|
|
"nport_periods",
|
|
"13f_filers",
|
|
"13f_periods",
|
|
"fts_categories",
|
|
"fts_form_types",
|
|
"fts_locations",
|
|
"edgar_document",
|
|
"edgar_document_markdown",
|
|
"asset_data_rows",
|
|
"filing_viewer_mcp/{path:path}",
|
|
"filing_viewer_app",
|
|
"section_markdown",
|
|
"as_filed_statement",
|
|
"standardized_statement",
|
|
"widgets.json",
|
|
"apps.json",
|
|
]
|
|
|
|
# Registered on the SEC router only when ``openbb-equity`` is not installed.
|
|
_EQUITY_FALLBACK_COMMANDS = [
|
|
"balance_sheet",
|
|
"balance_sheet_growth",
|
|
"cash_flow",
|
|
"cash_flow_growth",
|
|
"income_statement",
|
|
"income_statement_growth",
|
|
"compare_company_facts",
|
|
"equity_search",
|
|
"insider_trading",
|
|
"form_13f",
|
|
"equity_ftd",
|
|
"management_discussion_analysis",
|
|
]
|
|
|
|
# Registered on the SEC router only when ``openbb-etf`` is not installed.
|
|
_ETF_FALLBACK_COMMANDS = ["nport_disclosure"]
|
|
|
|
|
|
def test_router_metadata():
|
|
"""The router uses an empty prefix and a SEC description."""
|
|
assert sec_router.router.prefix == ""
|
|
assert "SEC" in sec_router.router.description
|
|
|
|
|
|
def test_all_commands_registered():
|
|
"""The registered commands match the current install configuration.
|
|
|
|
The base commands are always present; the equity/etf fallback commands are
|
|
present only when the respective extension is absent.
|
|
"""
|
|
paths = {route.path for route in sec_router.router.api_router.routes}
|
|
expected = set(_BASE_COMMANDS) | set(_EXTRA_ROUTES)
|
|
if not sec_router.EQUITY_INSTALLED:
|
|
expected |= set(_EQUITY_FALLBACK_COMMANDS)
|
|
if not sec_router.ETF_INSTALLED:
|
|
expected |= set(_ETF_FALLBACK_COMMANDS)
|
|
assert paths == {f"/{name}" for name in expected}
|
|
|
|
|
|
@pytest.mark.parametrize("command_name", _BASE_COMMANDS)
|
|
def test_command_delegates_to_from_query(command_name):
|
|
"""Each base command builds a Query and awaits OBBject.from_query."""
|
|
sentinel = object()
|
|
command = getattr(sec_router, command_name)
|
|
with (
|
|
patch.object(sec_router, "Query", MagicMock()) as mock_query,
|
|
patch.object(
|
|
sec_router.OBBject,
|
|
"from_query",
|
|
new=AsyncMock(return_value=sentinel),
|
|
) as mock_from_query,
|
|
):
|
|
result = asyncio.run(
|
|
command(
|
|
cc=None,
|
|
provider_choices=None,
|
|
standard_params=None,
|
|
extra_params=None,
|
|
)
|
|
)
|
|
assert result is sentinel
|
|
mock_query.assert_called_once()
|
|
mock_from_query.assert_awaited_once()
|
|
|
|
|
|
def _load_router_module(*, equity_installed: bool, etf_installed: bool):
|
|
"""Re-execute ``sec_router`` with the install flags forced to given values.
|
|
|
|
Loaded under a private module name so the live ``openbb_sec.sec_router``
|
|
used elsewhere in the suite is untouched. ``Router.command`` is replaced
|
|
during load with a passthrough decorator so the endpoints bind without
|
|
requiring the SEC-prefixed models to be resolved through the live provider
|
|
registry or FastAPI's response-model machinery.
|
|
|
|
This exercises the install-gated endpoint definitions regardless of which
|
|
extensions are actually present in the test environment.
|
|
"""
|
|
import importlib.util
|
|
from pathlib import Path
|
|
|
|
from openbb_core.app.router import Router
|
|
|
|
import openbb_sec
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
f"openbb_sec_sec_router_e{int(equity_installed)}t{int(etf_installed)}",
|
|
Path(openbb_sec.__file__).parent / "sec_router.py",
|
|
)
|
|
module = importlib.util.module_from_spec(spec)
|
|
original_equity = openbb_sec.EQUITY_INSTALLED
|
|
original_etf = openbb_sec.ETF_INSTALLED
|
|
original_command = Router.command
|
|
openbb_sec.EQUITY_INSTALLED = equity_installed
|
|
openbb_sec.ETF_INSTALLED = etf_installed
|
|
|
|
def _passthrough_command(self, func=None, **_kwargs):
|
|
"""Bind ``func`` without touching the underlying FastAPI router."""
|
|
if func is None:
|
|
return lambda f: _passthrough_command(self, f, **_kwargs)
|
|
return func
|
|
|
|
Router.command = _passthrough_command # type: ignore[assignment]
|
|
try:
|
|
spec.loader.exec_module(module)
|
|
finally:
|
|
openbb_sec.EQUITY_INSTALLED = original_equity
|
|
openbb_sec.ETF_INSTALLED = original_etf
|
|
Router.command = original_command # type: ignore[assignment]
|
|
return module
|
|
|
|
|
|
def _load_standalone_router_module():
|
|
"""Re-execute ``sec_router`` with both install flags forced to False."""
|
|
return _load_router_module(equity_installed=False, etf_installed=False)
|
|
|
|
|
|
class TestStandaloneFallbackEndpoints:
|
|
"""The commands registered only when ``openbb-equity``/``openbb-etf`` are absent."""
|
|
|
|
_STANDALONE_NAMES = tuple(_EQUITY_FALLBACK_COMMANDS + _ETF_FALLBACK_COMMANDS)
|
|
|
|
def test_standalone_module_binds_all_fallback_endpoint_names(self):
|
|
"""All fallback endpoint names exist as module attributes after reload."""
|
|
module = _load_standalone_router_module()
|
|
for name in self._STANDALONE_NAMES:
|
|
assert callable(getattr(module, name, None)), f"missing {name}"
|
|
|
|
def test_standalone_module_binds_base_endpoint_names(self):
|
|
"""The always-present base commands also bind in the standalone module."""
|
|
module = _load_standalone_router_module()
|
|
for name in _BASE_COMMANDS:
|
|
assert callable(getattr(module, name, None)), f"missing {name}"
|
|
|
|
@pytest.mark.parametrize(
|
|
"endpoint_name",
|
|
[*_STANDALONE_NAMES, "company_filings", "latest_financial_reports"],
|
|
)
|
|
def test_standalone_endpoint_body_delegates_to_from_query(self, endpoint_name):
|
|
"""Each fallback endpoint body calls ``OBBject.from_query(Query(**locals()))``."""
|
|
module = _load_standalone_router_module()
|
|
fn = getattr(module, endpoint_name)
|
|
|
|
sentinel = object()
|
|
with (
|
|
patch.object(module, "Query", new=MagicMock()) as mock_query,
|
|
patch.object(
|
|
module.OBBject,
|
|
"from_query",
|
|
new=AsyncMock(return_value=sentinel),
|
|
) as mock_from_query,
|
|
):
|
|
out = asyncio.run(
|
|
fn(
|
|
cc=MagicMock(),
|
|
provider_choices=MagicMock(),
|
|
standard_params=MagicMock(),
|
|
extra_params=MagicMock(),
|
|
)
|
|
)
|
|
assert out is sentinel
|
|
mock_query.assert_called_once()
|
|
mock_from_query.assert_awaited_once()
|
|
|
|
|
|
class TestInstalledExtensionEndpoints:
|
|
"""Endpoints registered when ``openbb-equity`` / ``openbb-etf`` ARE installed.
|
|
|
|
Where those extensions are absent the installed-path branch never executes
|
|
live, so it is exercised by reloading the router with the flags forced True.
|
|
"""
|
|
|
|
_INSTALLED_NAMES = ("company_filings", "latest_financial_reports")
|
|
|
|
def test_installed_module_binds_endpoints(self):
|
|
"""The installed-path endpoints bind when equity/etf are present."""
|
|
module = _load_router_module(equity_installed=True, etf_installed=True)
|
|
for name in self._INSTALLED_NAMES:
|
|
assert callable(getattr(module, name, None)), f"missing {name}"
|
|
|
|
@pytest.mark.parametrize("endpoint_name", _INSTALLED_NAMES)
|
|
def test_installed_endpoint_body_delegates_to_from_query(self, endpoint_name):
|
|
"""Each installed-path endpoint body calls ``OBBject.from_query``."""
|
|
module = _load_router_module(equity_installed=True, etf_installed=True)
|
|
fn = getattr(module, endpoint_name)
|
|
|
|
sentinel = object()
|
|
with (
|
|
patch.object(module, "Query", new=MagicMock()) as mock_query,
|
|
patch.object(
|
|
module.OBBject,
|
|
"from_query",
|
|
new=AsyncMock(return_value=sentinel),
|
|
) as mock_from_query,
|
|
):
|
|
out = asyncio.run(
|
|
fn(
|
|
cc=MagicMock(),
|
|
provider_choices=MagicMock(),
|
|
standard_params=MagicMock(),
|
|
extra_params=MagicMock(),
|
|
)
|
|
)
|
|
assert out is sentinel
|
|
mock_query.assert_called_once()
|
|
mock_from_query.assert_awaited_once()
|
|
|
|
|
|
class TestWidgetEndpoints:
|
|
"""The raw GET handlers backing the OpenBB Workspace widgets."""
|
|
|
|
def test_filing_options(self):
|
|
filings = [
|
|
{"period_ending": "2024-09-28"},
|
|
{"period_ending": "2023-09-30"},
|
|
{"period_ending": None},
|
|
]
|
|
with patch(
|
|
"openbb_sec.models.sec_financials.get_form10_urls_by_symbol",
|
|
new=AsyncMock(return_value=filings),
|
|
):
|
|
options = asyncio.run(sec_router.filing_options(symbol="AAPL"))
|
|
assert {o["value"] for o in options} == {"2024", "2023"}
|
|
|
|
def test_filing_options_empty_symbol(self):
|
|
assert asyncio.run(sec_router.filing_options(symbol="")) == []
|
|
|
|
def test_exhibits_empty_symbol(self):
|
|
assert asyncio.run(sec_router.exhibits(symbol="")) == []
|
|
|
|
def test_exhibits_no_filing(self):
|
|
with patch(
|
|
"openbb_sec.models.sec_financials.resolve_filing_url",
|
|
new=AsyncMock(return_value=""),
|
|
):
|
|
assert (
|
|
asyncio.run(sec_router.exhibits(symbol="AAPL", calendar_year="1999"))
|
|
== []
|
|
)
|
|
|
|
def test_exhibits_lists_choices(self):
|
|
from types import SimpleNamespace
|
|
|
|
fs = SimpleNamespace(exhibit_choices=lambda: [{"value": "EX-21", "label": "x"}])
|
|
with (
|
|
patch(
|
|
"openbb_sec.models.sec_financials.resolve_filing_url",
|
|
new=AsyncMock(return_value="u"),
|
|
),
|
|
patch(
|
|
"openbb_sec.models.sec_financials.FinancialStatements.from_url",
|
|
return_value=fs,
|
|
),
|
|
):
|
|
options = asyncio.run(sec_router.exhibits(symbol="AAPL"))
|
|
assert options == [{"value": "EX-21", "label": "x"}]
|
|
|
|
def test_standardized_statement(self):
|
|
with patch(
|
|
"openbb_sec.utils.statement_widget.get_statement_widget_rows",
|
|
new=AsyncMock(return_value=[{"Line Item": "Total Assets"}]),
|
|
):
|
|
rows = asyncio.run(sec_router.standardized_statement(symbol="AAPL"))
|
|
assert rows == [{"Line Item": "Total Assets"}]
|
|
|
|
def test_standardized_statement_empty_symbol(self):
|
|
assert asyncio.run(sec_router.standardized_statement(symbol="")) == []
|
|
|
|
def test_as_filed_statement(self):
|
|
with patch(
|
|
"openbb_sec.utils.as_filed_widget.get_as_filed_widget_rows",
|
|
new=AsyncMock(return_value=[{"order": 1, "label": "Cash"}]),
|
|
):
|
|
rows = asyncio.run(sec_router.as_filed_statement(symbol="ORCL"))
|
|
assert rows == [{"order": 1, "label": "Cash"}]
|
|
|
|
def test_as_filed_statement_empty_symbol(self):
|
|
assert asyncio.run(sec_router.as_filed_statement(symbol="")) == []
|
|
|
|
def test_companies(self):
|
|
with patch(
|
|
"openbb_sec.utils.company_choices.get_company_choices",
|
|
new=AsyncMock(return_value=[{"label": "Apple Inc.", "value": "AAPL"}]),
|
|
):
|
|
result = asyncio.run(sec_router.companies(use_cache=True))
|
|
assert result == [{"label": "Apple Inc.", "value": "AAPL"}]
|
|
|
|
def test_get_apps_json(self):
|
|
apps = asyncio.run(sec_router.get_apps_json())
|
|
assert isinstance(apps, list)
|
|
assert apps[0]["name"]
|
|
ids = {
|
|
w["i"]
|
|
for app in apps
|
|
for tab in app["tabs"].values()
|
|
for w in tab["layout"]
|
|
}
|
|
if sec_router.EQUITY_INSTALLED:
|
|
assert "equity_ownership_form_13f_sec_obb" in ids
|
|
assert "sec_form_13f_sec_obb" not in ids
|
|
else:
|
|
assert "sec_form_13f_sec_obb" in ids
|
|
|
|
def test_section_markdown(self):
|
|
with patch(
|
|
"openbb_sec.utils.section_markdown.get_section_markdown",
|
|
new=AsyncMock(return_value="## Risk\n\nBody"),
|
|
):
|
|
result = asyncio.run(
|
|
sec_router.section_markdown(symbol="AAPL", section="risk_factors")
|
|
)
|
|
assert result == "## Risk\n\nBody"
|
|
|
|
def test_section_markdown_empty_symbol(self):
|
|
assert asyncio.run(sec_router.section_markdown(symbol="")) == ""
|
|
|
|
def test_warm_companies(self):
|
|
with patch(
|
|
"openbb_sec.utils.company_choices.get_company_choices",
|
|
new=AsyncMock(return_value=[]),
|
|
) as warm:
|
|
asyncio.run(sec_router._warm_companies())
|
|
warm.assert_awaited_once()
|
|
|
|
def test_warm_companies_suppresses_errors(self):
|
|
with patch(
|
|
"openbb_sec.utils.company_choices.get_company_choices",
|
|
new=AsyncMock(side_effect=RuntimeError("boom")),
|
|
):
|
|
asyncio.run(sec_router._warm_companies())
|
|
|
|
def test_get_widgets_json_includes_curated(self):
|
|
with (
|
|
patch("openbb_sec.sec_router._warm_companies", new=AsyncMock()),
|
|
patch("openbb_sec.sec_router.ensure_mcp_subprocess"),
|
|
):
|
|
widgets = asyncio.run(sec_router.get_widgets_json())
|
|
assert isinstance(widgets, dict)
|
|
assert widgets["sec_company_overview_sec_obb"]["type"] == "markdown"
|
|
|
|
def test_get_widgets_json_falls_back_on_error(self):
|
|
with (
|
|
patch("openbb_sec.sec_router._warm_companies", new=AsyncMock()),
|
|
patch("openbb_sec.sec_router.ensure_mcp_subprocess"),
|
|
patch(
|
|
"openbb_platform_api.utils.widgets.build_json",
|
|
side_effect=RuntimeError("boom"),
|
|
),
|
|
):
|
|
widgets = asyncio.run(sec_router.get_widgets_json())
|
|
assert "sec_balance_sheet_facts_sec_obb" in widgets
|
|
|
|
|
|
def test_nport_funds_endpoint(monkeypatch):
|
|
"""nport_funds delegates to the fund-choices helper."""
|
|
|
|
async def _funds(use_cache=True):
|
|
return [{"label": "XLK - Fund", "value": "XLK"}]
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.helpers.get_nport_fund_choices", _funds)
|
|
assert asyncio.run(sec_router.nport_funds())[0]["value"] == "XLK"
|
|
|
|
|
|
def test_filers_13f_endpoint(monkeypatch):
|
|
"""filers_13f delegates to the filer-choices helper."""
|
|
|
|
async def _filers(use_cache=True):
|
|
return [{"label": "BlackRock", "value": "1"}]
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.helpers.get_13f_filer_choices", _filers)
|
|
assert asyncio.run(sec_router.filers_13f())[0]["value"] == "1"
|
|
|
|
|
|
def test_periods_13f_endpoint(monkeypatch):
|
|
"""periods_13f returns sorted report dates, handling empties and errors."""
|
|
from pandas import DataFrame
|
|
|
|
assert asyncio.run(sec_router.periods_13f(symbol=None)) == []
|
|
|
|
async def _cands(cik=None, symbol=None):
|
|
return DataFrame(index=["2026-03-31", "2025-12-31"])
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.parse_13f.get_13f_candidates", _cands)
|
|
out = asyncio.run(sec_router.periods_13f(symbol="1067983"))
|
|
assert out[0]["value"] == "2026-03-31"
|
|
|
|
async def _boom(**kwargs):
|
|
raise RuntimeError("x")
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.parse_13f.get_13f_candidates", _boom)
|
|
assert asyncio.run(sec_router.periods_13f(symbol="AAA")) == []
|
|
|
|
|
|
def test_nport_periods_endpoint(monkeypatch):
|
|
"""nport_periods dedupes filing periods, handling empties and errors."""
|
|
assert asyncio.run(sec_router.nport_periods(symbol=None)) == []
|
|
|
|
async def _cands(symbol, use_cache=True):
|
|
return [
|
|
{"period_ending": "2026-05-31", "form_type": "N-MFP3"},
|
|
{"period_ending": "2026-05-31", "form_type": "N-MFP3"},
|
|
]
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.helpers.get_nport_candidates", _cands)
|
|
assert len(asyncio.run(sec_router.nport_periods(symbol="vmfxx"))) == 1
|
|
|
|
async def _boom(symbol, use_cache=True):
|
|
raise RuntimeError("x")
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.helpers.get_nport_candidates", _boom)
|
|
assert asyncio.run(sec_router.nport_periods(symbol="AAA")) == []
|
|
|
|
|
|
def test_form_types_endpoint(monkeypatch):
|
|
"""form_types returns distinct report types, handling empties and errors."""
|
|
from types import SimpleNamespace
|
|
|
|
assert asyncio.run(sec_router.form_types(symbol=None)) == []
|
|
|
|
async def _fetch(self, params, creds):
|
|
return [
|
|
SimpleNamespace(report_type="10-K"),
|
|
SimpleNamespace(report_type="8-K"),
|
|
SimpleNamespace(report_type=None),
|
|
]
|
|
|
|
monkeypatch.setattr(
|
|
"openbb_sec.models.company_filings.SecCompanyFilingsFetcher.fetch_data", _fetch
|
|
)
|
|
assert {c["value"] for c in asyncio.run(sec_router.form_types(symbol="aapl"))} == {
|
|
"10-K",
|
|
"8-K",
|
|
}
|
|
|
|
async def _boom(self, params, creds):
|
|
raise RuntimeError("x")
|
|
|
|
monkeypatch.setattr(
|
|
"openbb_sec.models.company_filings.SecCompanyFilingsFetcher.fetch_data", _boom
|
|
)
|
|
assert asyncio.run(sec_router.form_types(symbol="aapl")) == []
|
|
|
|
|
|
def test_report_types_endpoint():
|
|
"""report_types lists the latest-reports filter choices."""
|
|
out = asyncio.run(sec_router.report_types())
|
|
assert isinstance(out, list)
|
|
assert out and "value" in out[0]
|
|
|
|
|
|
def test_get_apps_json_endpoint():
|
|
"""get_apps_json returns the app templates."""
|
|
out = asyncio.run(sec_router.get_apps_json())
|
|
assert isinstance(out, list)
|
|
assert out
|
|
|
|
|
|
def _apps_widget_ids(apps):
|
|
"""Collect every widget id from tab layouts and groups."""
|
|
ids = set()
|
|
for app in apps:
|
|
for tab in app["tabs"].values():
|
|
ids.update(w["i"] for w in tab["layout"])
|
|
for group in app.get("groups", []):
|
|
ids.update(group.get("widgetIds", []))
|
|
return ids
|
|
|
|
|
|
def test_get_apps_json_remaps_for_equity(monkeypatch):
|
|
"""With openbb-equity installed, form_13f maps to the equity widget id."""
|
|
monkeypatch.setattr(sec_router, "EQUITY_INSTALLED", True)
|
|
monkeypatch.setattr(sec_router, "ETF_INSTALLED", False)
|
|
ids = _apps_widget_ids(asyncio.run(sec_router.get_apps_json()))
|
|
assert "equity_ownership_form_13f_sec_obb" in ids
|
|
assert "sec_form_13f_sec_obb" not in ids
|
|
|
|
|
|
def test_get_apps_json_remaps_for_etf(monkeypatch):
|
|
"""With openbb-etf installed, nport_disclosure maps to the etf widget id."""
|
|
monkeypatch.setattr(sec_router, "EQUITY_INSTALLED", False)
|
|
monkeypatch.setattr(sec_router, "ETF_INSTALLED", True)
|
|
ids = _apps_widget_ids(asyncio.run(sec_router.get_apps_json()))
|
|
assert "etf_nport_disclosure_sec_obb" in ids
|
|
assert "sec_nport_disclosure_sec_obb" not in ids
|
|
|
|
|
|
def test_get_apps_json_no_remap_when_standalone(monkeypatch):
|
|
"""With neither extension installed, the SEC-native ids are served as-is."""
|
|
monkeypatch.setattr(sec_router, "EQUITY_INSTALLED", False)
|
|
monkeypatch.setattr(sec_router, "ETF_INSTALLED", False)
|
|
ids = _apps_widget_ids(asyncio.run(sec_router.get_apps_json()))
|
|
assert "sec_form_13f_sec_obb" in ids
|
|
assert "sec_nport_disclosure_sec_obb" in ids
|
|
|
|
|
|
def test_filing_viewer_mcp_port(monkeypatch):
|
|
"""The MCP port reads its env override, defaulting to 7769."""
|
|
from openbb_sec.utils.filing_viewer_mcp import mcp_port
|
|
|
|
monkeypatch.delenv("OPENBB_SEC_MCP_PORT", raising=False)
|
|
assert mcp_port() == 7769
|
|
monkeypatch.setenv("OPENBB_SEC_MCP_PORT", "9999")
|
|
assert mcp_port() == 9999
|
|
|
|
|
|
def test_filing_viewer_app_serves_same_origin_mcp():
|
|
"""The viewer app derives a same-origin MCP base; no injected localhost."""
|
|
resp = asyncio.run(sec_router.filing_viewer_app())
|
|
body = resp.body.decode("utf-8")
|
|
assert "__OB_MCP_BASE__" not in body
|
|
assert 'OB_MCP_BASE = OB_API + "filing_viewer_mcp"' in body
|
|
assert "localhost:7769" not in body
|
|
|
|
|
|
def test_edgar_document_markdown_endpoint(monkeypatch):
|
|
"""edgar_document_markdown returns markdown, empty when no url is given."""
|
|
assert asyncio.run(sec_router.edgar_document_markdown(url=None)) == {"content": ""}
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.filing_viewer_mcp.document_to_markdown", lambda u: "MD"
|
|
)
|
|
out = asyncio.run(
|
|
sec_router.edgar_document_markdown(url="https://www.sec.gov/a.htm")
|
|
)
|
|
assert out == {"content": "MD"}
|
|
|
|
|
|
def test_fts_options_endpoints():
|
|
"""The full-text-search option endpoints return non-empty lists."""
|
|
assert asyncio.run(sec_router.fts_categories())
|
|
assert asyncio.run(sec_router.fts_form_types())[0]["value"]
|
|
assert asyncio.run(sec_router.fts_locations())
|
|
|
|
|
|
def _patch_bytes(monkeypatch, fn):
|
|
"""Patch cached_bytes used by the edgar_document proxy."""
|
|
monkeypatch.setattr("openbb_sec.utils.cache.cached_bytes", fn)
|
|
|
|
|
|
def test_edgar_document_no_url():
|
|
"""An empty URL shows the placeholder."""
|
|
resp = asyncio.run(sec_router.edgar_document(url=None))
|
|
assert b"Select a filing" in resp.body
|
|
|
|
|
|
def test_edgar_document_rejects_non_sec():
|
|
"""A non-SEC URL is rejected with 400."""
|
|
resp = asyncio.run(sec_router.edgar_document(url="https://evil.com/x"))
|
|
assert resp.status_code == 400
|
|
|
|
|
|
def test_edgar_document_load_error(monkeypatch):
|
|
"""A transport error returns a 502."""
|
|
|
|
def _boom(target, **kwargs):
|
|
raise RuntimeError("x")
|
|
|
|
_patch_bytes(monkeypatch, _boom)
|
|
resp = asyncio.run(sec_router.edgar_document(url="https://www.sec.gov/a.htm"))
|
|
assert resp.status_code == 502
|
|
|
|
|
|
def test_edgar_document_pdf(monkeypatch):
|
|
"""PDFs are served as application/pdf by magic bytes or extension."""
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"%PDF-1.7")
|
|
assert (
|
|
asyncio.run(
|
|
sec_router.edgar_document(url="https://www.sec.gov/a.htm")
|
|
).media_type
|
|
== "application/pdf"
|
|
)
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"data")
|
|
assert (
|
|
asyncio.run(
|
|
sec_router.edgar_document(url="https://www.sec.gov/a.pdf")
|
|
).media_type
|
|
== "application/pdf"
|
|
)
|
|
|
|
|
|
def test_edgar_document_images(monkeypatch):
|
|
"""Images are detected by extension and by magic bytes."""
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"\x89PNG\r\n\x1a\n")
|
|
assert (
|
|
asyncio.run(
|
|
sec_router.edgar_document(url="https://www.sec.gov/a.png")
|
|
).media_type
|
|
== "image/png"
|
|
)
|
|
for magic in (b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF89a0000"):
|
|
_patch_bytes(monkeypatch, lambda t, _m=magic, **k: _m)
|
|
resp = asyncio.run(sec_router.edgar_document(url="https://www.sec.gov/a.dat"))
|
|
assert resp.body == magic
|
|
|
|
|
|
def test_edgar_document_html_rewrites(monkeypatch):
|
|
"""HTML is rewritten: assets proxied, inline/anchors/external kept."""
|
|
html = (
|
|
"<html><body>"
|
|
'<img src="pic.png">'
|
|
'<img src="data:image/png;base64,xxx">'
|
|
'<img src="https://cdn.com/a.png">'
|
|
'<img src="//cdn.com/b.png">'
|
|
'<img src="/files/c.png">'
|
|
'<div style="background:url(bg.gif)"></div>'
|
|
'<div style="background:url(data:image/gif;base64,zz)"></div>'
|
|
'<div style="background:url('data:image/svg+xml,<svg/>')"></div>'
|
|
'<a href="page.htm">x</a>'
|
|
'<a href="#sec">y</a>'
|
|
'<a href="https://x.com">z</a>'
|
|
"</body></html>"
|
|
)
|
|
_patch_bytes(monkeypatch, lambda t, **k: html.encode())
|
|
resp = asyncio.run(sec_router.edgar_document(url="https://www.sec.gov/dir/doc.htm"))
|
|
body = resp.body.decode()
|
|
assert resp.media_type == "text/html"
|
|
# Proxied as a relative path so it works under any API prefix.
|
|
assert 'src="edgar_document?url=' in body
|
|
assert "/api/v1/sec/" not in body
|
|
assert "data:image/png" in body
|
|
assert "url(data:image/gif" in body
|
|
assert 'href="#sec"' in body
|
|
assert 'href="https://x.com"' in body
|
|
assert 'href="https://www.sec.gov/dir/page.htm"' in body
|
|
|
|
|
|
def test_edgar_document_xsl_path_served_as_text(monkeypatch):
|
|
"""A non-HTML document already under /xsl is returned as plain text."""
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"raw xml content")
|
|
resp = asyncio.run(
|
|
sec_router.edgar_document(url="https://www.sec.gov/x/xslF345/form.xml")
|
|
)
|
|
assert resp.media_type.startswith("text/plain")
|
|
|
|
|
|
def test_edgar_document_rendered_html(monkeypatch):
|
|
"""A raw XML form resolves to its xsl-rendered HTML."""
|
|
acc = "000123456724000077"
|
|
base = "https://www.sec.gov/Archives/edgar/data/1/" + acc
|
|
rendered_path = "/Archives/edgar/data/1/" + acc + "/xslF345X02/form.xml"
|
|
|
|
def _bytes(target, **kwargs):
|
|
if target.endswith("-index.html"):
|
|
return ('<a href="' + rendered_path + '">Rendered</a>').encode()
|
|
if "/xsl" in target:
|
|
return b'<html><body>Rendered<img src="logo.png"></body></html>'
|
|
return b"<?xml version='1.0'?><ownershipDocument/>"
|
|
|
|
_patch_bytes(monkeypatch, _bytes)
|
|
resp = asyncio.run(sec_router.edgar_document(url=base + "/form.xml"))
|
|
assert resp.media_type == "text/html"
|
|
assert "Rendered" in resp.body.decode()
|
|
|
|
|
|
def test_edgar_document_rendered_no_index_link(monkeypatch):
|
|
"""When the index has no xsl link, the raw text is served."""
|
|
acc = "000123456724000077"
|
|
base = "https://www.sec.gov/Archives/edgar/data/1/" + acc
|
|
|
|
def _bytes(target, **kwargs):
|
|
if target.endswith("-index.html"):
|
|
return b"<html><body>no link</body></html>"
|
|
return b"<?xml version='1.0'?><x/>"
|
|
|
|
_patch_bytes(monkeypatch, _bytes)
|
|
_patch_stream(monkeypatch, [], status_code=404)
|
|
resp = asyncio.run(sec_router.edgar_document(url=base + "/form.xml"))
|
|
assert resp.media_type.startswith("text/plain")
|
|
|
|
|
|
def test_edgar_document_rendered_index_error(monkeypatch):
|
|
"""An index-fetch error falls back to plain text."""
|
|
acc = "000123456724000077"
|
|
base = "https://www.sec.gov/Archives/edgar/data/1/" + acc
|
|
|
|
def _bytes(target, **kwargs):
|
|
if target.endswith("-index.html"):
|
|
raise RuntimeError("boom")
|
|
return b"<?xml version='1.0'?><x/>"
|
|
|
|
_patch_bytes(monkeypatch, _bytes)
|
|
_patch_stream(monkeypatch, [], status_code=404)
|
|
resp = asyncio.run(sec_router.edgar_document(url=base + "/form.xml"))
|
|
assert resp.media_type.startswith("text/plain")
|
|
|
|
|
|
def test_edgar_document_plain_text_short_dir(monkeypatch):
|
|
"""A non-HTML document outside an accession directory is plain text."""
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"plain content")
|
|
resp = asyncio.run(sec_router.edgar_document(url="https://www.sec.gov/dir/x.txt"))
|
|
assert resp.media_type.startswith("text/plain")
|
|
|
|
|
|
class _FakeStreamResponse:
|
|
"""Minimal streaming response stand-in for ``sec_make_request``."""
|
|
|
|
def __init__(self, chunks, status_code=200):
|
|
self._chunks = chunks
|
|
self.status_code = status_code
|
|
|
|
def iter_content(self, size):
|
|
yield from self._chunks
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
|
|
def _patch_stream(monkeypatch, chunks, status_code=200):
|
|
"""Patch the rate-limited streaming fetch used by ``_bounded_get``."""
|
|
|
|
def _fake(target, headers=None, stream=None, timeout=None):
|
|
return _FakeStreamResponse(chunks, status_code)
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.ratelimit.sec_make_request", _fake)
|
|
|
|
|
|
# A short URL whose accession segment is not 18 digits, so ``_rendered_html``
|
|
# returns None immediately and the ``.xml`` branch falls through to the
|
|
# bounded streaming fetch and the structured renderers.
|
|
_XML_URL = "https://www.sec.gov/files/assetdata.xml"
|
|
|
|
|
|
class TestEdgarDocumentXmlRendering:
|
|
"""The ``.xml`` branch: bounded fetch then structured-render fallbacks."""
|
|
|
|
def test_streams_and_renders_structured_xml(self, monkeypatch):
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"")
|
|
asset_xml = (
|
|
b"<assetData><asset><x>1</x></asset><asset><x>2</x></asset></assetData>"
|
|
)
|
|
# The leading empty chunk exercises the ``if piece`` guard.
|
|
_patch_stream(monkeypatch, [b"", asset_xml])
|
|
resp = asyncio.run(sec_router.edgar_document(url=_XML_URL))
|
|
assert resp.media_type == "text/html"
|
|
assert "ob-xml" in resp.body.decode()
|
|
|
|
def test_bounded_read_breaks_at_limit_then_plain_text(self, monkeypatch):
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"")
|
|
# A single chunk at the 5MB cap forces the read loop to break, and the
|
|
# non-XML payload falls through every renderer to plain text.
|
|
_patch_stream(monkeypatch, [b"x" * 5_000_000])
|
|
resp = asyncio.run(sec_router.edgar_document(url=_XML_URL))
|
|
assert resp.media_type.startswith("text/plain")
|
|
|
|
def test_renders_financial_report_for_xbrl(self, monkeypatch):
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"")
|
|
_patch_stream(monkeypatch, [b"<data/>"])
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.xml_render.render_xml_as_html", lambda *a, **k: None
|
|
)
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.xbrl_render._looks_xbrl", lambda chunk: True
|
|
)
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.financial_report.render_financial_report",
|
|
AsyncMock(return_value="<div>REPORT</div>"),
|
|
)
|
|
resp = asyncio.run(sec_router.edgar_document(url=_XML_URL))
|
|
assert resp.media_type == "text/html"
|
|
assert "REPORT" in resp.body.decode()
|
|
|
|
def test_falls_back_to_xbrl_facts_when_no_report(self, monkeypatch):
|
|
_patch_bytes(monkeypatch, lambda t, **k: b"")
|
|
_patch_stream(monkeypatch, [b"<data/>"])
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.xml_render.render_xml_as_html", lambda *a, **k: None
|
|
)
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.xbrl_render._looks_xbrl", lambda chunk: True
|
|
)
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.financial_report.render_financial_report",
|
|
AsyncMock(return_value=None),
|
|
)
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.xbrl_render.render_xbrl_facts",
|
|
lambda *a, **k: "<div>FACTS</div>",
|
|
)
|
|
resp = asyncio.run(sec_router.edgar_document(url=_XML_URL))
|
|
assert resp.media_type == "text/html"
|
|
assert "FACTS" in resp.body.decode()
|
|
|
|
|
|
class TestAssetDataRows:
|
|
"""The AgGrid SSRM ``asset_data_rows`` endpoint."""
|
|
|
|
def _body(self, url):
|
|
return sec_router.SecAssetDataRowsRequest(
|
|
document_url=url, startRow=0, endRow=0
|
|
)
|
|
|
|
def test_empty_url_returns_empty(self):
|
|
resp = asyncio.run(sec_router.asset_data_rows(self._body("")))
|
|
assert resp.rowCount == 0
|
|
assert resp.columns == []
|
|
|
|
def test_non_sec_url_returns_empty(self):
|
|
resp = asyncio.run(sec_router.asset_data_rows(self._body("https://evil.com/x")))
|
|
assert resp.rowData == []
|
|
|
|
def test_load_error_returns_empty(self, monkeypatch):
|
|
def _boom(url):
|
|
raise RuntimeError("x")
|
|
|
|
monkeypatch.setattr("openbb_sec.utils.asset_data.load_asset_data", _boom)
|
|
resp = asyncio.run(sec_router.asset_data_rows(self._body(_XML_URL)))
|
|
assert resp.rowCount == 0
|
|
|
|
def test_no_columns_returns_empty(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.asset_data.load_asset_data", lambda url: ([], [])
|
|
)
|
|
resp = asyncio.run(sec_router.asset_data_rows(self._body(_XML_URL)))
|
|
assert resp.columns == []
|
|
|
|
def test_serves_paged_rows(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
"openbb_sec.utils.asset_data.load_asset_data",
|
|
lambda url: (["a", "b"], [("1", "2"), ("3", "4")]),
|
|
)
|
|
resp = asyncio.run(sec_router.asset_data_rows(self._body(_XML_URL)))
|
|
assert resp.rowCount == 2
|
|
assert resp.columns == ["a", "b"]
|
|
assert resp.rowData == [{"a": "1", "b": "2"}, {"a": "3", "b": "4"}]
|
|
|
|
|
|
def test_mcp_public_url_env_override(monkeypatch):
|
|
"""An explicit ``OPENBB_SEC_MCP_PUBLIC_URL`` is returned verbatim."""
|
|
monkeypatch.setenv("OPENBB_SEC_MCP_PUBLIC_URL", "https://example.com/mcp")
|
|
assert sec_router._mcp_public_url() == "https://example.com/mcp"
|
|
|
|
|
|
def test_mcp_public_url_reads_port_from_argv_space(monkeypatch):
|
|
"""The port is read from a ``--port 1234`` argv pair."""
|
|
monkeypatch.delenv("OPENBB_SEC_MCP_PUBLIC_URL", raising=False)
|
|
monkeypatch.delenv("OPENBB_API_PORT", raising=False)
|
|
monkeypatch.setattr("sys.argv", ["openbb-api", "--port", "1234"])
|
|
assert ":1234" in sec_router._mcp_public_url()
|
|
|
|
|
|
def test_mcp_public_url_reads_port_from_argv_equals(monkeypatch):
|
|
"""The port is read from a ``--port=5678`` argv token."""
|
|
monkeypatch.delenv("OPENBB_SEC_MCP_PUBLIC_URL", raising=False)
|
|
monkeypatch.delenv("OPENBB_API_PORT", raising=False)
|
|
monkeypatch.setattr("sys.argv", ["openbb-api", "--port=5678"])
|
|
assert ":5678" in sec_router._mcp_public_url()
|
|
|
|
|
|
def test_ensure_mcp_subprocess(monkeypatch):
|
|
"""The MCP server is launched once as a guarded subprocess."""
|
|
import openbb_sec.utils.filing_viewer_mcp as mcp
|
|
|
|
calls: dict = {}
|
|
|
|
class _FakeProc:
|
|
def poll(self):
|
|
return None
|
|
|
|
def _fake_popen(cmd, **kwargs):
|
|
calls["cmd"] = cmd
|
|
return _FakeProc()
|
|
|
|
monkeypatch.setattr(mcp, "_mcp_process", None)
|
|
monkeypatch.setattr(mcp, "_port_open", lambda *a: False)
|
|
monkeypatch.setattr("atexit.register", lambda *a, **k: None)
|
|
monkeypatch.setattr("subprocess.Popen", _fake_popen)
|
|
|
|
mcp.ensure_mcp_subprocess()
|
|
assert calls["cmd"][1:3] == ["-m", "openbb_sec.utils.filing_viewer_mcp"]
|
|
|
|
# A live process short-circuits the second call.
|
|
calls.clear()
|
|
mcp.ensure_mcp_subprocess()
|
|
assert "cmd" not in calls
|
|
|
|
# A port already in use also short-circuits, without launching.
|
|
monkeypatch.setattr(mcp, "_mcp_process", None)
|
|
monkeypatch.setattr(mcp, "_port_open", lambda *a: True)
|
|
calls.clear()
|
|
mcp.ensure_mcp_subprocess()
|
|
assert "cmd" not in calls
|