"""전체시장 지수 파서/캐시 테스트 — 외부 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