"""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