151 lines
5.4 KiB
Python
151 lines
5.4 KiB
Python
"""
|
|
Network integration tests — requires internet access and live API.
|
|
NOT run automatically on every edit (slow, rate-limited).
|
|
|
|
Run manually before pushing an update:
|
|
python -m pytest tests/test_network.py -v
|
|
|
|
Or run with the fast tests excluded:
|
|
python -m pytest tests/ -v -m "not network"
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
import pytest
|
|
import requests
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "dist"))
|
|
|
|
API_BASE = "https://api.verimundsolutions.com"
|
|
EDGAR_BASE = "https://data.sec.gov"
|
|
EDGAR_UA = {"User-Agent": "Verimund Solutions support@verimundsolutions.com"}
|
|
|
|
pytestmark = pytest.mark.network
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# API health checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestAPIHealth:
|
|
def test_update_endpoint_alive(self):
|
|
"""GET /update should respond (not timeout, not 500)."""
|
|
r = requests.get(f"{API_BASE}/update", timeout=10)
|
|
assert r.status_code != 500, f"/update returned 500: {r.text}"
|
|
assert r.status_code in (200, 400, 401, 422), (
|
|
f"Unexpected status from /update: {r.status_code}"
|
|
)
|
|
|
|
def test_versions_endpoint_alive(self):
|
|
"""GET /admin/versions returns 401 without auth (not 500 or timeout)."""
|
|
r = requests.get(f"{API_BASE}/admin/versions", timeout=10)
|
|
assert r.status_code in (200, 401, 403), (
|
|
f"/admin/versions returned unexpected status: {r.status_code}"
|
|
)
|
|
|
|
def test_auth_endpoint_rejects_bad_request(self):
|
|
"""POST /auth with garbage data returns 400/422, not 500."""
|
|
r = requests.post(
|
|
f"{API_BASE}/auth",
|
|
json={"license_key": "bad", "hwid": "bad", "timestamp": "0", "signature": "bad"},
|
|
timeout=10,
|
|
)
|
|
assert r.status_code in (400, 401, 422), (
|
|
f"/auth returned unexpected status: {r.status_code}"
|
|
)
|
|
|
|
def test_no_500_errors_on_any_public_endpoint(self):
|
|
endpoints = ["/update", "/admin/versions"]
|
|
for ep in endpoints:
|
|
r = requests.get(f"{API_BASE}{ep}", timeout=10)
|
|
assert r.status_code != 500, f"{ep} returned 500: {r.text}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# yfinance field checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestYFinanceFields:
|
|
"""
|
|
Verify that yfinance still returns the fields our scoring depends on.
|
|
Uses AAPL as a known-stable ticker.
|
|
If these fail, yfinance has renamed a field we use — scoring will silently
|
|
return neutral 50 for affected metrics.
|
|
"""
|
|
|
|
@pytest.fixture(scope="class")
|
|
def aapl_info(self):
|
|
import yfinance as yf
|
|
return yf.Ticker("AAPL").info
|
|
|
|
REQUIRED_FIELDS = [
|
|
"trailingPE",
|
|
"forwardPE",
|
|
"priceToBook",
|
|
"returnOnAssets",
|
|
"returnOnEquity",
|
|
"currentRatio",
|
|
"debtToEquity",
|
|
"revenueGrowth",
|
|
"earningsGrowth",
|
|
"shortPercentOfFloat",
|
|
"recommendationMean",
|
|
"targetMeanPrice",
|
|
"currentPrice",
|
|
"marketCap",
|
|
]
|
|
|
|
def test_required_fields_present(self, aapl_info):
|
|
missing = [f for f in self.REQUIRED_FIELDS if f not in aapl_info]
|
|
assert not missing, (
|
|
f"yfinance dropped fields we depend on: {missing}\n"
|
|
f"Update scoring functions or field mappings."
|
|
)
|
|
|
|
def test_de_is_percentage_scale(self, aapl_info):
|
|
"""
|
|
yfinance returns debtToEquity as percentage (e.g. 150 not 1.5).
|
|
Our code divides by 100. If this breaks, D/E scoring silently becomes wrong.
|
|
"""
|
|
de = aapl_info.get("debtToEquity")
|
|
if de is not None:
|
|
assert de > 1.0, (
|
|
f"debtToEquity={de} looks like a ratio, not percentage. "
|
|
f"Remove the /100.0 normalization in stock_screener.py line ~1356."
|
|
)
|
|
|
|
def test_price_history_available(self):
|
|
import yfinance as yf
|
|
hist = yf.download("AAPL", period="1mo", progress=False, auto_adjust=True)
|
|
assert len(hist) > 10, "yfinance price history returned fewer than 10 bars for AAPL"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# EDGAR API checks
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestEDGARAPI:
|
|
def test_frames_endpoint_alive(self):
|
|
"""EDGAR XBRL frames API returns valid data for a known concept/period."""
|
|
r = requests.get(
|
|
f"{EDGAR_BASE}/api/xbrl/frames/us-gaap/Revenues/USD/CY2023Q4.json",
|
|
headers=EDGAR_UA,
|
|
timeout=20,
|
|
)
|
|
assert r.status_code == 200, f"EDGAR frames API returned {r.status_code}"
|
|
data = r.json()
|
|
assert "data" in data, "EDGAR response missing 'data' key"
|
|
assert len(data["data"]) > 100, "EDGAR returned suspiciously few companies"
|
|
|
|
def test_company_facts_endpoint_alive(self):
|
|
"""EDGAR company facts for Apple (CIK 320193) returns valid data."""
|
|
r = requests.get(
|
|
f"{EDGAR_BASE}/api/xbrl/companyfacts/CIK0000320193.json",
|
|
headers=EDGAR_UA,
|
|
timeout=20,
|
|
)
|
|
assert r.status_code == 200, f"EDGAR company facts returned {r.status_code}"
|
|
data = r.json()
|
|
assert "facts" in data
|
|
assert "us-gaap" in data["facts"]
|