feat(signal_v2): stock_client + 6 integration tests
httpx async client with custom retry loop (max 3, exponential 1s/2s/4s), memory dict cache (portfolio 60s / news-sentiment 300s / screener 60s), X-WebAI-Key auth header injection. Stale fallback returns last successful response with logger.warning on persistent failures. 6 integration tests pass with respx httpx mock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
18
signal_v2/tests/conftest.py
Normal file
18
signal_v2/tests/conftest.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Pytest fixtures for signal_v2 tests."""
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_dedup_db(tmp_path) -> Path:
|
||||
"""SQLite 단위 테스트용 임시 DB path."""
|
||||
return tmp_path / "test_signal_v2.db"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_stock_api():
|
||||
"""respx 로 stock API mock. base_url 은 테스트마다 임의."""
|
||||
with respx.mock(base_url="https://test.stock.local", assert_all_called=False) as mock:
|
||||
yield mock
|
||||
168
signal_v2/tests/test_stock_client.py
Normal file
168
signal_v2/tests/test_stock_client.py
Normal file
@@ -0,0 +1,168 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user