Files
dzentra_bot/app/tests/unit/bootstrap/test_application.py

443 lines
12 KiB
Python

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())