"""Integration tests for the recovery loop.""" from __future__ import annotations from datetime import UTC, datetime, timedelta from decimal import Decimal from pathlib import Path from uuid import uuid4 import pytest from pytest_httpx import HTTPXMock from cerbero_bite.config import golden_config from cerbero_bite.config.mcp_endpoints import load_endpoints from cerbero_bite.runtime import build_runtime from cerbero_bite.runtime.recovery import recover_state from cerbero_bite.state import PositionRecord, transaction from cerbero_bite.state import connect as connect_state pytestmark = pytest.mark.httpx_mock(assert_all_responses_were_requested=False) def _now() -> datetime: return datetime(2026, 4, 27, 14, 0, tzinfo=UTC) def _build_ctx(tmp_path: Path): return build_runtime( cfg=golden_config(), endpoints=load_endpoints(env={}), token="t", db_path=tmp_path / "state.sqlite", audit_path=tmp_path / "audit.log", retry_max=1, clock=_now, ) def _make_record(*, status: str, proposal_id, opened: datetime | None = None) -> PositionRecord: base_now = _now() return PositionRecord( proposal_id=proposal_id, spread_type="bull_put", expiry=base_now + timedelta(days=18), short_strike=Decimal("2475"), long_strike=Decimal("2350"), short_instrument="ETH-15MAY26-2475-P", long_instrument="ETH-15MAY26-2350-P", n_contracts=2, spread_width_usd=Decimal("125"), spread_width_pct=Decimal("0.04"), credit_eth=Decimal("0.030"), credit_usd=Decimal("90"), max_loss_usd=Decimal("160"), spot_at_entry=Decimal("3000"), dvol_at_entry=Decimal("50"), delta_at_entry=Decimal("-0.12"), eth_price_at_entry=Decimal("3000"), proposed_at=base_now - timedelta(hours=1), opened_at=opened, status=status, created_at=base_now - timedelta(hours=1), updated_at=base_now - timedelta(hours=1), ) @pytest.mark.asyncio async def test_recovery_promotes_awaiting_fill_when_broker_shows_position( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _build_ctx(tmp_path) pid = uuid4() record = _make_record(status="awaiting_fill", proposal_id=pid) conn = connect_state(ctx.db_path) try: with transaction(conn): ctx.repository.create_position(conn, record) finally: conn.close() httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_positions", json=[ {"instrument": "ETH-15MAY26-2475-P", "size": 2}, {"instrument": "ETH-15MAY26-2350-P", "size": 2}, ], ) await recover_state(ctx, now=_now()) conn = connect_state(ctx.db_path) try: positions = ctx.repository.list_positions(conn) finally: conn.close() assert positions[0].status == "open" assert positions[0].opened_at is not None @pytest.mark.asyncio async def test_recovery_cancels_awaiting_fill_when_broker_lacks_legs( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _build_ctx(tmp_path) pid = uuid4() record = _make_record(status="awaiting_fill", proposal_id=pid) conn = connect_state(ctx.db_path) try: with transaction(conn): ctx.repository.create_position(conn, record) finally: conn.close() httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_positions", json=[], ) await recover_state(ctx, now=_now()) conn = connect_state(ctx.db_path) try: positions = ctx.repository.list_positions(conn) finally: conn.close() assert positions[0].status == "cancelled" assert positions[0].close_reason == "recovery_no_fill" # discrepancies → kill switch armed assert ctx.kill_switch.is_armed() is True @pytest.mark.asyncio async def test_recovery_alerts_on_open_position_missing_on_broker( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _build_ctx(tmp_path) pid = uuid4() record = _make_record( status="open", proposal_id=pid, opened=_now() - timedelta(days=1) ) conn = connect_state(ctx.db_path) try: with transaction(conn): ctx.repository.create_position(conn, record) finally: conn.close() httpx_mock.add_response( url="http://mcp-deribit:9011/tools/get_positions", json=[], ) await recover_state(ctx, now=_now()) assert ctx.kill_switch.is_armed() is True @pytest.mark.asyncio async def test_recovery_noop_when_no_in_flight_positions( tmp_path: Path, httpx_mock: HTTPXMock ) -> None: ctx = _build_ctx(tmp_path) # No HTTP stubs needed because get_positions is not even called. await recover_state(ctx, now=_now()) assert ctx.kill_switch.is_armed() is False