"""Tests for trade-alert broadcasts and balance pre-check logic added 2026-06-01. Coverage targets: 1. _broadcast_trade_alert — fire-and-forget, must not raise 2. Balance pre-check maths — required_margin = (notional / leverage) * 1.1 3. Startup drain in the Telegram bot loop — offset advances past pending updates """ from __future__ import annotations import asyncio import pytest # ── 1. _broadcast_trade_alert ───────────────────────────────────────────────── @pytest.mark.asyncio async def test_broadcast_trade_alert_no_connections(monkeypatch): """broadcast_trade_alert must silently succeed when no WS clients are connected.""" from app.services.bot_engine import _broadcast_trade_alert from app.ws import manager as mgr_mod # Patch manager.broadcast to verify it's called with correct payload calls: list[dict] = [] async def fake_broadcast(msg: dict): calls.append(msg) monkeypatch.setattr(mgr_mod.manager, "broadcast", fake_broadcast) await _broadcast_trade_alert("0xabc", "execution_failed", asset="BTC", reason="test error") assert len(calls) == 1 assert calls[0]["type"] == "trade_alert" assert calls[0]["wallet"] == "0xabc" assert calls[0]["event"] == "execution_failed" assert calls[0]["asset"] == "BTC" @pytest.mark.asyncio async def test_broadcast_trade_alert_swallows_exceptions(monkeypatch): """broadcast_trade_alert must not propagate exceptions — trade flow must continue.""" from app.services.bot_engine import _broadcast_trade_alert from app.ws import manager as mgr_mod async def exploding_broadcast(msg: dict): raise RuntimeError("WS layer crashed") monkeypatch.setattr(mgr_mod.manager, "broadcast", exploding_broadcast) # Must not raise await _broadcast_trade_alert("0xabc", "budget_reached", asset="BTC") # ── 2. Balance pre-check maths ──────────────────────────────────────────────── def test_required_margin_formula(): """required_margin = (notional / leverage) * 1.1 — verify key scenarios.""" def required_margin(notional: float, leverage: int) -> float: return round((notional / max(leverage, 1)) * 1.1, 2) # 2× leverage, $100 position → $50 margin + 10% buffer = $55 assert required_margin(100, 2) == 55.0 # 5× leverage, $500 position → $100 margin + 10% = $110 assert required_margin(500, 5) == 110.0 # 1× leverage, $20 position → $20 + 10% = $22 assert required_margin(20, 1) == 22.0 # edge: leverage=0 treated as 1 (no divide-by-zero) assert required_margin(100, 0) == 110.0 def test_balance_check_does_not_block_low_leverage(): """With $100 balance and 10× leverage on a $200 notional, margin = $22 — should PASS.""" balance = 100.0 notional = 200.0 leverage = 10 required = (notional / max(leverage, 1)) * 1.1 assert balance >= required, ( f"Balance ${balance} should cover ${required:.2f} margin " f"(${notional} notional at {leverage}×)" ) def test_balance_check_fires_at_truly_insufficient_balance(): """With $5 balance and 2× on $20 notional, margin = $11 — should BLOCK.""" balance = 5.0 notional = 20.0 leverage = 2 required = (notional / max(leverage, 1)) * 1.1 assert balance < required, ( f"Balance ${balance} should NOT cover ${required:.2f} margin" ) # ── 3. Telegram startup drain ──────────────────────────────────────────────── @pytest.mark.asyncio async def test_telegram_startup_drain_advances_offset(monkeypatch): """Verify the drain calls getUpdates with timeout=0 and ACKs the last update_id.""" import httpx drain_calls: list[dict] = [] class FakeResponse: status_code = 200 def json(self): if len(drain_calls) == 1: # first call: return pending updates return {"result": [{"update_id": 100}, {"update_id": 101}]} return {"result": []} # second call (ACK): no pending class FakeClient: async def __aenter__(self): return self async def __aexit__(self, *a): pass async def get(self, url, params=None): drain_calls.append(params or {}) return FakeResponse() monkeypatch.setattr(httpx, "AsyncClient", lambda **kw: FakeClient()) # Simulate only the drain portion (extract the logic) from app.config import settings monkeypatch.setattr(settings, "telegram_bot_token", "test-token") import httpx as _httpx # Re-run the drain logic inline (mirrors telegram_bot.py startup drain) token = "test-token" TG_API = "https://api.telegram.org/bot{token}/{method}" async with _httpx.AsyncClient(timeout=10) as client: r = await client.get( TG_API.format(token=token, method="getUpdates"), params={"timeout": 0, "limit": 100}, ) if r.status_code == 200: pending = r.json().get("result", []) if pending: drain_offset = pending[-1]["update_id"] + 1 async with _httpx.AsyncClient(timeout=10) as client: await client.get( TG_API.format(token=token, method="getUpdates"), params={"timeout": 0, "offset": drain_offset}, ) # First call: drain request (timeout=0, limit=100) assert drain_calls[0].get("timeout") == 0 assert drain_calls[0].get("limit") == 100 # Second call: ACK with offset = last_update_id + 1 assert drain_calls[1].get("offset") == 102 # 101 + 1