from __future__ import annotations import asyncio import pytest from src.bootstrap.application import ( ApplicationComposition, run_application, ) from src.market_data.acquisition.runtime.trade_stream_production_runtime import ( TradeStreamProductionRuntimeState, ) class FakeBotSession: def __init__( self, *, close_error: BaseException | None = None, ) -> None: self.close_calls = 0 self.close_error = close_error async def close(self) -> None: self.close_calls += 1 if self.close_error is not None: raise self.close_error class FakeBot: def __init__( self, *, close_error: BaseException | None = None, ) -> None: self.session = FakeBotSession( close_error=close_error, ) class FakeDispatcher: def __init__( self, *, return_immediately: bool = False, error: BaseException | None = None, ) -> None: self.started = asyncio.Event() self.release = asyncio.Event() self.cancelled = asyncio.Event() self.return_immediately = return_immediately self.error = error self.close_bot_session_values: list[bool] = [] async def start_polling( self, bot: FakeBot, *, close_bot_session: bool, ) -> None: del bot self.close_bot_session_values.append(close_bot_session) self.started.set() try: if not self.return_immediately: await self.release.wait() except asyncio.CancelledError: self.cancelled.set() raise if self.error is not None: raise self.error class FakeRuntime: def __init__( self, *, return_immediately: bool = False, error: BaseException | None = None, stop_error: BaseException | None = None, ) -> None: self.started = asyncio.Event() self.release = asyncio.Event() self.stopped = asyncio.Event() self.return_immediately = return_immediately self.error = error self.stop_error = stop_error self.stop_calls = 0 @property def state(self) -> TradeStreamProductionRuntimeState: return ( TradeStreamProductionRuntimeState.RUNNING if self.started.is_set() and not self.stopped.is_set() else TradeStreamProductionRuntimeState.STOPPED ) @property def running(self) -> bool: return self.state is TradeStreamProductionRuntimeState.RUNNING async def run(self) -> None: self.started.set() if not self.return_immediately: await self.release.wait() if self.error is not None: raise self.error async def stop(self) -> None: self.stop_calls += 1 self.release.set() self.stopped.set() if self.stop_error is not None: raise self.stop_error class BlockingStopRuntime(FakeRuntime): def __init__(self) -> None: super().__init__() self.stop_entered = asyncio.Event() self.stop_release = asyncio.Event() async def stop(self) -> None: self.stop_calls += 1 self.stop_entered.set() await self.stop_release.wait() self.release.set() self.stopped.set() def make_application( *, dispatcher: FakeDispatcher, runtime: FakeRuntime | None, bot: FakeBot | None = None, ) -> ApplicationComposition: return ApplicationComposition( bot=bot or FakeBot(), # type: ignore[arg-type] dispatcher=dispatcher, # type: ignore[arg-type] trade_stream_runtime=runtime, ) def test_disabled_runtime_runs_only_polling_and_closes_bot() -> None: async def scenario() -> None: dispatcher = FakeDispatcher(return_immediately=True) bot = FakeBot() await run_application( make_application( dispatcher=dispatcher, runtime=None, bot=bot, ) ) assert dispatcher.close_bot_session_values == [False] assert bot.session.close_calls == 1 asyncio.run(scenario()) def test_polling_completion_stops_and_awaits_runtime() -> None: async def scenario() -> None: dispatcher = FakeDispatcher(return_immediately=True) runtime = FakeRuntime() bot = FakeBot() await run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, ) ) assert runtime.started.is_set() assert runtime.stop_calls == 1 assert runtime.stopped.is_set() assert bot.session.close_calls == 1 asyncio.run(scenario()) def test_runtime_error_is_fatal_and_stops_polling() -> None: async def scenario() -> None: expected = RuntimeError("trade stream failed") dispatcher = FakeDispatcher() runtime = FakeRuntime( return_immediately=True, error=expected, ) bot = FakeBot() with pytest.raises(RuntimeError) as exc_info: await run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, ) ) assert exc_info.value is expected assert dispatcher.cancelled.is_set() assert runtime.stop_calls == 1 assert bot.session.close_calls == 1 asyncio.run(scenario()) def test_normal_runtime_completion_is_fatal() -> None: async def scenario() -> None: dispatcher = FakeDispatcher() runtime = FakeRuntime(return_immediately=True) with pytest.raises( RuntimeError, match="terminated unexpectedly", ): await run_application( make_application( dispatcher=dispatcher, runtime=runtime, ) ) assert dispatcher.cancelled.is_set() assert runtime.stop_calls == 1 asyncio.run(scenario()) def test_application_cancellation_performs_full_cleanup() -> None: async def scenario() -> None: dispatcher = FakeDispatcher() runtime = FakeRuntime() bot = FakeBot() task = asyncio.create_task( run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, ) ) ) await dispatcher.started.wait() await runtime.started.wait() task.cancel() with pytest.raises(asyncio.CancelledError): await task assert dispatcher.cancelled.is_set() assert runtime.stop_calls == 1 assert runtime.stopped.is_set() assert bot.session.close_calls == 1 await asyncio.sleep(0) assert not { child.get_name() for child in asyncio.all_tasks() if child is not asyncio.current_task() and not child.done() and child.get_name() in { "telegram-polling", "trade-stream-runtime", "application-shutdown", } } asyncio.run(scenario()) def test_polling_error_stops_runtime_and_preserves_error() -> None: async def scenario() -> None: expected = RuntimeError("polling failed") dispatcher = FakeDispatcher( return_immediately=True, error=expected, ) runtime = FakeRuntime() bot = FakeBot() with pytest.raises(RuntimeError) as exc_info: await run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, ) ) assert exc_info.value is expected assert runtime.stop_calls == 1 assert runtime.stopped.is_set() assert bot.session.close_calls == 1 asyncio.run(scenario()) def test_simultaneous_root_failures_are_awaited_deterministically() -> None: async def scenario() -> None: polling_error = RuntimeError("polling failed") runtime_error = ValueError("trade stream failed") dispatcher = FakeDispatcher( error=polling_error, ) runtime = FakeRuntime( error=runtime_error, ) bot = FakeBot() task = asyncio.create_task( run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, ) ) ) await dispatcher.started.wait() await runtime.started.wait() dispatcher.release.set() runtime.release.set() with pytest.raises(ValueError) as exc_info: await task assert exc_info.value is runtime_error assert runtime.stop_calls == 1 assert bot.session.close_calls == 1 assert any( "cleanup also failed" in note for note in getattr(runtime_error, "__notes__", ()) ) await asyncio.sleep(0) assert not { child.get_name() for child in asyncio.all_tasks() if child is not asyncio.current_task() and not child.done() and child.get_name() in { "telegram-polling", "trade-stream-runtime", "application-shutdown", } } asyncio.run(scenario()) def test_repeated_cancellation_does_not_interrupt_cleanup() -> None: async def scenario() -> None: dispatcher = FakeDispatcher() runtime = BlockingStopRuntime() bot = FakeBot() task = asyncio.create_task( run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, ) ) ) await dispatcher.started.wait() await runtime.started.wait() task.cancel() await runtime.stop_entered.wait() task.cancel() runtime.stop_release.set() with pytest.raises(asyncio.CancelledError): await task assert task.cancelled() is True assert dispatcher.cancelled.is_set() assert runtime.stop_calls == 1 assert runtime.stopped.is_set() assert bot.session.close_calls == 1 await asyncio.sleep(0) assert not { child.get_name() for child in asyncio.all_tasks() if child is not asyncio.current_task() and not child.done() and child.get_name() in { "telegram-polling", "trade-stream-runtime", "application-shutdown", } } asyncio.run(scenario()) def test_cleanup_error_does_not_replace_runtime_error() -> None: async def scenario() -> None: expected = RuntimeError("trade stream failed") dispatcher = FakeDispatcher() runtime = FakeRuntime( return_immediately=True, error=expected, stop_error=RuntimeError("stop failed"), ) bot = FakeBot( close_error=RuntimeError("close failed"), ) with pytest.raises(RuntimeError) as exc_info: await run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, ) ) assert exc_info.value is expected assert bot.session.close_calls == 1 assert any( "cleanup also failed" in note for note in getattr(expected, "__notes__", ()) ) asyncio.run(scenario())