File size: 17,978 Bytes
0feab1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8e6b8bd
 
 
0feab1a
 
 
 
 
 
 
 
 
b4eaf42
 
0feab1a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e610a2f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
"""
Tests for architecture & deployment optimizations:
  #2   In-memory model cache (ephemeral disk fix)
  #6   CORS includes production domains
  #16  Startup warmup event
  #22  line_webhook.py Flex Message builder extraction
  #26  twstock realtime non-trading-hours cache
  #29  MoneyDJ SSL verification with certifi
  #31  render.yaml completeness
  #32  API 404 catch-all guard
"""

import time
from pathlib import Path
from unittest.mock import patch, MagicMock

import pytest


# ── #2 In-memory model cache ──────────────────────────────────────────────────

class TestInMemoryModelCache:
    """Verify the in-memory model cache layer in predictor_service."""

    def test_mem_cache_functions_exist(self):
        from services.predictor_service import (
            _get_mem_cached_model,
            _set_mem_cached_model,
            _evict_oldest_model,
            _model_mem_cache,
            _MEM_CACHE_MAX_SIZE,
        )
        assert callable(_get_mem_cached_model)
        assert callable(_set_mem_cached_model)
        assert callable(_evict_oldest_model)
        assert isinstance(_model_mem_cache, dict)
        assert _MEM_CACHE_MAX_SIZE > 0

    def test_set_and_get_mem_cache(self):
        from services.predictor_service import (
            _model_mem_cache, _set_mem_cached_model, _get_mem_cached_model,
        )
        _model_mem_cache.clear()
        mock_predictor = MagicMock()
        _set_mem_cached_model("TEST_STOCK", "2026-04-01", mock_predictor)
        result = _get_mem_cached_model("TEST_STOCK", "2026-04-01")
        assert result is mock_predictor
        _model_mem_cache.clear()

    def test_mem_cache_miss_on_key_mismatch(self):
        from services.predictor_service import (
            _model_mem_cache, _set_mem_cached_model, _get_mem_cached_model,
        )
        _model_mem_cache.clear()
        mock_predictor = MagicMock()
        _set_mem_cached_model("TEST_STOCK", "2026-04-01", mock_predictor)
        result = _get_mem_cached_model("TEST_STOCK", "2026-04-02")
        assert result is None
        _model_mem_cache.clear()

    def test_mem_cache_miss_on_absent_stock(self):
        from services.predictor_service import _model_mem_cache, _get_mem_cached_model
        _model_mem_cache.clear()
        result = _get_mem_cached_model("NONEXIST", "2026-04-01")
        assert result is None

    def test_eviction_at_max_size(self):
        from services.predictor_service import (
            _model_mem_cache, _set_mem_cached_model, _MEM_CACHE_MAX_SIZE,
        )
        _model_mem_cache.clear()
        # Fill to max
        for i in range(_MEM_CACHE_MAX_SIZE):
            _set_mem_cached_model(f"STOCK_{i}", "key", MagicMock())
            time.sleep(0.001)  # ensure different timestamps
        assert len(_model_mem_cache) == _MEM_CACHE_MAX_SIZE
        # Add one more β€” should evict the oldest
        _set_mem_cached_model("STOCK_NEW", "key", MagicMock())
        assert len(_model_mem_cache) == _MEM_CACHE_MAX_SIZE
        assert "STOCK_0" not in _model_mem_cache
        assert "STOCK_NEW" in _model_mem_cache
        _model_mem_cache.clear()

    def test_load_predictor_checks_mem_cache_first(self):
        """load_predictor should return from memory cache without touching disk."""
        import inspect
        from services.predictor_service import load_predictor
        source = inspect.getsource(load_predictor)
        assert "_get_mem_cached_model" in source
        assert "Memory cache hit" in source


# ── #6 CORS includes production domains ───────────────────────────────────────

