"""하드닝 R4: /health 가 스윙·MDD·적응형·체결보정 상태를 degraded 로 반영한다.""" import pytest from app import main as m class _NoDbEngine: """실 DB 접속 금지 — 예외 발생형(§4.1). /health 는 DB 실패를 degraded 로 처리한다.""" def connect(self): raise RuntimeError("test: real DB access blocked") @pytest.fixture(autouse=True) def _block_db_and_redis(monkeypatch): monkeypatch.setattr(m, "engine", _NoDbEngine()) from app.core import redis as _redis monkeypatch.setattr(_redis, "get_redis", lambda: None) def _patch_getters(monkeypatch, swing=None, mdd=None, age=0.0, fs=None): from app.services.swing import scheduler as sch, fill_sweep as fsw from app.services import adaptive_strategy as adp monkeypatch.setattr(sch, "get_swing_scheduler_health_v2", lambda: swing or {"degraded_reasons": []}) monkeypatch.setattr(sch, "get_mdd_update_health", lambda: mdd or {"blind_block_active": False}) monkeypatch.setattr(adp, "get_last_success_age_sec_v2", lambda: age) monkeypatch.setattr(fsw, "get_fill_sweep_health_v2", lambda: fs or {"exhausted": 0}) def test_clean(monkeypatch): _patch_getters(monkeypatch) _, reasons = m._runtime_health_v2(market_open=True) assert reasons == [] def test_swing_reasons_and_blind_block(monkeypatch): _patch_getters(monkeypatch, swing={"degraded_reasons": ["stuck_steps", "exit_stale"]}, mdd={"blind_block_active": True}) _, reasons = m._runtime_health_v2(market_open=True) assert {"stuck_steps", "exit_stale", "mdd_blind_block"} <= set(reasons) def test_adaptive_stale_only_in_market(monkeypatch): from app.services import adaptive_strategy as adp _patch_getters(monkeypatch, age=adp._STALE_LIMIT_SEC + 1) assert "adaptive_stale" in m._runtime_health_v2(market_open=True)[1] assert "adaptive_stale" not in m._runtime_health_v2(market_open=False)[1] def test_getter_exception_is_degraded_not_crash(monkeypatch): from app.services.swing import scheduler as sch def boom(): raise RuntimeError("x") _patch_getters(monkeypatch) monkeypatch.setattr(sch, "get_swing_scheduler_health_v2", boom) _, reasons = m._runtime_health_v2(market_open=True) assert "swing_health_unavailable" in reasons @pytest.mark.asyncio async def test_health_impl_degraded_on_dlq_manual_and_reasons(monkeypatch): from app.services import db_worker as w _patch_getters(monkeypatch, mdd={"blind_block_active": True}) async def dlq(): return {"pending": 0, "manual": 2, "expired": 0} monkeypatch.setattr(w, "dlq_status_counts", dlq) monkeypatch.setattr(m, "is_market_open", lambda: True) out = await m._health_check_impl() assert out["status"] == "degraded" assert "mdd_blind_block" in out["degraded_reasons"] and "dlq_manual" in out["degraded_reasons"] def test_detail_endpoint_requires_api_key(): route = [r for r in m.app.routes if getattr(r, "path", "") == "/health/detail"][0] assert route.dependant.dependencies @pytest.mark.asyncio async def test_health_impl_never_touches_real_engine(monkeypatch): """회귀: 실 engine 대신 차단 목이 쓰이고 db 실패는 degraded 로 귀결된다.""" from app.services import db_worker as w _patch_getters(monkeypatch) async def dlq(): return {"pending": 0, "manual": 0, "expired": 0} monkeypatch.setattr(w, "dlq_status_counts", dlq) monkeypatch.setattr(m, "is_market_open", lambda: True) out = await m._health_check_impl() assert out["status"] == "degraded" def _patch_extra(monkeypatch, wh=None, bh=None, kh=None, rh=None): from app.services import db_worker as w, schedulers as sc, market_analyzer as ma monkeypatch.setattr(w, "get_worker_health_v2", lambda: wh or {"qsize": 0, "circuit_open": False, "last_success_ts": None}) monkeypatch.setattr(sc, "get_batch_analysis_health_v2", lambda: bh or {"consecutive_failed_cycles": 0}) monkeypatch.setattr(sc, "get_krx_data_health_v2", lambda: kh or {"ok": None}) monkeypatch.setattr(ma, "get_regime_source_health", lambda: rh or {"blocked": False}) def test_db_circuit_and_write_stall(monkeypatch): import time _patch_getters(monkeypatch) _patch_extra(monkeypatch, wh={"qsize": 3, "circuit_open": True, "last_success_ts": time.time() - m._DB_WRITE_STALL_SEC - 10}) _, reasons = m._runtime_health_v2(market_open=True) assert {"db_circuit_open", "db_write_stalled"} <= set(reasons) def test_idle_queue_not_stalled(monkeypatch): import time _patch_getters(monkeypatch) _patch_extra(monkeypatch, wh={"qsize": 0, "circuit_open": False, "last_success_ts": time.time() - 99999}) assert m._runtime_health_v2(market_open=True)[1] == [] def test_batch_krx_regime_reasons(monkeypatch): _patch_getters(monkeypatch) _patch_extra(monkeypatch, bh={"consecutive_failed_cycles": m._BATCH_FAIL_CYCLES_DEGRADED}, kh={"ok": False}, rh={"blocked": True}) _, reasons = m._runtime_health_v2(market_open=True) assert {"batch_analysis_failing", "krx_data_incomplete", "regime_blocked"} <= set(reasons) assert "regime_blocked" not in m._runtime_health_v2(market_open=False)[1] def test_new_getter_exception_degraded(monkeypatch): from app.services import db_worker as w _patch_getters(monkeypatch) _patch_extra(monkeypatch) def boom(): raise RuntimeError("x") monkeypatch.setattr(w, "get_worker_health_v2", boom) assert "db_worker_health_unavailable" in m._runtime_health_v2(market_open=True)[1]