"""Tests for stock_client.StockClient.""" import asyncio import logging import pytest import httpx from signal_v2.stock_client import StockClient BASE_URL = "https://test.stock.local" API_KEY = "test-secret" async def test_get_portfolio_normal_returns_dict_with_pnl_pct(mock_stock_api): """정상 200 응답 + cache 저장.""" mock_stock_api.get("/api/webai/portfolio").mock( return_value=httpx.Response( 200, json={ "holdings": [{"ticker": "005930", "pnl_pct": 0.047}], "cash": [], "summary": {}, }, ) ) client = StockClient(BASE_URL, API_KEY) try: result = await client.get_portfolio() assert result["holdings"][0]["pnl_pct"] == 0.047 # Cache populated assert len(client._cache) >= 1 finally: await client.close() async def test_get_portfolio_uses_cache_within_ttl(mock_stock_api): """60s TTL 내 두번째 호출 = mock 콜 1회.""" route = mock_stock_api.get("/api/webai/portfolio").mock( return_value=httpx.Response( 200, json={"holdings": [], "cash": [], "summary": {}} ) ) client = StockClient(BASE_URL, API_KEY) try: await client.get_portfolio() await client.get_portfolio() # second call within TTL assert route.call_count == 1 finally: await client.close() async def test_get_portfolio_refetches_after_ttl_expiry(mock_stock_api, monkeypatch): """TTL 만료 후 재호출 = mock 콜 2회. time.monotonic 모킹.""" route = mock_stock_api.get("/api/webai/portfolio").mock( return_value=httpx.Response( 200, json={"holdings": [], "cash": [], "summary": {}} ) ) # Fake clock: starts at 0, jumps to 61 between calls fake_time = [0.0] monkeypatch.setattr( "signal_v2.stock_client.time.monotonic", lambda: fake_time[0] ) client = StockClient(BASE_URL, API_KEY) try: await client.get_portfolio() fake_time[0] = 61.0 # 60s TTL 만료 await client.get_portfolio() assert route.call_count == 2 finally: await client.close() async def test_get_portfolio_retries_3_times_on_timeout(mock_stock_api, monkeypatch): """timeout 2번 + 200 1번 → 최종 성공. exponential sleep 호출 검증.""" sleep_calls = [] async def fake_sleep(s): sleep_calls.append(s) monkeypatch.setattr("asyncio.sleep", fake_sleep) mock_stock_api.get("/api/webai/portfolio").mock( side_effect=[ httpx.TimeoutException("timeout 1"), httpx.TimeoutException("timeout 2"), httpx.Response( 200, json={"holdings": [], "cash": [], "summary": {}} ), ] ) client = StockClient(BASE_URL, API_KEY) try: result = await client.get_portfolio() assert result["holdings"] == [] assert sleep_calls == [1, 2] # exponential 1s, 2s finally: await client.close() async def test_get_portfolio_429_triggers_backoff(mock_stock_api, monkeypatch): """429 → 1s backoff → 200.""" sleep_calls = [] async def fake_sleep(s): sleep_calls.append(s) monkeypatch.setattr("asyncio.sleep", fake_sleep) mock_stock_api.get("/api/webai/portfolio").mock( side_effect=[ httpx.Response(429, text="rate limit"), httpx.Response( 200, json={"holdings": [], "cash": [], "summary": {}} ), ] ) client = StockClient(BASE_URL, API_KEY) try: result = await client.get_portfolio() assert result["holdings"] == [] assert sleep_calls == [1] finally: await client.close() async def test_get_portfolio_falls_back_to_stale_on_all_failures( mock_stock_api, monkeypatch, caplog ): """cache 에 이전 성공 응답 + 모든 retry 5xx → stale 반환 + logger.warning.""" # No-op sleep for fast test async def fake_sleep(s): return None monkeypatch.setattr("asyncio.sleep", fake_sleep) # Patch time.monotonic BEFORE first call so cached timestamp uses fake clock fake_time = [0.0] monkeypatch.setattr( "signal_v2.stock_client.time.monotonic", lambda: fake_time[0] ) # First call succeeds route1 = mock_stock_api.get("/api/webai/portfolio").mock( return_value=httpx.Response( 200, json={"holdings": [{"ticker": "005930"}], "cash": [], "summary": {}}, ) ) client = StockClient(BASE_URL, API_KEY) try: first = await client.get_portfolio() assert first["holdings"][0]["ticker"] == "005930" # Advance fake clock past TTL (60s) so cache is stale fake_time[0] = 61.0 # Now mock to return 500s persistently route1.mock(return_value=httpx.Response(500, text="server error")) with caplog.at_level(logging.WARNING, logger="signal_v2.stock_client"): result = await client.get_portfolio() assert result["holdings"][0]["ticker"] == "005930" # stale data returned assert any( "stale" in rec.message.lower() for rec in caplog.records ) finally: await client.close()