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()