class TestCORSConfig:
    """Verify CORS allowed origins include production Render domain."""

    def test_allowed_origins_includes_render(self):
        from main import ALLOWED_ORIGINS
        render_found = any("onrender.com" in o for o in ALLOWED_ORIGINS)
        assert render_found, f"No onrender.com in ALLOWED_ORIGINS: {ALLOWED_ORIGINS}"

    def test_allowed_origins_includes_localhost(self):
        from main import ALLOWED_ORIGINS
        assert "http://localhost:5173" in ALLOWED_ORIGINS

    def test_render_external_url_env_support(self):
        """main.py should read RENDER_EXTERNAL_URL env var."""
        src = Path(__file__).parent.parent / "main.py"
        code = src.read_text(encoding="utf-8")
        assert "RENDER_EXTERNAL_URL" in code


# ── #16 Startup warmup ────────────────────────────────────────────────────────

class TestStartupWarmup:
    """Verify FastAPI startup event for model pre-training."""

    def test_warmup_function_exists(self):
        from main import _warmup_models
        import inspect
        assert inspect.iscoroutinefunction(_warmup_models)

    def test_lifespan_registered(self):
        from main import app
        # FastAPI stores lifespan in app.router.lifespan_context
        assert app.router.lifespan_context is not None, "No lifespan handler registered"

    def test_warmup_stocks_configurable(self):
        """WARMUP_STOCKS should be configurable via env var."""
        src = Path(__file__).parent.parent / "main.py"
        code = src.read_text(encoding="utf-8")
        assert "WARMUP_STOCKS" in code

    def test_warmup_stocks_default_includes_popular(self):
        from main import _WARMUP_STOCKS
        assert "2330" in _WARMUP_STOCKS


# ── #22 line_webhook.py Flex Message builder extraction ───────────────────────

class TestFlexMessageExtraction:
    """Verify Flex Message builder is extracted to services/line_message_builder.py."""

    def test_builder_module_exists(self):
        builder_path = Path(__file__).parent.parent / "services" / "line_message_builder.py"
        assert builder_path.exists(), "services/line_message_builder.py not found"

    def test_builder_has_key_functions(self):
        from services.line_message_builder import (
            build_flex_prediction,
            build_quick_reply_menu,
            build_plain_text_prediction,
            prob_bar,
            FLEX_AVAILABLE,
        )
        assert callable(build_flex_prediction)
        assert callable(build_quick_reply_menu)
        assert callable(build_plain_text_prediction)
        assert callable(prob_bar)

    def test_prob_bar_output(self):
        from services.line_message_builder import prob_bar
        result = prob_bar(0.72)
        assert "72%" in result
        assert "β–ˆ" in result
        assert "β–‘" in result

    def test_prob_bar_boundaries(self):
        from services.line_message_builder import prob_bar
        # 0% β€” all empty
        result_zero = prob_bar(0.0)
        assert "0%" in result_zero
        # 100% β€” all filled
        result_full = prob_bar(1.0)
        assert "100%" in result_full

    def test_plain_text_prediction_output(self):
        from services.line_message_builder import build_plain_text_prediction
        result = build_plain_text_prediction(
            name="台積電", stock_no="2330", exchange="TWSE",
            price_str="NT$ 600.00", signal="BUY", confidence="HIGH",
            buy_prob=0.75, sell_prob=0.25, change_pct=2.5,
            today_change_pct=1.2,
        )
        assert "台積電" in result
        assert "2330" in result
        assert "θ²·ι€²" in result
        assert "+2.50%" in result
        assert "+1.20%" in result

    def test_plain_text_prediction_no_today_change(self):
        from services.line_message_builder import build_plain_text_prediction
        result = build_plain_text_prediction(
            name="Test", stock_no="1234", exchange="",
            price_str="N/A", signal="HOLD", confidence="LOW",
            buy_prob=0.5, sell_prob=0.5, change_pct=0.0,
        )
        assert "今ζ—₯漲跌" not in result
        assert "θ§€ζœ›" in result

    def test_webhook_imports_from_builder(self):
        """line_webhook.py should import from services.line_message_builder."""
        src = Path(__file__).parent.parent / "routers" / "line_webhook.py"
        code = src.read_text(encoding="utf-8")
        assert "from services.line_message_builder import" in code

    def test_webhook_no_inline_flex_builder(self):
        """line_webhook.py should NOT have the old inline _build_flex_prediction."""
        src = Path(__file__).parent.parent / "routers" / "line_webhook.py"
        code = src.read_text(encoding="utf-8")
        # The old function had a docstring mentioning "Flex Message bubble"
        assert "def _prob_bar(" not in code, "Old _prob_bar still in line_webhook.py"

    def test_webhook_line_count_reduced(self):
        """line_webhook.py should be significantly shorter after extraction."""
        src = Path(__file__).parent.parent / "routers" / "line_webhook.py"
        lines = src.read_text(encoding="utf-8").splitlines()
        # Original was 595 lines; after extraction should be under 520
        # (grew from ~420 β†’ ~500 with chart image feature)
        assert len(lines) < 520, f"line_webhook.py still has {len(lines)} lines (expected < 520)"


