- GET /api/v1/market/indices — 네이버 JSON API(비-KIS), 45초 캐시 - 부분 실패 시 직전값 병합, 전부 실패 시 12초 쿨다운 + 직전 캐시 반환 - 방향 code 해석 불가 시 원 부호 폴백, NaN/inf 거부 - MarketIndexBar: 60초 갱신, 상승 빨강/하락 파랑, 장 마감 배지 - 테스트 12건 (전체 877 passed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
"""전체시장 지수 파서/캐시 테스트 — 외부 HTTP 는 예외 발생형 목."""
|
|
import sys, os
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
|
|
import json
|
|
import pytest
|
|
from unittest.mock import patch
|
|
|
|
from app.services import market_indices as mi
|
|
|
|
|
|
def _kr(close, diff, ratio, code):
|
|
return {"closePrice": close, "compareToPreviousClosePrice": diff, "fluctuationsRatio": ratio,
|
|
"compareToPreviousPrice": {"code": code}, "marketStatus": "OPEN", "localTradedAt": "t"}
|
|
|
|
|
|
def test_parse_kr_rising_unsigned():
|
|
r = mi.parse_index_payload("KOSPI", "코스피", "KR", _kr("6,869.42", "31.38", "0.46", "2"))
|
|
assert r["price"] == 6869.42 and r["change"] == 31.38 and r["change_pct"] == 0.46
|
|
|
|
|
|
def test_parse_kr_falling_unsigned_becomes_negative():
|
|
r = mi.parse_index_payload("KOSDAQ", "코스닥", "KR", _kr("879.26", "23.35", "2.73", "5"))
|
|
assert r["change"] == -23.35 and r["change_pct"] == -2.73
|
|
|
|
|
|
def test_parse_us_signed_falling():
|
|
r = mi.parse_index_payload("SP500", "S&P 500", "US", _kr("7,651.54", "-19.30", "-0.25", "5"))
|
|
assert r["change"] == -19.30 and r["change_pct"] == -0.25
|
|
|
|
|
|
def test_parse_flat_zeroes_change():
|
|
r = mi.parse_index_payload("KOSPI", "코스피", "KR", _kr("100", "0", "0", "3"))
|
|
assert r["change"] == 0 and r["change_pct"] == 0
|
|
|
|
|
|
def test_parse_invalid_returns_none():
|
|
assert mi.parse_index_payload("X", "x", "KR", {}) is None
|
|
assert mi.parse_index_payload("X", "x", "KR", None) is None
|
|
assert mi.parse_index_payload("X", "x", "KR", {"closePrice": "abc"}) is None
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_cache():
|
|
mi._cache.update(ts=0.0, data=None, by_key={}, fail_ts=0.0)
|
|
yield
|
|
mi._cache.update(ts=0.0, data=None, by_key={}, fail_ts=0.0)
|
|
|
|
|
|
class _Resp:
|
|
def __init__(self, payload): self._p = payload
|
|
def raise_for_status(self): pass
|
|
def json(self): return self._p
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_failure_and_cache():
|
|
calls = []
|
|
|
|
async def fake_get(self, url, **kw):
|
|
calls.append(url)
|
|
if "KOSDAQ" in url:
|
|
raise RuntimeError("boom")
|
|
return _Resp(_kr("100", "1", "1.0", "2"))
|
|
|
|
with patch("httpx.AsyncClient.get", fake_get):
|
|
items = await mi.get_market_indices_cached()
|
|
assert {i["code"] for i in items} == {"KOSPI", "KOSPI200", "SP500", "NASDAQ"}
|
|
n = len(calls)
|
|
await mi.get_market_indices_cached()
|
|
assert len(calls) == n # 캐시 적중
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_endpoint_returns_json():
|
|
from fastapi import FastAPI
|
|
import httpx
|
|
from app.api.endpoints import market_indices as ep
|
|
|
|
async def fake_cached():
|
|
return [{"code": "KOSPI", "name": "코스피", "market": "KR", "price": 1.0,
|
|
"change": 0.1, "change_pct": 0.1}]
|
|
|
|
app = FastAPI()
|
|
app.include_router(ep.router, prefix="/api/market")
|
|
with patch.object(ep, "get_market_indices_cached", fake_cached):
|
|
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as c:
|
|
r = await c.get("/api/market/indices")
|
|
body = json.loads(r.text)
|
|
assert r.status_code == 200 and body["count"] == 1 and body["indices"][0]["code"] == "KOSPI"
|
|
|
|
|
|
def test_parse_nan_rejected():
|
|
assert mi.parse_index_payload("X", "x", "KR", _kr("nan", "1", "1", "2")) is None
|
|
assert mi.parse_index_payload("X", "x", "KR", _kr("inf", "1", "1", "2")) is None
|
|
r = mi.parse_index_payload("X", "x", "KR", _kr("100", "nan", "inf", "2"))
|
|
assert r["change"] == 0 and r["change_pct"] == 0
|
|
|
|
|
|
def test_parse_missing_code_keeps_raw_sign():
|
|
d = {"closePrice": "100", "compareToPreviousClosePrice": "-2.5", "fluctuationsRatio": "-1.2"}
|
|
r = mi.parse_index_payload("X", "x", "US", d)
|
|
assert r["change"] == -2.5 and r["change_pct"] == -1.2
|
|
d["compareToPreviousPrice"] = {"code": "9"}
|
|
assert mi.parse_index_payload("X", "x", "US", d)["change"] == -2.5
|
|
|
|
|
|
def _clock(monkeypatch, t):
|
|
monkeypatch.setattr(mi.time, "monotonic", lambda: t[0])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_partial_failure_merges_previous(monkeypatch):
|
|
t = [1000.0]
|
|
_clock(monkeypatch, t)
|
|
fail = {"on": False}
|
|
|
|
async def fake_get(self, url, **kw):
|
|
if fail["on"] and "KOSDAQ" in url:
|
|
raise RuntimeError("boom")
|
|
return _Resp(_kr("100", "1", "1.0", "2"))
|
|
|
|
with patch("httpx.AsyncClient.get", fake_get):
|
|
assert len(await mi.get_market_indices_cached()) == 5
|
|
t[0] += 50
|
|
fail["on"] = True
|
|
items = await mi.get_market_indices_cached()
|
|
assert "KOSDAQ" in {i["code"] for i in items} and len(items) == 5
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_all_fail_returns_stale_and_cooldown(monkeypatch):
|
|
t = [1000.0]
|
|
_clock(monkeypatch, t)
|
|
calls = []
|
|
fail = {"on": False}
|
|
|
|
async def fake_get(self, url, **kw):
|
|
calls.append(url)
|
|
if fail["on"]:
|
|
raise RuntimeError("down")
|
|
return _Resp(_kr("100", "1", "1.0", "2"))
|
|
|
|
with patch("httpx.AsyncClient.get", fake_get):
|
|
await mi.get_market_indices_cached()
|
|
t[0] += 50
|
|
fail["on"] = True
|
|
n = len(calls)
|
|
items = await mi.get_market_indices_cached()
|
|
assert len(items) == 5 and len(calls) == n + 5
|
|
t[0] += 5 # 쿨다운 내
|
|
assert len(await mi.get_market_indices_cached()) == 5
|
|
assert len(calls) == n + 5
|
|
t[0] += 20 # 쿨다운 경과 -> 재조회
|
|
await mi.get_market_indices_cached()
|
|
assert len(calls) == n + 10
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cold_start_all_fail_not_cached_45s(monkeypatch):
|
|
t = [1000.0]
|
|
_clock(monkeypatch, t)
|
|
state = {"fail": True}
|
|
|
|
async def fake_get(self, url, **kw):
|
|
if state["fail"]:
|
|
raise RuntimeError("down")
|
|
return _Resp(_kr("100", "1", "1.0", "2"))
|
|
|
|
with patch("httpx.AsyncClient.get", fake_get):
|
|
assert await mi.get_market_indices_cached() == []
|
|
state["fail"] = False
|
|
t[0] += mi.FAIL_COOLDOWN_SEC + 1
|
|
assert len(await mi.get_market_indices_cached()) == 5
|