from __future__ import annotations import asyncio import threading 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, calls: list[str] | None = None, ) -> None: self.started = asyncio.Event() self.release = asyncio.Event() self.cancelled = asyncio.Event() self.return_immediately = return_immediately self.error = error self.calls = calls self.close_bot_session_values: list[bool] = [] async def start_polling( self, bot: FakeBot, *, close_bot_session: bool, ) -> None: del bot if self.calls is not None: self.calls.append("polling.start") 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, calls: list[str] | 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.calls = calls 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: if self.calls is not None: self.calls.append("runtime.run") 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 if self.calls is not None: self.calls.append("runtime.stop") 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() class FakeStorageLifecycle: def __init__( self, *, calls: list[str] | None = None, start_error: BaseException | None = None, stop_error: BaseException | None = None, start_release: threading.Event | None = None, stop_release: threading.Event | None = None, ) -> None: self.calls = calls self.start_error = start_error self.stop_error = stop_error self.start_release = start_release self.stop_release = stop_release self.start_entered = threading.Event() self.stop_entered = threading.Event() self.start_calls = 0 self.stop_calls = 0 self.started = False def start(self) -> None: self.start_calls += 1 if self.calls is not None: self.calls.append("storage.start") self.start_entered.set() if self.start_release is not None: if not self.start_release.wait(timeout=2.0): raise TimeoutError("storage startup was not released") if self.start_error is not None: raise self.start_error self.started = True def stop(self) -> None: self.stop_calls += 1 if self.calls is not None: self.calls.append("storage.stop") self.started = False self.stop_entered.set() if self.stop_release is not None: if not self.stop_release.wait(timeout=2.0): raise TimeoutError("storage shutdown was not released") if self.stop_error is not None: raise self.stop_error def make_application( *, dispatcher: FakeDispatcher, runtime: FakeRuntime | None, bot: FakeBot | None = None, storage_lifecycle: FakeStorageLifecycle | None = None, ) -> ApplicationComposition: return ApplicationComposition( bot=bot or FakeBot(), # type: ignore[arg-type] dispatcher=dispatcher, # type: ignore[arg-type] trade_stream_runtime=runtime, market_data_storage_lifecycle=storage_lifecycle, ) def test_storage_lifecycle_wraps_root_runtime_tasks() -> None: async def scenario() -> None: calls: list[str] = [] storage = FakeStorageLifecycle(calls=calls) dispatcher = FakeDispatcher( return_immediately=True, calls=calls, ) runtime = FakeRuntime(calls=calls) await run_application( make_application( dispatcher=dispatcher, runtime=runtime, storage_lifecycle=storage, ) ) assert storage.start_calls == 1 assert storage.stop_calls == 1 assert calls.index("storage.start") < calls.index( "polling.start" ) assert calls.index("storage.start") < calls.index( "runtime.run" ) assert calls.index("runtime.stop") < calls.index( "storage.stop" ) asyncio.run(scenario()) def test_storage_startup_failure_prevents_root_task_start() -> None: async def scenario() -> None: expected = RuntimeError("storage startup failed") storage = FakeStorageLifecycle(start_error=expected) dispatcher = FakeDispatcher() runtime = FakeRuntime() bot = FakeBot() with pytest.raises(RuntimeError) as error_info: await run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, storage_lifecycle=storage, ) ) assert error_info.value is expected assert dispatcher.started.is_set() is False assert runtime.started.is_set() is False assert runtime.stop_calls == 1 assert storage.stop_calls == 1 assert bot.session.close_calls == 1 asyncio.run(scenario()) def test_cancellation_waits_for_storage_startup_before_cleanup() -> None: async def scenario() -> None: start_release = threading.Event() storage = FakeStorageLifecycle( start_release=start_release, ) dispatcher = FakeDispatcher() runtime = FakeRuntime() bot = FakeBot() task = asyncio.create_task( run_application( make_application( dispatcher=dispatcher, runtime=runtime, bot=bot, storage_lifecycle=storage, ) ) ) entered = await asyncio.to_thread( storage.start_entered.wait, 1.0, ) assert entered is True task.cancel() try: await asyncio.sleep(0) await asyncio.sleep(0) assert task.done() is False finally: start_release.set() with pytest.raises(asyncio.CancelledError): await task assert dispatcher.started.is_set() is False assert runtime.started.is_set() is False assert storage.stop_calls == 1 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 { "application-shutdown", "market-data-storage-shutdown", "market-data-storage-startup", "telegram-polling", "trade-stream-runtime", } } asyncio.run(scenario()) def test_storage_shutdown_error_is_reported_and_bot_still_closes() -> None: async def scenario() -> None: expected = RuntimeError("storage close failed") storage = FakeStorageLifecycle(stop_error=expected) dispatcher = FakeDispatcher(return_immediately=True) bot = FakeBot() with pytest.raises(RuntimeError) as error_info: await run_application( make_application( dispatcher=dispatcher, runtime=None, bot=bot, storage_lifecycle=storage, ) ) assert error_info.value is expected assert storage.stop_calls == 1 assert bot.session.close_calls == 1 asyncio.run(scenario()) def test_storage_shutdown_error_does_not_replace_runtime_error() -> None: async def scenario() -> None: runtime_error = RuntimeError("runtime failed") storage_error = RuntimeError("storage close failed") storage = FakeStorageLifecycle(stop_error=storage_error) dispatcher = FakeDispatcher() runtime = FakeRuntime( return_immediately=True, error=runtime_error, ) with pytest.raises(RuntimeError) as error_info: await run_application( make_application( dispatcher=dispatcher, runtime=runtime, storage_lifecycle=storage, ) ) assert error_info.value is runtime_error assert any( "cleanup also failed" in note for note in getattr(runtime_error, "__notes__", ()) ) asyncio.run(scenario()) def test_repeated_cancellation_does_not_interrupt_storage_shutdown( ) -> None: async def scenario() -> None: stop_release = threading.Event() storage = FakeStorageLifecycle( stop_release=stop_release, ) dispatcher = FakeDispatcher(return_immediately=True) bot = FakeBot() task = asyncio.create_task( run_application( make_application( dispatcher=dispatcher, runtime=None, bot=bot, storage_lifecycle=storage, ) ) ) entered = await asyncio.to_thread( storage.stop_entered.wait, 1.0, ) assert entered is True task.cancel() task.cancel() try: await asyncio.sleep(0) assert task.done() is False finally: stop_release.set() with pytest.raises(asyncio.CancelledError): await task assert storage.stop_calls == 1 assert bot.session.close_calls == 1 asyncio.run(scenario()) 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())