# ── #26 twstock realtime non-trading-hours cache ──────────────────────────────

class TestRealtimeQuoteCache:
    """Verify stale quote cache for non-trading hours."""

    def test_realtime_cache_exists(self):
        from data.fetcher import _realtime_cache
        from collections.abc import MutableMapping
        assert isinstance(_realtime_cache, MutableMapping)

    def test_cache_and_return_stores_data(self):
        """get_realtime_quote should store results in _realtime_cache."""
        import inspect
        from data.fetcher import get_realtime_quote
        source = inspect.getsource(get_realtime_quote)
        assert "_cache_and_return" in source
        assert "_get_stale" in source

    def test_is_realtime_field_in_source(self):
        """Results should include is_realtime field."""
        import inspect
        from data.fetcher import get_realtime_quote
        source = inspect.getsource(get_realtime_quote)
        assert "is_realtime" in source

    def test_stale_cache_returns_on_empty(self):
        """_get_stale should return empty dict when cache is empty."""
        from data.fetcher import _realtime_cache
        _realtime_cache.clear()
        from data.fetcher import get_realtime_quote
        # Extract _get_stale from the function's closure-free approach
        # We test the concept: no crash on empty cache
        src = Path(__file__).parent.parent / "data" / "fetcher.py"
        code = src.read_text(encoding="utf-8")
        assert 'def _get_stale(key: str)' in code


# ── #29 MoneyDJ SSL verification ─────────────────────────────────────────────

class TestMoneyDJSSL:
    """Verify MoneyDJ uses proper SSL verification with certifi fallback."""

    def test_no_global_verify_false(self):
        """fund_fetcher.py should not have global s.verify = False."""
        src = Path(__file__).parent.parent / "data" / "fund_fetcher.py"
        code = src.read_text(encoding="utf-8")
        # The main _session() should use certifi, not verify=False
        lines = code.splitlines()
        for i, line in enumerate(lines):
            if "def _session():" in line:
                # Check next 10 lines for verify setting
                block = "\n".join(lines[i:i+10])
                assert "certifi.where()" in block, "_session() should use certifi.where()"
                break

    def test_insecure_fallback_exists(self):
        """_session_insecure() should exist as a fallback."""
        from data.fund_fetcher import _session_insecure
        assert callable(_session_insecure)

    def test_certifi_in_requirements(self):
        req = Path(__file__).parent.parent / "requirements.txt"
        text = req.read_text(encoding="utf-8")
        assert "certifi" in text

    def test_certifi_import(self):
        """fund_fetcher should import certifi."""
        src = Path(__file__).parent.parent / "data" / "fund_fetcher.py"
        code = src.read_text(encoding="utf-8")
        assert "import certifi" in code

    def test_no_global_disable_warnings(self):
        """Global urllib3.disable_warnings should be removed (only in _session_insecure)."""
        src = Path(__file__).parent.parent / "data" / "fund_fetcher.py"
        code = src.read_text(encoding="utf-8")
        # Count occurrences β€” should only appear inside _session_insecure
        lines = code.splitlines()
        global_disable = False
        for line in lines:
            stripped = line.strip()
            if stripped == "urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)":
                # Check if it's at module level (no indentation) vs inside a function
                if not line.startswith(" ") and not line.startswith("\t"):
                    global_disable = True
        assert not global_disable, "Global disable_warnings should be removed"


# ── #31 render.yaml completeness ──────────────────────────────────────────────

