DockerSpace / tests /test_arch_deploy.py
DennisChan0909's picture
feat: integrate local architecture with HF Space
e610a2f
Raw
History Blame Contribute Delete
18 kB
"""
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"