Build 060.26: complete Integration and Regression
This commit is contained in:
1
app/tests/support/__init__.py
Normal file
1
app/tests/support/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Общие вспомогательные компоненты тестов Dzentra."""
|
||||
18
app/tests/support/async_wait.py
Normal file
18
app/tests/support/async_wait.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
Predicate = Callable[[], bool]
|
||||
|
||||
|
||||
async def wait_until(
|
||||
predicate: Predicate,
|
||||
*,
|
||||
timeout_seconds: float = 3.0,
|
||||
) -> None:
|
||||
"""Дождаться наблюдаемого условия без фиксированной длинной паузы."""
|
||||
async with asyncio.timeout(timeout_seconds):
|
||||
while not predicate():
|
||||
await asyncio.sleep(0.005)
|
||||
222
app/tests/support/live_trade_stream.py
Normal file
222
app/tests/support/live_trade_stream.py
Normal file
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.core.config import Settings
|
||||
|
||||
|
||||
RUN_LIVE_TESTS_ENV = "DZENTRA_RUN_LIVE_TESTS"
|
||||
LIVE_REST_URL_ENV = "DZENTRA_LIVE_REST_URL"
|
||||
LIVE_WEBSOCKET_URL_ENV = "DZENTRA_LIVE_WS_URL"
|
||||
LIVE_SYMBOLS_ENV = "DZENTRA_LIVE_SYMBOLS"
|
||||
LIVE_TRADE_TIMEOUT_ENV = "DZENTRA_LIVE_TRADE_TIMEOUT_SECONDS"
|
||||
|
||||
DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS = 600.0
|
||||
LIVE_RUNTIME_CLEANUP_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
class LiveTestConfigurationError(ValueError):
|
||||
"""Ошибка явной конфигурации opt-in live verification."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveTradeStreamTestConfig:
|
||||
rest_url: str
|
||||
websocket_url: str
|
||||
symbol: str
|
||||
trade_timeout_seconds: float
|
||||
|
||||
@property
|
||||
def scenario_timeout_seconds(self) -> float:
|
||||
return (
|
||||
self.trade_timeout_seconds * 2
|
||||
+ LIVE_RUNTIME_CLEANUP_TIMEOUT_SECONDS * 2
|
||||
)
|
||||
|
||||
|
||||
def load_live_trade_stream_test_config(
|
||||
environment: Mapping[str, str] | None = None,
|
||||
) -> LiveTradeStreamTestConfig | None:
|
||||
values = environment if environment is not None else os.environ
|
||||
opt_in = values.get(RUN_LIVE_TESTS_ENV, "").strip()
|
||||
|
||||
if opt_in in {"", "0"}:
|
||||
return None
|
||||
|
||||
if opt_in != "1":
|
||||
raise LiveTestConfigurationError(
|
||||
f"{RUN_LIVE_TESTS_ENV} must be exactly 1 when enabled."
|
||||
)
|
||||
|
||||
rest_url = _require_environment_value(
|
||||
values,
|
||||
LIVE_REST_URL_ENV,
|
||||
).rstrip("/")
|
||||
websocket_url = _require_environment_value(
|
||||
values,
|
||||
LIVE_WEBSOCKET_URL_ENV,
|
||||
)
|
||||
symbols_value = _require_environment_value(
|
||||
values,
|
||||
LIVE_SYMBOLS_ENV,
|
||||
)
|
||||
symbols = tuple(
|
||||
symbol.strip()
|
||||
for symbol in symbols_value.split(",")
|
||||
)
|
||||
|
||||
if any(not symbol for symbol in symbols):
|
||||
raise LiveTestConfigurationError(
|
||||
f"{LIVE_SYMBOLS_ENV} must not contain empty symbols."
|
||||
)
|
||||
|
||||
if len(symbols) != 1:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{LIVE_SYMBOLS_ENV} must contain exactly one symbol "
|
||||
"for bounded live verification."
|
||||
)
|
||||
|
||||
_validate_secure_url(
|
||||
rest_url,
|
||||
name=LIVE_REST_URL_ENV,
|
||||
expected_scheme="https",
|
||||
required_path=None,
|
||||
)
|
||||
_validate_secure_url(
|
||||
websocket_url,
|
||||
name=LIVE_WEBSOCKET_URL_ENV,
|
||||
expected_scheme="wss",
|
||||
required_path="/connect",
|
||||
)
|
||||
|
||||
timeout_seconds = _parse_positive_timeout(
|
||||
values.get(
|
||||
LIVE_TRADE_TIMEOUT_ENV,
|
||||
str(DEFAULT_LIVE_TRADE_TIMEOUT_SECONDS),
|
||||
),
|
||||
)
|
||||
|
||||
return LiveTradeStreamTestConfig(
|
||||
rest_url=rest_url,
|
||||
websocket_url=websocket_url,
|
||||
symbol=symbols[0],
|
||||
trade_timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
|
||||
def build_live_trade_stream_settings(
|
||||
config: LiveTradeStreamTestConfig,
|
||||
) -> Settings:
|
||||
from src.core.config import Settings, TradeStreamSettings
|
||||
|
||||
return Settings(
|
||||
bot_token="live-verification-does-not-use-telegram",
|
||||
bot_parse_mode="HTML",
|
||||
app_env="live-verification",
|
||||
log_level="INFO",
|
||||
tz="UTC",
|
||||
exchange_enabled=True,
|
||||
exchange_name="dzengi",
|
||||
exchange_base_url=config.rest_url,
|
||||
exchange_ws_url="",
|
||||
exchange_api_key="",
|
||||
exchange_api_secret="",
|
||||
exchange_timeout_sec=20,
|
||||
exchange_testnet=False,
|
||||
default_symbol=config.symbol,
|
||||
trade_stream=TradeStreamSettings(
|
||||
enabled=True,
|
||||
websocket_url=config.websocket_url,
|
||||
symbols=(config.symbol,),
|
||||
open_timeout_seconds=10.0,
|
||||
probe_timeout_seconds=20.0,
|
||||
close_timeout_seconds=10.0,
|
||||
heartbeat_timeout_seconds=20.0,
|
||||
scheduler_interval_seconds=5.0,
|
||||
recovery_window_ms=3_599_999,
|
||||
),
|
||||
db_host="localhost",
|
||||
db_port=5432,
|
||||
db_name="live-verification",
|
||||
db_user="live-verification",
|
||||
db_password="",
|
||||
debug_enabled=False,
|
||||
journal_debug_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def _require_environment_value(
|
||||
environment: Mapping[str, str],
|
||||
name: str,
|
||||
) -> str:
|
||||
value = environment.get(name, "").strip()
|
||||
|
||||
if not value:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{name} is required when {RUN_LIVE_TESTS_ENV}=1."
|
||||
)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _validate_secure_url(
|
||||
raw_url: str,
|
||||
*,
|
||||
name: str,
|
||||
expected_scheme: str,
|
||||
required_path: str | None,
|
||||
) -> None:
|
||||
parsed = urlsplit(raw_url)
|
||||
|
||||
if parsed.scheme.lower() != expected_scheme or not parsed.netloc:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{name} must be an absolute {expected_scheme} URL."
|
||||
)
|
||||
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{name} must not contain embedded credentials."
|
||||
)
|
||||
|
||||
if parsed.query or parsed.fragment:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{name} must not contain a query or fragment."
|
||||
)
|
||||
|
||||
if required_path is None:
|
||||
if parsed.path not in {"", "/"}:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{name} must be a base URL without an endpoint path."
|
||||
)
|
||||
return
|
||||
|
||||
if parsed.path.rstrip("/") != required_path:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{name} must end with {required_path}."
|
||||
)
|
||||
|
||||
|
||||
def _parse_positive_timeout(raw_value: str) -> float:
|
||||
try:
|
||||
timeout_seconds = float(raw_value.strip())
|
||||
except ValueError as error:
|
||||
raise LiveTestConfigurationError(
|
||||
f"{LIVE_TRADE_TIMEOUT_ENV} must be a number."
|
||||
) from error
|
||||
|
||||
if (
|
||||
not math.isfinite(timeout_seconds)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise LiveTestConfigurationError(
|
||||
f"{LIVE_TRADE_TIMEOUT_ENV} must be positive and finite."
|
||||
)
|
||||
|
||||
return timeout_seconds
|
||||
314
app/tests/support/trade_stream_runtime.py
Normal file
314
app/tests/support/trade_stream_runtime.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any, Protocol
|
||||
|
||||
from src.bootstrap.trade_stream_runtime import (
|
||||
build_trade_stream_production_runtime,
|
||||
)
|
||||
from src.core.config import Settings, TradeStreamSettings
|
||||
from src.market_data.acquisition.consistency.trade_stream_state_store import (
|
||||
TradeStreamStateStore,
|
||||
)
|
||||
from src.market_data.acquisition.runtime.trade_stream_production_runtime import (
|
||||
TradeStreamProductionRuntime,
|
||||
TradeStreamProductionRuntimeState,
|
||||
)
|
||||
from tests.support.async_wait import wait_until
|
||||
|
||||
|
||||
SYMBOL = "BTC/USD_LEVERAGE"
|
||||
RUNTIME_CLEANUP_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
OWNED_TASK_NAMES = frozenset(
|
||||
{
|
||||
"trade-stream-receive",
|
||||
"trade-stream-runtime",
|
||||
"trade-stream-runtime-recovery",
|
||||
"trade-stream-scheduler",
|
||||
"trade-stream-startup",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class ManagedRuntimeProtocol(Protocol):
|
||||
@property
|
||||
def state(self) -> TradeStreamProductionRuntimeState:
|
||||
...
|
||||
|
||||
async def run(self) -> None:
|
||||
...
|
||||
|
||||
async def stop(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
def run_scenario(
|
||||
scenario: Awaitable[None],
|
||||
*,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> None:
|
||||
asyncio.run(
|
||||
asyncio.wait_for(
|
||||
scenario,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def make_settings(
|
||||
*,
|
||||
websocket_url: str,
|
||||
rest_base_url: str,
|
||||
open_timeout_seconds: float = 0.5,
|
||||
probe_timeout_seconds: float = 0.2,
|
||||
close_timeout_seconds: float = 0.2,
|
||||
heartbeat_timeout_seconds: float = 60.0,
|
||||
scheduler_interval_seconds: float = 60.0,
|
||||
) -> Settings:
|
||||
return Settings(
|
||||
bot_token="integration-test-token",
|
||||
bot_parse_mode="HTML",
|
||||
app_env="test",
|
||||
log_level="INFO",
|
||||
tz="UTC",
|
||||
exchange_enabled=True,
|
||||
exchange_name="dzengi",
|
||||
exchange_base_url=rest_base_url,
|
||||
exchange_ws_url="",
|
||||
exchange_api_key="",
|
||||
exchange_api_secret="",
|
||||
exchange_timeout_sec=2,
|
||||
exchange_testnet=True,
|
||||
default_symbol=SYMBOL,
|
||||
trade_stream=TradeStreamSettings(
|
||||
enabled=True,
|
||||
websocket_url=websocket_url,
|
||||
symbols=(SYMBOL,),
|
||||
open_timeout_seconds=open_timeout_seconds,
|
||||
probe_timeout_seconds=probe_timeout_seconds,
|
||||
close_timeout_seconds=close_timeout_seconds,
|
||||
heartbeat_timeout_seconds=heartbeat_timeout_seconds,
|
||||
scheduler_interval_seconds=scheduler_interval_seconds,
|
||||
recovery_window_ms=3_599_999,
|
||||
),
|
||||
db_host="localhost",
|
||||
db_port=5432,
|
||||
db_name="integration",
|
||||
db_user="integration",
|
||||
db_password="",
|
||||
debug_enabled=False,
|
||||
journal_debug_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
def build_runtime(
|
||||
*,
|
||||
websocket_url: str,
|
||||
rest_base_url: str,
|
||||
probe_timeout_seconds: float = 0.2,
|
||||
close_timeout_seconds: float = 0.2,
|
||||
heartbeat_timeout_seconds: float = 60.0,
|
||||
scheduler_interval_seconds: float = 60.0,
|
||||
) -> TradeStreamProductionRuntime:
|
||||
runtime = build_trade_stream_production_runtime(
|
||||
make_settings(
|
||||
websocket_url=websocket_url,
|
||||
rest_base_url=rest_base_url,
|
||||
probe_timeout_seconds=probe_timeout_seconds,
|
||||
close_timeout_seconds=close_timeout_seconds,
|
||||
heartbeat_timeout_seconds=heartbeat_timeout_seconds,
|
||||
scheduler_interval_seconds=scheduler_interval_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
assert runtime is not None
|
||||
return runtime
|
||||
|
||||
|
||||
def state_store_from(
|
||||
runtime: TradeStreamProductionRuntime,
|
||||
) -> TradeStreamStateStore:
|
||||
runtime_graph: Any = runtime
|
||||
|
||||
return (
|
||||
runtime_graph
|
||||
._reconnect_recovery_coordinator
|
||||
._recovery_coordinator
|
||||
._state_store
|
||||
)
|
||||
|
||||
|
||||
def reconnect_coordinator_from(
|
||||
runtime: TradeStreamProductionRuntime,
|
||||
) -> Any:
|
||||
runtime_graph: Any = runtime
|
||||
return runtime_graph._reconnect_recovery_coordinator
|
||||
|
||||
|
||||
def active_owned_task_names() -> tuple[str, ...]:
|
||||
current_task = asyncio.current_task()
|
||||
|
||||
return tuple(
|
||||
sorted(
|
||||
task.get_name()
|
||||
for task in asyncio.all_tasks()
|
||||
if task is not current_task
|
||||
and not task.done()
|
||||
and task.get_name() in OWNED_TASK_NAMES
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def start_runtime(
|
||||
runtime: ManagedRuntimeProtocol,
|
||||
*,
|
||||
timeout_seconds: float = 3.0,
|
||||
) -> asyncio.Task[None]:
|
||||
task = asyncio.create_task(
|
||||
runtime.run(),
|
||||
name="trade-stream-runtime",
|
||||
)
|
||||
|
||||
try:
|
||||
await wait_until(
|
||||
lambda: runtime.state
|
||||
in {
|
||||
TradeStreamProductionRuntimeState.RUNNING,
|
||||
TradeStreamProductionRuntimeState.FAILED,
|
||||
},
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
|
||||
if (
|
||||
runtime.state
|
||||
is TradeStreamProductionRuntimeState.FAILED
|
||||
):
|
||||
await task
|
||||
except BaseException as error:
|
||||
try:
|
||||
await stop_runtime(runtime, task)
|
||||
except BaseException as cleanup_error:
|
||||
error.add_note(
|
||||
"Runtime startup cleanup also failed: "
|
||||
f"{type(cleanup_error).__name__}."
|
||||
)
|
||||
|
||||
raise
|
||||
|
||||
return task
|
||||
|
||||
|
||||
async def wait_until_or_runtime_exit(
|
||||
predicate: Callable[[], bool],
|
||||
*,
|
||||
runtime_task: asyncio.Task[None],
|
||||
timeout_seconds: float,
|
||||
) -> None:
|
||||
"""
|
||||
Дождаться условия, немедленно распространяя завершение Runtime.
|
||||
|
||||
Runtime task не отменяется: helper владеет только внутренней задачей
|
||||
ожидания условия. Если Runtime завершился без ошибки до выполнения
|
||||
условия, это считается ошибкой проверочного сценария.
|
||||
"""
|
||||
condition_task = asyncio.create_task(
|
||||
wait_until(
|
||||
predicate,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.wait(
|
||||
(
|
||||
condition_task,
|
||||
runtime_task,
|
||||
),
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
if runtime_task.done():
|
||||
condition_task.cancel()
|
||||
|
||||
try:
|
||||
await condition_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
await runtime_task
|
||||
|
||||
raise RuntimeError(
|
||||
"Trade Stream Runtime exited before the expected "
|
||||
"live condition was reached."
|
||||
)
|
||||
|
||||
await condition_task
|
||||
finally:
|
||||
if not condition_task.done():
|
||||
condition_task.cancel()
|
||||
|
||||
try:
|
||||
await condition_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
async def stop_runtime(
|
||||
runtime: ManagedRuntimeProtocol,
|
||||
task: asyncio.Task[None],
|
||||
*,
|
||||
timeout_seconds: float = RUNTIME_CLEANUP_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
primary_error: BaseException | None = None
|
||||
|
||||
if not task.done():
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
runtime.stop(),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except BaseException as error:
|
||||
primary_error = error
|
||||
|
||||
if not task.done() and primary_error is not None:
|
||||
task.cancel()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(task),
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except BaseException as error:
|
||||
if isinstance(error, TimeoutError):
|
||||
task.cancel()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
task,
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except BaseException as cancellation_error:
|
||||
error.add_note(
|
||||
"Runtime task cancellation also failed: "
|
||||
f"{type(cancellation_error).__name__}."
|
||||
)
|
||||
|
||||
if primary_error is None:
|
||||
primary_error = error
|
||||
else:
|
||||
primary_error.add_note(
|
||||
"Runtime task cleanup also failed: "
|
||||
f"{type(error).__name__}."
|
||||
)
|
||||
|
||||
if primary_error is not None:
|
||||
raise primary_error
|
||||
|
||||
|
||||
async def assert_no_owned_tasks() -> None:
|
||||
await asyncio.sleep(0)
|
||||
assert active_owned_task_names() == ()
|
||||
Reference in New Issue
Block a user