class TestRenderYaml:
    """Verify render.yaml has all required fields."""

    def test_render_yaml_exists(self):
        path = Path(__file__).parent.parent / "render.yaml"
        assert path.exists()

    def test_render_yaml_has_required_fields(self):
        import yaml
        path = Path(__file__).parent.parent / "render.yaml"
        data = yaml.safe_load(path.read_text(encoding="utf-8"))
        service = data["services"][0]
        assert service.get("healthCheckPath") == "/health"
        assert service.get("region") is not None
        assert "envVars" in service

    def test_render_yaml_env_vars(self):
        import yaml
        path = Path(__file__).parent.parent / "render.yaml"
        data = yaml.safe_load(path.read_text(encoding="utf-8"))
        service = data["services"][0]
        env_keys = {e["key"] for e in service["envVars"]}
        assert "LINE_CHANNEL_SECRET" in env_keys
        assert "LINE_CHANNEL_ACCESS_TOKEN" in env_keys
        assert "WARMUP_STOCKS" in env_keys
        assert "LIGHTWEIGHT_MODE" in env_keys


# ── HF Space local-architecture integration ─────────────────────────────────

class TestHuggingFaceSpaceIntegration:
    """Verify HF Space stays lightweight and uses Mac-precomputed artifacts."""

    def test_dockerfile_disables_slow_hf_enrichments(self):
        dockerfile = Path(__file__).parent.parent / "Dockerfile"
        text = dockerfile.read_text(encoding="utf-8")
        for required in [
            "LIGHTWEIGHT_MODE=1",
            "ENABLE_OPTUNA=0",
            "ENABLE_NEWS_OVERLAY=0",
            "ENABLE_SECURITIES_LENDING=0",
            "ENABLE_BLOCK_TRADE_FEATURES=0",
            "ENABLE_LARGE_HOLDER_FEATURES=0",
            "LOCAL_PRECOMPUTE_FRESH_HOURS=96",
            "WARMUP_STOCKS=\"\"",
        ]:
            assert required in text

    def test_dockerignore_excludes_local_only_runtime_state(self):
        dockerignore = Path(__file__).parent.parent / ".dockerignore"
        text = dockerignore.read_text(encoding="utf-8")
        for required in [
            "logs/",
            "model_cache/",
            ".hf_token",
            "frontend/node_modules/",
            "data/cache/",
            "prompt-optimizer-local/",
        ]:
            assert required in text

    def test_predictor_service_skips_background_ml_on_hf(self):
        src = Path(__file__).parent.parent / "services" / "predictor_service.py"
        text = src.read_text(encoding="utf-8")
        assert "HF free CPU should not train durable ML models" in text
        assert "await asyncio.to_thread(enqueue, stock_no)" in text


# ── #32 API 404 handler ──────────────────────────────────────────────────────

class TestAPI404Handler:
    """Verify /api/* typos return 404 instead of being caught by SPA."""

    def test_api_not_found_route_exists(self):
        from main import app
        routes = [r.path for r in app.routes if hasattr(r, 'path')]
        assert "/api/{rest_of_path:path}" in routes, \
            f"API 404 catch-all route not found. Routes: {routes}"

    def test_api_not_found_returns_404(self):
        from fastapi.testclient import TestClient
        from main import app
        client = TestClient(app, raise_server_exceptions=False)
        resp = client.get("/api/nonexistent/endpoint")
        assert resp.status_code == 404
        data = resp.json()
        assert "detail" in data
        assert "not found" in data["detail"].lower()

    def test_real_api_endpoints_still_work(self):
        """Existing /api/stock/* routes should NOT be intercepted by the 404 handler."""
        from main import app
        routes = [r.path for r in app.routes if hasattr(r, 'path')]
        # Check that real stock API routes exist and are registered BEFORE the catch-all
        assert "/api/stock/search" in routes or any("/api/stock/" in r for r in routes)

    def test_api_404_before_spa_catchall(self):
        """The API 404 handler should be defined BEFORE the SPA catch-all in source."""
        src = Path(__file__).parent.parent / "main.py"
        code = src.read_text(encoding="utf-8")
        api_404_pos = code.find("api_not_found")
        spa_pos = code.find("serve_spa")
        if spa_pos > 0:
            assert api_404_pos < spa_pos, "API 404 handler must appear before SPA catch-all"