From b484f0daffd038504907fa31563ff1ef248fa0a5 Mon Sep 17 00:00:00 2001 From: agentson Date: Wed, 18 Feb 2026 01:12:09 +0900 Subject: [PATCH] fix: align cooldown test with wait-and-retry behavior + boost overseas coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test_token_refresh_cooldown: updated to match the wait-then-retry behavior introduced in aeed881 (was expecting fail-fast ConnectionError) - Added 22 tests for OverseasBroker: get_overseas_price, get_overseas_balance, send_overseas_order, _get_currency_code, _extract_ranking_rows - src/broker/overseas.py coverage: 52% → 100% - All 594 tests pass Co-Authored-By: Claude Opus 4.6 --- tests/test_broker.py | 12 +- tests/test_overseas_broker.py | 242 +++++++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 7 deletions(-) diff --git a/tests/test_broker.py b/tests/test_broker.py index 37377e8..58f5587 100644 --- a/tests/test_broker.py +++ b/tests/test_broker.py @@ -90,12 +90,12 @@ class TestTokenManagement: await broker.close() @pytest.mark.asyncio - async def test_token_refresh_cooldown_prevents_rapid_retries(self, settings): - """Token refresh should enforce cooldown after failure (issue #54).""" + async def test_token_refresh_cooldown_waits_then_retries(self, settings): + """Token refresh should wait out cooldown then retry (issue #54).""" broker = KISBroker(settings) - broker._refresh_cooldown = 2.0 # Short cooldown for testing + broker._refresh_cooldown = 0.1 # Short cooldown for testing - # First refresh attempt fails with 403 (EGW00133) + # All attempts fail with 403 (EGW00133) mock_resp_403 = AsyncMock() mock_resp_403.status = 403 mock_resp_403.text = AsyncMock( @@ -109,8 +109,8 @@ class TestTokenManagement: with pytest.raises(ConnectionError, match="Token refresh failed"): await broker._ensure_token() - # Second attempt within cooldown should fail with cooldown error - with pytest.raises(ConnectionError, match="Token refresh on cooldown"): + # Second attempt within cooldown should wait then retry (and still get 403) + with pytest.raises(ConnectionError, match="Token refresh failed"): await broker._ensure_token() await broker.close() diff --git a/tests/test_overseas_broker.py b/tests/test_overseas_broker.py index c345442..0a3eca9 100644 --- a/tests/test_overseas_broker.py +++ b/tests/test_overseas_broker.py @@ -1,4 +1,4 @@ -"""Tests for OverseasBroker.fetch_overseas_rankings with correct KIS API specs.""" +"""Tests for OverseasBroker — rankings, price, balance, order, and helpers.""" from __future__ import annotations @@ -279,3 +279,243 @@ class TestFetchOverseasRankings: call_params = mock_session.get.call_args[1]["params"] assert call_params["EXCD"] == mapped, f"{original} should map to {mapped}" + + +class TestGetOverseasPrice: + """Test get_overseas_price method.""" + + @pytest.mark.asyncio + async def test_success(self, overseas_broker: OverseasBroker) -> None: + """Successful price fetch returns JSON data.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output": {"last": "150.00"}}) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._auth_headers = AsyncMock(return_value={"authorization": "Bearer t"}) + + result = await overseas_broker.get_overseas_price("NASD", "AAPL") + assert result["output"]["last"] == "150.00" + + call_args = mock_session.get.call_args + params = call_args[1]["params"] + assert params["EXCD"] == "NASD" + assert params["SYMB"] == "AAPL" + + @pytest.mark.asyncio + async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Non-200 response should raise ConnectionError.""" + mock_resp = AsyncMock() + mock_resp.status = 400 + mock_resp.text = AsyncMock(return_value="Bad Request") + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + + with pytest.raises(ConnectionError, match="get_overseas_price failed"): + await overseas_broker.get_overseas_price("NASD", "AAPL") + + @pytest.mark.asyncio + async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Network error should raise ConnectionError.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn refused")) + cm.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=cm) + + _setup_broker_mocks(overseas_broker, mock_session) + + with pytest.raises(ConnectionError, match="Network error"): + await overseas_broker.get_overseas_price("NASD", "AAPL") + + +class TestGetOverseasBalance: + """Test get_overseas_balance method.""" + + @pytest.mark.asyncio + async def test_success(self, overseas_broker: OverseasBroker) -> None: + """Successful balance fetch returns JSON data.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"output1": [{"pdno": "AAPL"}]}) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + + result = await overseas_broker.get_overseas_balance("NASD") + assert result["output1"][0]["pdno"] == "AAPL" + + @pytest.mark.asyncio + async def test_http_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Non-200 should raise ConnectionError.""" + mock_resp = AsyncMock() + mock_resp.status = 500 + mock_resp.text = AsyncMock(return_value="Server Error") + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + + with pytest.raises(ConnectionError, match="get_overseas_balance failed"): + await overseas_broker.get_overseas_balance("NASD") + + @pytest.mark.asyncio + async def test_network_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Network error should raise ConnectionError.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=TimeoutError("timeout")) + cm.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.get = MagicMock(return_value=cm) + + _setup_broker_mocks(overseas_broker, mock_session) + + with pytest.raises(ConnectionError, match="Network error"): + await overseas_broker.get_overseas_balance("NYSE") + + +class TestSendOverseasOrder: + """Test send_overseas_order method.""" + + @pytest.mark.asyncio + async def test_buy_market_order(self, overseas_broker: OverseasBroker) -> None: + """Market buy order should use VTTT1002U and ORD_DVSN=01.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0"}) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + result = await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 10) + assert result["rt_cd"] == "0" + + # Verify BUY TR_ID + overseas_broker._broker._auth_headers.assert_called_with("VTTT1002U") + + call_args = mock_session.post.call_args + body = call_args[1]["json"] + assert body["ORD_DVSN"] == "01" # market order + assert body["OVRS_ORD_UNPR"] == "0" + + @pytest.mark.asyncio + async def test_sell_limit_order(self, overseas_broker: OverseasBroker) -> None: + """Limit sell order should use VTTT1006U and ORD_DVSN=00.""" + mock_resp = AsyncMock() + mock_resp.status = 200 + mock_resp.json = AsyncMock(return_value={"rt_cd": "0"}) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + result = await overseas_broker.send_overseas_order("NYSE", "MSFT", "SELL", 5, price=350.0) + assert result["rt_cd"] == "0" + + overseas_broker._broker._auth_headers.assert_called_with("VTTT1006U") + + call_args = mock_session.post.call_args + body = call_args[1]["json"] + assert body["ORD_DVSN"] == "00" # limit order + assert body["OVRS_ORD_UNPR"] == "350.0" + + @pytest.mark.asyncio + async def test_order_http_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Non-200 should raise ConnectionError.""" + mock_resp = AsyncMock() + mock_resp.status = 400 + mock_resp.text = AsyncMock(return_value="Bad Request") + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=_make_async_cm(mock_resp)) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + with pytest.raises(ConnectionError, match="send_overseas_order failed"): + await overseas_broker.send_overseas_order("NASD", "AAPL", "BUY", 1) + + @pytest.mark.asyncio + async def test_order_network_error_raises(self, overseas_broker: OverseasBroker) -> None: + """Network error should raise ConnectionError.""" + cm = MagicMock() + cm.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError("conn reset")) + cm.__aexit__ = AsyncMock(return_value=False) + + mock_session = MagicMock() + mock_session.post = MagicMock(return_value=cm) + + _setup_broker_mocks(overseas_broker, mock_session) + overseas_broker._broker._get_hash_key = AsyncMock(return_value="hashval") + + with pytest.raises(ConnectionError, match="Network error"): + await overseas_broker.send_overseas_order("NASD", "TSLA", "SELL", 2) + + +class TestGetCurrencyCode: + """Test _get_currency_code mapping.""" + + def test_us_exchanges(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("NASD") == "USD" + assert overseas_broker._get_currency_code("NYSE") == "USD" + assert overseas_broker._get_currency_code("AMEX") == "USD" + + def test_japan(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("TSE") == "JPY" + + def test_hong_kong(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("SEHK") == "HKD" + + def test_china(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("SHAA") == "CNY" + assert overseas_broker._get_currency_code("SZAA") == "CNY" + + def test_vietnam(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("HNX") == "VND" + assert overseas_broker._get_currency_code("HSX") == "VND" + + def test_unknown_defaults_usd(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._get_currency_code("UNKNOWN") == "USD" + + +class TestExtractRankingRows: + """Test _extract_ranking_rows helper.""" + + def test_output_key(self, overseas_broker: OverseasBroker) -> None: + data = {"output": [{"a": 1}, {"b": 2}]} + assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}] + + def test_output1_key(self, overseas_broker: OverseasBroker) -> None: + data = {"output1": [{"c": 3}]} + assert overseas_broker._extract_ranking_rows(data) == [{"c": 3}] + + def test_output2_key(self, overseas_broker: OverseasBroker) -> None: + data = {"output2": [{"d": 4}]} + assert overseas_broker._extract_ranking_rows(data) == [{"d": 4}] + + def test_no_list_returns_empty(self, overseas_broker: OverseasBroker) -> None: + data = {"output": "not a list"} + assert overseas_broker._extract_ranking_rows(data) == [] + + def test_empty_data(self, overseas_broker: OverseasBroker) -> None: + assert overseas_broker._extract_ranking_rows({}) == [] + + def test_filters_non_dict_rows(self, overseas_broker: OverseasBroker) -> None: + data = {"output": [{"a": 1}, "invalid", {"b": 2}]} + assert overseas_broker._extract_ranking_rows(data) == [{"a": 1}, {"b": 2}]