278 lines
9.6 KiB
Python
278 lines
9.6 KiB
Python
"""Tests for /api/ticker/validate and /api/ticker/historical."""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from io import BytesIO
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
|
|
def _build_session_factory(tmp_path):
|
|
"""Spin up a fresh in-memory schema and return (engine, factory, setup).
|
|
Mirrors tests/test_llm_csv_parser.py / tests/test_referral_conversion.py."""
|
|
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
|
|
|
|
from app import db as db_mod
|
|
from app.db import Base
|
|
import app.models # noqa: F401
|
|
|
|
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path}/tv.db")
|
|
factory = async_sessionmaker(engine, expire_on_commit=False)
|
|
db_mod._engine = engine
|
|
db_mod._session_factory = factory
|
|
|
|
async def _setup():
|
|
async with engine.begin() as conn:
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
return engine, factory, _setup
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_happy_path(tmp_path, monkeypatch):
|
|
from app.routers.ticker_validate import validate_ticker
|
|
from app.services.market import Quote
|
|
import app.routers.ticker_validate as mod
|
|
|
|
_, factory, setup = _build_session_factory(tmp_path)
|
|
await setup()
|
|
|
|
# Mock fetch_yahoo to return a successful quote.
|
|
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
|
return Quote(
|
|
symbol=symbol, source="yahoo", label=label, note=note,
|
|
price=172.40, currency="USD", as_of="2026-05-27", changes={},
|
|
)
|
|
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
|
|
|
|
# Avoid the MySQL-only upsert on SQLite.
|
|
async def _fake_upsert(session, tickers):
|
|
return len(list(tickers))
|
|
monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)
|
|
|
|
async with factory() as session:
|
|
result = await validate_ticker(symbol="aapl", session=session)
|
|
|
|
assert result["ok"] is True
|
|
assert result["symbol"] == "AAPL"
|
|
assert result["price"] == 172.40
|
|
assert result["currency"] == "USD"
|
|
assert result["as_of"] == "2026-05-27"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_unknown_symbol(tmp_path, monkeypatch):
|
|
from app.routers.ticker_validate import validate_ticker
|
|
from app.services.market import Quote
|
|
import app.routers.ticker_validate as mod
|
|
|
|
_, factory, setup = _build_session_factory(tmp_path)
|
|
await setup()
|
|
|
|
# Mock fetch_yahoo to return a Quote with error and no price.
|
|
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
|
return Quote(symbol=symbol, source="yahoo", label=label, note=note,
|
|
price=None, currency=None, as_of=None,
|
|
error="empty result")
|
|
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
|
|
|
|
async with factory() as session:
|
|
result = await validate_ticker(symbol="XYZNOTREAL", session=session)
|
|
|
|
assert result["ok"] is False
|
|
assert "not recognised" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_empty_symbol_rejects():
|
|
from app.routers.ticker_validate import validate_ticker
|
|
|
|
# Direct call — no session needed because we short-circuit before any DB use.
|
|
result = await validate_ticker(symbol=" ", session=None)
|
|
assert result["ok"] is False
|
|
assert "required" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_validate_seeds_universe_and_quote(tmp_path, monkeypatch):
|
|
"""Side-effect check: on success, the symbol is upserted into the
|
|
universe and a Quote row is written."""
|
|
from sqlalchemy import select
|
|
|
|
from app.models import Quote as QuoteModel
|
|
from app.routers.ticker_validate import validate_ticker
|
|
from app.services.market import Quote
|
|
import app.routers.ticker_validate as mod
|
|
|
|
_, factory, setup = _build_session_factory(tmp_path)
|
|
await setup()
|
|
|
|
upsert_calls: list[list[str]] = []
|
|
|
|
async def _fake_yahoo(client, symbol, label, note, anchor=None):
|
|
return Quote(symbol=symbol, source="yahoo", label=label, note=note,
|
|
price=100.0, currency="USD", as_of="2026-05-27", changes={})
|
|
monkeypatch.setattr(mod, "fetch_yahoo", _fake_yahoo)
|
|
|
|
async def _fake_upsert(session, tickers):
|
|
upsert_calls.append(list(tickers))
|
|
return len(list(tickers))
|
|
monkeypatch.setattr(mod, "upsert_tickers", _fake_upsert)
|
|
|
|
async with factory() as session:
|
|
result = await validate_ticker(symbol="MSFT", session=session)
|
|
|
|
assert result["ok"] is True
|
|
assert upsert_calls == [["MSFT"]]
|
|
|
|
# Quote row was written.
|
|
async with factory() as session:
|
|
rows = (await session.execute(
|
|
select(QuoteModel).where(QuoteModel.symbol == "MSFT")
|
|
)).scalars().all()
|
|
assert len(rows) == 1
|
|
assert rows[0].price == 100.0
|
|
assert rows[0].currency == "USD"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_happy_path(monkeypatch):
|
|
from app.routers.ticker_validate import get_historical
|
|
import app.routers.ticker_validate as mod
|
|
|
|
async def _fake_hist(client, symbol, target_iso):
|
|
# close, currency, actual_iso
|
|
return 185.92, "USD", "2024-01-12"
|
|
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
|
|
|
|
result = await get_historical(symbol="aapl", date="2024-01-15")
|
|
|
|
assert result["ok"] is True
|
|
assert result["close"] == 185.92
|
|
assert result["currency"] == "USD"
|
|
# 2024-01-15 was a Monday — but our fake says it walked back to Jan 12 (Fri).
|
|
assert result["actual_date"] == "2024-01-12"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_future_date_rejected():
|
|
from fastapi import HTTPException
|
|
from app.routers.ticker_validate import get_historical
|
|
|
|
future = "2099-01-01"
|
|
with pytest.raises(HTTPException) as exc:
|
|
await get_historical(symbol="AAPL", date=future)
|
|
assert exc.value.status_code == 400
|
|
assert "future" in str(exc.value.detail).lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_bad_date_format_rejected():
|
|
from fastapi import HTTPException
|
|
from app.routers.ticker_validate import get_historical
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await get_historical(symbol="AAPL", date="not-a-date")
|
|
assert exc.value.status_code == 400
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_no_data(monkeypatch):
|
|
from app.routers.ticker_validate import get_historical
|
|
import app.routers.ticker_validate as mod
|
|
|
|
async def _fake_hist(client, symbol, target_iso):
|
|
return None, None, None
|
|
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
|
|
|
|
result = await get_historical(symbol="ZZZNEW", date="2020-01-15")
|
|
assert result["ok"] is False
|
|
assert "no data" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_historical_provider_failure(monkeypatch):
|
|
import httpx
|
|
|
|
from app.routers.ticker_validate import get_historical
|
|
import app.routers.ticker_validate as mod
|
|
|
|
async def _fake_hist(client, symbol, target_iso):
|
|
raise httpx.RequestError("connection failed")
|
|
monkeypatch.setattr(mod, "fetch_yahoo_historical", _fake_hist)
|
|
|
|
result = await get_historical(symbol="AAPL", date="2024-01-15")
|
|
assert result["ok"] is False
|
|
assert "couldn" in result["error"].lower() or "fetch" in result["error"].lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_yahoo_historical_walks_back_to_preceding_trading_day(monkeypatch):
|
|
"""Unit test for the helper itself: feed a hand-crafted series with a
|
|
weekend gap, ask for the Saturday close, expect Friday's close."""
|
|
from app.routers.ticker_validate import fetch_yahoo_historical
|
|
|
|
# Build a fake httpx client that returns a chart payload with a
|
|
# Thu-Fri-Mon-Tue series; we ask for Saturday and expect Friday.
|
|
thu_ts = int(datetime(2024, 1, 11, tzinfo=timezone.utc).timestamp())
|
|
fri_ts = int(datetime(2024, 1, 12, tzinfo=timezone.utc).timestamp())
|
|
mon_ts = int(datetime(2024, 1, 15, tzinfo=timezone.utc).timestamp())
|
|
payload = {
|
|
"chart": {"result": [{
|
|
"meta": {"currency": "USD"},
|
|
"timestamp": [thu_ts, fri_ts, mon_ts],
|
|
"indicators": {"quote": [{"close": [184.0, 185.92, 186.10]}]},
|
|
}]}
|
|
}
|
|
|
|
class _FakeResponse:
|
|
def __init__(self, data): self._data = data
|
|
def json(self): return self._data
|
|
def raise_for_status(self): pass
|
|
|
|
class _FakeClient:
|
|
async def get(self, *args, **kwargs):
|
|
return _FakeResponse(payload)
|
|
|
|
close, currency, actual = await fetch_yahoo_historical(
|
|
_FakeClient(), "AAPL", "2024-01-13", # a Saturday
|
|
)
|
|
assert close == 185.92
|
|
assert currency == "USD"
|
|
assert actual == "2024-01-12"
|
|
|
|
|
|
def test_validate_route_requires_paid():
|
|
"""Static check that the /ticker/validate route is gated by require_paid."""
|
|
from app.routers.ticker_validate import router
|
|
from app.services.access import require_paid
|
|
|
|
route = next(
|
|
r for r in router.routes
|
|
if getattr(r, "path", "") == "/ticker/validate"
|
|
)
|
|
dep_callables = [d.call for d in route.dependant.dependencies]
|
|
assert require_paid in dep_callables
|
|
|
|
|
|
def test_historical_route_requires_paid():
|
|
from app.routers.ticker_validate import router
|
|
from app.services.access import require_paid
|
|
|
|
route = next(
|
|
r for r in router.routes
|
|
if getattr(r, "path", "") == "/ticker/historical"
|
|
)
|
|
dep_callables = [d.call for d in route.dependant.dependencies]
|
|
assert require_paid in dep_callables
|
|
|
|
|
|
def test_routes_mounted_under_api_prefix():
|
|
"""Confirm the router is mounted on the FastAPI app under /api."""
|
|
from app.main import app
|
|
|
|
paths = {getattr(r, "path", "") for r in app.routes}
|
|
assert "/api/ticker/validate" in paths
|
|
assert "/api/ticker/historical" in paths
|