"""loss_tracker 기동 복원 실패 fail-closed 회귀 (밴 소실 방지).""" from datetime import datetime, timedelta, timezone from types import SimpleNamespace from unittest.mock import AsyncMock, patch import pytest from app.core.config import settings import app.services.loss_tracker as lt from app.services.loss_tracker import ConsecutiveLossTracker def _t(): return ConsecutiveLossTracker( ban_count=settings.CONSECUTIVE_LOSS_BAN_COUNT, ban_calendar_days=settings.CONSECUTIVE_LOSS_BAN_DAYS, ) class _FailDB: def __init__(self): self.calls = 0 self.rollbacks = 0 async def execute(self, *a, **k): self.calls += 1 raise ConnectionError("db down") async def rollback(self): self.rollbacks += 1 class _OkDB: def __init__(self, recs): self.recs = recs async def execute(self, *a, **k): recs = self.recs return SimpleNamespace(scalars=lambda: SimpleNamespace(all=lambda: recs)) async def rollback(self): pass @pytest.fixture(autouse=True) def _fast(monkeypatch): monkeypatch.setattr(lt, "_LOAD_RETRY_DELAYS", (0.0, 0.0)) @pytest.mark.asyncio async def test_load_failure_retries_and_fails_closed(): t = _t() db = _FailDB() notifier = SimpleNamespace(notify_system_warning=AsyncMock()) with patch("app.services.telegram_notifier.telegram_notifier", notifier): await t.load_from_db(db) assert db.calls == 3 and db.rollbacks == 3 assert t._load_failed is True assert t.is_banned("000270") # 아무 종목이나 차단 notifier.notify_system_warning.assert_awaited_once() @pytest.mark.asyncio async def test_save_deferred_while_failed_then_merged_on_success(): t = _t() t._load_failed = True t.record_trade_result("000270", True) enq = AsyncMock() with patch("app.services.db_worker.enqueue_stock_ban_upsert", enq): await t.save_to_db(None, "000270") enq.assert_not_awaited() # DB 밴 행 덮어쓰기 금지 future = (datetime.now(timezone.utc) + timedelta(days=10)).replace(tzinfo=None) rec = SimpleNamespace(stock_code="000270", consecutive_losses=settings.CONSECUTIVE_LOSS_BAN_COUNT, ban_until=future) await t.load_from_db(_OkDB([rec])) assert t._load_failed is False assert t.is_banned("000270") assert t._tracker["000270"]["consecutive_losses"] >= settings.CONSECUTIVE_LOSS_BAN_COUNT enq.assert_awaited_once() # 병합된 값이 저장됨 @pytest.mark.asyncio async def test_retry_skips_within_min_interval(monkeypatch): t = _t() t._load_failed = True import time t._last_load_attempt = time.monotonic() assert await t.retry_load_if_failed_v2() is False assert t._load_failed is True def test_normal_tracker_unaffected(): t = _t() assert t.is_banned("005930") is False @pytest.mark.asyncio async def test_is_banned_safe_does_not_block_on_slow_reload(monkeypatch): """진입 판정은 재복원을 기다리지 않고 즉시 fail-closed, 재복원은 단일 비행.""" import asyncio import app.core.database as dbmod started = [] release = asyncio.Event() class _SlowSession: async def __aenter__(self): started.append(1) await release.wait() # DB 무응답 raise ConnectionError("db down") async def __aexit__(self, *a): return False monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _SlowSession()) monkeypatch.setattr(lt, "_LOAD_RETRY_MIN_INTERVAL_SEC", 0.0) t = _t() t._load_failed = True r1 = await asyncio.wait_for(t.is_banned_safe("000270"), timeout=1.0) r2 = await asyncio.wait_for(t.is_banned_safe("005930"), timeout=1.0) assert r1 is True and r2 is True await asyncio.sleep(0) assert len(started) == 1 # 단일 비행 release.set() await t._reload_task assert t._load_failed is True @pytest.mark.asyncio async def test_background_reload_has_timeout_cap(monkeypatch): import asyncio import app.core.database as dbmod class _HangSession: async def __aenter__(self): await asyncio.sleep(60) async def __aexit__(self, *a): return False monkeypatch.setattr(dbmod, "AsyncSessionLocal", lambda: _HangSession()) monkeypatch.setattr(lt, "_LOAD_RETRY_TASK_TIMEOUT_SEC", 0.05) t = _t() t._load_failed = True await asyncio.wait_for(t._reload_in_background_impl(), timeout=2.0) assert t._load_failed is True