""" Overlay API tests — Phase 5 Tests cover: 1. Cache utility unit tests (no DB, no Redis required) 2. Overlay API endpoint integration tests using the real SQLite DB (Redis is not required — graceful degradation is expected) """ import pytest from fastapi.testclient import TestClient from app.main import app from app.utils.cache import build_cache_key, compute_etag, _serialize, _deserialize # --------------------------------------------------------------------------- # Shared test client (uses real stock_oracle.db — no table creation needed) # --------------------------------------------------------------------------- @pytest.fixture(scope="module") def client(): with TestClient(app) as c: yield c # =========================================================================== # 1. Cache utility unit tests # =========================================================================== class TestCacheUtils: def test_build_cache_key_basic(self): key = build_cache_key("overlay:score", "AAPL") assert key == "overlay:score:AAPL" def test_build_cache_key_multiple_parts(self): key = build_cache_key("overlay:bulk", "AAPL,MSFT", "true") assert key == "overlay:bulk:AAPL,MSFT:true" def test_build_cache_key_skips_none(self): key = build_cache_key("overlay:headlines", "TSLA", None) assert key == "overlay:headlines:TSLA" def test_build_cache_key_skips_empty_string(self): key = build_cache_key("overlay:wiki", "AAPL", "") assert key == "overlay:wiki:AAPL" def test_compute_etag_deterministic(self): data = {"symbol": "AAPL", "score": 1.23} b = _serialize(data) assert compute_etag(b) == compute_etag(b) def test_compute_etag_different_data(self): a = _serialize({"a": 1}) b = _serialize({"a": 2}) assert compute_etag(a) != compute_etag(b) def test_compute_etag_is_hex_string(self): b = _serialize({"x": "y"}) etag = compute_etag(b) assert isinstance(etag, str) assert len(etag) == 64 # sha256 hex = 64 chars def test_serialize_deserialize_roundtrip(self): payload = {"symbol": "AAPL", "overlay_score": 0.75, "items": [1, 2, 3]} raw = _serialize(payload) result = _deserialize(raw) assert result == payload def test_deserialize_none_returns_none(self): assert _deserialize(None) is None def test_deserialize_invalid_bytes_returns_none(self): assert _deserialize(b"not-json{{{") is None # =========================================================================== # 2. Redis config fix verification # =========================================================================== class TestRedisConfig: def test_redis_url_uses_port_16380(self): from app.core.config import settings assert "16380" in settings.REDIS_URL, ( f"REDIS_URL should use port 16380 (docker-compose), got: {settings.REDIS_URL}" ) def test_redis_port_default_matches_docker_compose(self): from app.core.config import settings assert settings.REDIS_PORT == 16380 def test_cache_graceful_degradation_no_redis(self): """With no Redis running, get_cached_response returns None without error.""" import asyncio from app.utils.cache import get_cached_response, set_cached_response async def _run(): # set_cached_response should return an etag even without Redis etag = await set_cached_response("test:key", {"a": 1}, ttl_seconds=60) assert isinstance(etag, str) assert len(etag) == 64 # get_cached_response should return None without Redis result = await get_cached_response("test:key") # Either None (no Redis) or a tuple (Redis connected) assert result is None or isinstance(result, tuple) asyncio.run(_run()) # =========================================================================== # 3. Overlay endpoint integration tests # =========================================================================== class TestAdminHealth: def test_returns_200(self, client): r = client.get("/api/v1/overlay/admin/health") assert r.status_code == 200 def test_response_schema(self, client): r = client.get("/api/v1/overlay/admin/health") data = r.json() assert "overlay_enabled" in data assert "sources" in data assert isinstance(data["sources"], list) assert len(data["sources"]) > 0 def test_sources_have_required_fields(self, client): r = client.get("/api/v1/overlay/admin/health") for src in r.json()["sources"]: assert "source" in src assert "status" in src assert "records_24h" in src def test_overlay_is_enabled(self, client): r = client.get("/api/v1/overlay/admin/health") assert r.json()["overlay_enabled"] is True class TestAdminJobLog: def test_returns_200(self, client): r = client.get("/api/v1/overlay/admin/job-log") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/admin/job-log").json() assert "logs" in data assert "total_count" in data assert isinstance(data["logs"], list) def test_limit_param(self, client): r = client.get("/api/v1/overlay/admin/job-log?limit=5") assert r.status_code == 200 assert len(r.json()["logs"]) <= 5 def test_limit_too_large_is_capped(self, client): r = client.get("/api/v1/overlay/admin/job-log?limit=600") assert r.status_code == 422 # exceeds max 500 class TestTopMovers: def test_returns_200(self, client): r = client.get("/api/v1/overlay/top-movers") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/top-movers").json() assert "top_movers" in data assert "total_count" in data assert isinstance(data["top_movers"], list) def test_limit_param(self, client): r = client.get("/api/v1/overlay/top-movers?limit=5") assert r.status_code == 200 assert len(r.json()["top_movers"]) <= 5 def test_limit_below_min_rejected(self, client): r = client.get("/api/v1/overlay/top-movers?limit=0") assert r.status_code == 422 def test_limit_above_max_rejected(self, client): r = client.get("/api/v1/overlay/top-movers?limit=101") assert r.status_code == 422 def test_x_cache_header_present(self, client): r = client.get("/api/v1/overlay/top-movers") # X-Cache should be HIT or MISS (Redis may or may not be running) assert "X-Cache" in r.headers assert r.headers["X-Cache"] in ("HIT", "MISS") class TestBulkOverlay: def test_returns_200_with_known_symbol(self, client): r = client.get("/api/v1/overlay/bulk?symbols=AAPL") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/bulk?symbols=AAPL,MSFT").json() assert "results" in data assert "total_count" in data assert isinstance(data["results"], list) def test_too_many_symbols_rejected(self, client): symbols = ",".join([f"S{i:03d}" for i in range(51)]) r = client.get(f"/api/v1/overlay/bulk?symbols={symbols}") assert r.status_code == 400 assert "50" in r.json()["detail"] def test_empty_symbols_rejected(self, client): r = client.get("/api/v1/overlay/bulk?symbols=,,,") assert r.status_code == 400 def test_symbols_uppercased(self, client): r = client.get("/api/v1/overlay/bulk?symbols=aapl") assert r.status_code == 200 def test_x_cache_header_present(self, client): r = client.get("/api/v1/overlay/bulk?symbols=AAPL") assert "X-Cache" in r.headers class TestHeadlines: def test_returns_200(self, client): r = client.get("/api/v1/overlay/AAPL/headlines") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/AAPL/headlines").json() assert "symbol" in data assert data["symbol"] == "AAPL" assert "headlines" in data assert "headline_count_6h" in data assert "headline_count_24h" in data assert "publisher_breadth_24h" in data def test_hours_param_valid(self, client): r = client.get("/api/v1/overlay/AAPL/headlines?hours=48") assert r.status_code == 200 def test_hours_below_min_rejected(self, client): r = client.get("/api/v1/overlay/AAPL/headlines?hours=0") assert r.status_code == 422 def test_hours_above_max_rejected(self, client): r = client.get("/api/v1/overlay/AAPL/headlines?hours=200") assert r.status_code == 422 def test_symbol_uppercased(self, client): r = client.get("/api/v1/overlay/aapl/headlines") assert r.status_code == 200 assert r.json()["symbol"] == "AAPL" def test_headline_counts_non_negative(self, client): data = client.get("/api/v1/overlay/AAPL/headlines").json() assert data["headline_count_6h"] >= 0 assert data["headline_count_24h"] >= 0 assert data["publisher_breadth_24h"] >= 0 class TestYouTube: def test_returns_200(self, client): r = client.get("/api/v1/overlay/AAPL/youtube") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/AAPL/youtube").json() assert "symbol" in data assert data["symbol"] == "AAPL" assert "videos" in data assert "mentions_24h" in data assert "weighted_views_24h" in data def test_mentions_non_negative(self, client): data = client.get("/api/v1/overlay/AAPL/youtube").json() assert data["mentions_24h"] >= 0 assert data["weighted_views_24h"] >= 0.0 class TestWiki: def test_returns_200(self, client): r = client.get("/api/v1/overlay/AAPL/wiki") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/AAPL/wiki").json() assert "symbol" in data assert data["symbol"] == "AAPL" assert "pageviews" in data assert isinstance(data["pageviews"], list) def test_days_param(self, client): r = client.get("/api/v1/overlay/AAPL/wiki?days=7") assert r.status_code == 200 def test_days_below_min_rejected(self, client): r = client.get("/api/v1/overlay/AAPL/wiki?days=0") assert r.status_code == 422 def test_days_above_max_rejected(self, client): r = client.get("/api/v1/overlay/AAPL/wiki?days=91") assert r.status_code == 422 class TestCrowding: def test_returns_200(self, client): r = client.get("/api/v1/overlay/AAPL/crowding") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/AAPL/crowding").json() assert "symbol" in data assert data["symbol"] == "AAPL" assert "short_volume_ratio" in data assert "crowding_stress_z" in data class TestTrends: def test_returns_200(self, client): r = client.get("/api/v1/overlay/AAPL/trends") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/AAPL/trends").json() assert "symbol" in data assert data["symbol"] == "AAPL" assert "trends" in data assert isinstance(data["trends"], list) def test_no_topics_returns_empty_trends(self, client): # Symbol with no topic mapping → empty trends list data = client.get("/api/v1/overlay/AAPL/trends").json() # Either empty (no mappings) or populated — both are valid assert isinstance(data["trends"], list) class TestHistory: def test_returns_200(self, client): r = client.get("/api/v1/overlay/AAPL/history") assert r.status_code == 200 def test_response_schema(self, client): data = client.get("/api/v1/overlay/AAPL/history").json() assert "symbol" in data assert data["symbol"] == "AAPL" assert "history" in data assert "metadata" in data def test_days_param(self, client): r = client.get("/api/v1/overlay/AAPL/history?days=7") assert r.status_code == 200 def test_days_above_max_rejected(self, client): r = client.get("/api/v1/overlay/AAPL/history?days=366") assert r.status_code == 422 def test_metadata_includes_data_points(self, client): data = client.get("/api/v1/overlay/AAPL/history").json() assert "data_points" in data["metadata"] class TestOverlayScore: def test_unknown_symbol_returns_404_or_200(self, client): # If no data exists for an obscure symbol → 404 # If get_or_build succeeds → 200 r = client.get("/api/v1/overlay/ZZZZZ") assert r.status_code in (200, 404) def test_404_detail_message(self, client): r = client.get("/api/v1/overlay/ZZZZZ") if r.status_code == 404: assert "ZZZZZ" in r.json()["detail"] def test_200_response_schema(self, client): r = client.get("/api/v1/overlay/AAPL") if r.status_code == 200: data = r.json() assert "symbol" in data assert "overlay_score" in data assert "overlay_confidence" in data assert "features" in data assert "source_presence" in data class TestRouteOrdering: """Verify static paths aren't shadowed by /{symbol}.""" def test_bulk_not_treated_as_symbol(self, client): r = client.get("/api/v1/overlay/bulk?symbols=AAPL") assert r.status_code in (200, 400) # Not 404 from symbol route def test_top_movers_not_treated_as_symbol(self, client): r = client.get("/api/v1/overlay/top-movers") assert r.status_code == 200 def test_admin_health_not_treated_as_symbol(self, client): r = client.get("/api/v1/overlay/admin/health") assert r.status_code == 200