Spaces:
Running
Running
File size: 12,169 Bytes
0feab1a 20fef51 0feab1a 20fef51 0feab1a 20fef51 0feab1a 20fef51 0feab1a 20fef51 0feab1a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 | """
Tests for performance optimization fixes:
#1 datetime.utcnow() deprecation fix
#3 Cache maxsize (TTLCache)
#4 History endpoint shares indicator cache
#5 APP_VERSION constant
#17 stock_history async (asyncio.to_thread)
#27 yfinance exchange mapping via twstock.codes
#30 Dockerfile dual workers
"""
import time
from datetime import datetime, timezone
from pathlib import Path
from unittest.mock import patch, MagicMock
import pytest
# ββ #1 datetime.utcnow() deprecation ββββββββββββββββββββββββββββββββββββββββ
class TestDatetimeUTCNow:
"""Verify no usage of deprecated datetime.utcnow()."""
def test_main_uses_timezone_utc(self):
"""main.py health endpoint should use datetime.now(timezone.utc)."""
src = Path(__file__).parent.parent / "main.py"
code = src.read_text(encoding="utf-8")
assert "datetime.utcnow()" not in code, "main.py still uses deprecated utcnow()"
assert "datetime.now(timezone.utc)" in code
def test_predictor_service_uses_timezone_utc(self):
"""predictor_service.py should use datetime.now(timezone.utc)."""
src = Path(__file__).parent.parent / "services" / "predictor_service.py"
code = src.read_text(encoding="utf-8")
assert "datetime.utcnow()" not in code, "predictor_service still uses utcnow()"
assert "datetime.now(timezone.utc)" in code
# ββ #3 Cache maxsize (TTLCache) ββββββββββββββββββββββββββββββββββββββββββββββ
class TestCacheMaxsize:
"""Verify all four caches use TTLCache with maxsize."""
def test_stock_router_info_cache_is_ttlcache(self):
from routers.stock import _info_cache
from cachetools import TTLCache
assert isinstance(_info_cache, TTLCache)
assert _info_cache.maxsize == 200
def test_stock_router_quote_cache_is_ttlcache(self):
from routers.stock import _quote_cache
from cachetools import TTLCache
assert isinstance(_quote_cache, TTLCache)
assert _quote_cache.maxsize == 200
def test_predictor_service_pred_cache_is_ttlcache(self):
from services.predictor_service import _pred_cache
from cachetools import TTLCache
assert isinstance(_pred_cache, TTLCache)
assert _pred_cache.maxsize == 100
def test_predictor_service_indicator_cache_is_ttlcache(self):
from services.predictor_service import _indicator_cache
from cachetools import TTLCache
assert isinstance(_indicator_cache, TTLCache)
assert _indicator_cache.maxsize == 100
def test_ttlcache_evicts_on_maxsize(self):
"""TTLCache should evict oldest entries when maxsize is reached."""
from cachetools import TTLCache
cache = TTLCache(maxsize=3, ttl=60)
cache["a"] = 1
cache["b"] = 2
cache["c"] = 3
cache["d"] = 4 # should evict "a"
assert "a" not in cache
assert len(cache) == 3
def test_requirements_has_cachetools(self):
"""requirements.txt should include cachetools."""
req = Path(__file__).parent.parent / "requirements.txt"
text = req.read_text(encoding="utf-8")
assert "cachetools" in text
# ββ #4 History endpoint shares indicator cache βββββββββββββββββββββββββββββββ
class TestHistorySharesCache:
"""Verify history endpoint imports and uses predictor_service indicator cache."""
def test_stock_router_imports_indicator_cache_functions(self):
"""routers/stock.py should import _get_cached_indicators from predictor_service."""
from routers import stock
assert hasattr(stock, '_get_cached_indicators')
assert hasattr(stock, '_set_cached_indicators')
assert hasattr(stock, '_data_key')
def test_fetch_history_sync_uses_shared_cache(self):
"""_fetch_history_sync should call _get_cached_indicators."""
import inspect
from routers.stock import _fetch_history_sync
source = inspect.getsource(_fetch_history_sync)
assert "_get_cached_indicators" in source
assert "_set_cached_indicators" in source
# ββ #5 APP_VERSION constant ββββββββββββββββββββββββββββββββββββββββββββββββββ
class TestAppVersion:
"""Verify version is a single constant, not duplicated strings."""
def test_app_version_constant_exists(self):
from main import APP_VERSION
assert isinstance(APP_VERSION, str)
assert APP_VERSION == "1.1.0"
def test_no_hardcoded_version_strings(self):
"""main.py should not have any leftover hardcoded version strings."""
src = Path(__file__).parent.parent / "main.py"
code = src.read_text(encoding="utf-8")
# Should not have standalone string "1.0.0" or "1.1.0" outside APP_VERSION
lines = code.splitlines()
for line in lines:
stripped = line.strip()
if stripped.startswith("APP_VERSION"):
continue
assert 'version="1.0.0"' not in stripped, f"Hardcoded 1.0.0: {stripped}"
assert 'version": "1.0.0"' not in stripped, f"Hardcoded 1.0.0: {stripped}"
assert '"version": "1.1.0"' not in stripped, f"Hardcoded 1.1.0: {stripped}"
def test_health_uses_app_version(self):
"""Health endpoint should reference APP_VERSION, not a string literal."""
src = Path(__file__).parent.parent / "main.py"
code = src.read_text(encoding="utf-8")
assert '"version": APP_VERSION' in code or "'version': APP_VERSION" in code
# ββ #17 stock_history async (asyncio.to_thread) βββββββββββββββββββββββββββββ
class TestHistoryAsync:
"""Verify history endpoint uses asyncio.to_thread."""
def test_stock_history_uses_to_thread(self):
"""stock_history should wrap sync work in asyncio.to_thread."""
import inspect
from routers.stock import stock_history
source = inspect.getsource(stock_history)
assert "asyncio.to_thread" in source, "stock_history should use asyncio.to_thread"
def test_fetch_history_sync_is_sync_function(self):
"""_fetch_history_sync should be a regular (non-async) function."""
import asyncio
from routers.stock import _fetch_history_sync
assert not asyncio.iscoroutinefunction(_fetch_history_sync)
# ββ #27 yfinance exchange mapping via twstock.codes ββββββββββββββββββββββββββ
class TestExchangeMapping:
"""Verify detect_exchange uses twstock.codes for accurate lookup."""
def test_detect_exchange_with_suffix(self):
from data.fetcher import detect_exchange
assert detect_exchange("2330.TW") == "TWSE"
assert detect_exchange("7856.TWO") == "TPEX"
def test_detect_exchange_uses_twstock_codes(self):
"""detect_exchange should use _build_exchange_map for bare codes."""
from data.fetcher import detect_exchange, _build_exchange_map
# Mock the exchange map to include a known TPEX stock
with patch("data.fetcher._exchange_map", {"4169": "TPEX", "2330": "TWSE"}):
assert detect_exchange("4169") == "TPEX"
assert detect_exchange("2330") == "TWSE"
def test_build_exchange_map_returns_dict(self):
"""_build_exchange_map should return a dict."""
from data.fetcher import _build_exchange_map
# Reset cache for test
import data.fetcher as fetcher_mod
old_map = fetcher_mod._exchange_map
fetcher_mod._exchange_map = None
try:
result = _build_exchange_map()
assert isinstance(result, dict)
finally:
fetcher_mod._exchange_map = old_map
def test_detect_exchange_fallback_heuristic(self):
"""Unknown stocks should still use the heuristic fallback."""
from data.fetcher import detect_exchange
with patch("data.fetcher._exchange_map", {}):
# 5-digit β TPEX
assert detect_exchange("12345") == "TPEX"
# 4-digit 6xxx-9xxx β TPEX
assert detect_exchange("6000") == "TPEX"
# 4-digit < 6000 β TWSE
assert detect_exchange("1234") == "TWSE"
# ββ #30 Dockerfile worker count βββββββββββββββββββββββββββββββββββββββββββββββ
class TestDockerfileWorkers:
"""Verify Dockerfile uses a Hugging Face free-plan friendly worker count."""
def test_dockerfile_has_single_worker(self):
dockerfile = Path(__file__).parent.parent / "Dockerfile"
text = dockerfile.read_text(encoding="utf-8")
assert "--workers" in text, "Dockerfile should specify --workers"
# Check the final CMD line (not HEALTHCHECK CMD) has one worker.
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("CMD") and "uvicorn" in stripped:
assert '"--workers"' in stripped and '"1"' in stripped, \
f"CMD should have --workers 1, got: {stripped}"
break
else:
pytest.fail("No uvicorn CMD line found in Dockerfile")
# ββ Predictor service cache integration ββββββββββββββββββββββββββββββββββββββ
class TestPredictorServiceCache:
"""Test TTLCache-based prediction and indicator caching."""
def test_pred_cache_set_get(self):
from services.predictor_service import (
_pred_cache, _set_cached_prediction, _get_cached_prediction,
)
_pred_cache.clear()
_set_cached_prediction("TEST_STOCK", {"signal": "BUY"})
result = _get_cached_prediction("TEST_STOCK")
assert result is not None
assert result["signal"] == "BUY"
_pred_cache.clear()
def test_pred_cache_miss(self):
from services.predictor_service import _pred_cache, _get_cached_prediction
_pred_cache.clear()
assert _get_cached_prediction("NONEXIST") is None
def test_indicator_cache_set_get(self):
from services.predictor_service import (
_indicator_cache, _set_cached_indicators, _get_cached_indicators,
)
_indicator_cache.clear()
_set_cached_indicators("TEST", "2026-04-01", "fake_df")
result = _get_cached_indicators("TEST", "2026-04-01")
assert result == "fake_df"
_indicator_cache.clear()
def test_indicator_cache_invalidates_on_key_change(self):
from services.predictor_service import (
_indicator_cache, _set_cached_indicators, _get_cached_indicators,
)
_indicator_cache.clear()
_set_cached_indicators("TEST", "2026-04-01", "old_df")
result = _get_cached_indicators("TEST", "2026-04-02")
assert result is None
_indicator_cache.clear()
# ββ Stock router TTLCache integration ββββββββββββββββββββββββββββββββββββββββ
class TestStockRouterCache:
"""Test TTLCache-based info and quote caching in stock router."""
def test_info_cache_stores_and_retrieves(self):
from routers.stock import _info_cache
_info_cache.clear()
_info_cache["2330"] = {"name": "TSMC", "current_price": 600}
assert "2330" in _info_cache
assert _info_cache["2330"]["name"] == "TSMC"
_info_cache.clear()
def test_quote_cache_stores_and_retrieves(self):
from routers.stock import _quote_cache
_quote_cache.clear()
_quote_cache["2330"] = {"price": 600, "change": 5}
assert "2330" in _quote_cache
assert _quote_cache["2330"]["price"] == 600
_quote_cache.clear()
|