diff --git a/application/account_facts.py b/application/account_facts.py index 2b1d3005..4c61b9ff 100644 --- a/application/account_facts.py +++ b/application/account_facts.py @@ -167,7 +167,7 @@ def collect_account_facts( except Exception: raise _fail("spot_read_failed") from None if not isinstance(account, Mapping): - raise _fail("spot_response_invalid") + raise _fail("spot_response_not_object") uid = account.get("uid") if isinstance(uid, bool) or not isinstance(uid, (str, int)) or not str(uid): raise _fail("account_identity_missing") @@ -178,19 +178,23 @@ def collect_account_facts( raise _fail("account_identity_mismatch") balances = account.get("balances") - if not isinstance(balances, list) or len(balances) > 5000: - raise _fail("spot_response_invalid") + if not isinstance(balances, list): + raise _fail("spot_balances_invalid") + if len(balances) > 5000: + raise _fail("spot_balances_limit_exceeded") assets: dict[str, dict[str, Decimal]] = {} for row in balances: if not isinstance(row, Mapping): - raise _fail("spot_response_invalid") + raise _fail("spot_balance_row_invalid") asset = row.get("asset") - if ( - not isinstance(asset, str) - or not _ASSET.fullmatch(asset) - or asset in assets - ): - raise _fail("spot_response_invalid") + if not isinstance(asset, str): + raise _fail("spot_asset_type_invalid") + if not asset.isascii(): + raise _fail("spot_asset_non_ascii") + if not _ASSET.fullmatch(asset): + raise _fail("spot_asset_format_invalid") + if asset in assets: + raise _fail("spot_asset_duplicate") assets[asset] = { "spot_free": _decimal(row.get("free")), "spot_locked": _decimal(row.get("locked")), diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 1db1f097..4680bdfa 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -439,6 +439,32 @@ def get_account(self): assert client.calls == [("spot",)] +@pytest.mark.parametrize(("account", "reason"), [ + (None, "spot_response_not_object"), + ({"uid": "123456", "balances": None}, "spot_balances_invalid"), + ({"uid": "123456", "balances": [{}] * 5001}, "spot_balances_limit_exceeded"), + ({"uid": "123456", "balances": ["synthetic-private-row"]}, "spot_balance_row_invalid"), + ({"uid": "123456", "balances": [{"asset": 17}]}, "spot_asset_type_invalid"), + ({"uid": "123456", "balances": [{"asset": "synthetic-bad-symbol"}]}, "spot_asset_format_invalid"), + ({"uid": "123456", "balances": [{"asset": "币"}]}, "spot_asset_non_ascii"), + ({"uid": "123456", "balances": [ + {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, + {"asset": "SYNTHETIC", "free": "0", "locked": "0"}, + ]}, "spot_asset_duplicate"), +]) +def test_spot_validation_uses_fixed_reason_without_echoing_response_values(account, reason): + class InvalidSpot(FakeBinance): + def get_account(self): + self.calls.append(("spot",)) + return account + + client = InvalidSpot() + with pytest.raises(AccountFactsUnavailable) as raised: + _collect(client) + assert str(raised.value) == f"binance_account_facts_{reason}" + assert client.calls == [("spot",)] + + def test_rejects_earn_asset_without_an_explicit_spot_balance_row(): class SpotWithoutEarnAsset(FakeBinance): def get_account(self):