Build 060.30: finalize Market Data Acquisition documentation

This commit is contained in:
2026-08-03 23:30:41 +03:00
parent 8c98de9acc
commit 64a5bdd04c
61 changed files with 13980 additions and 1873 deletions

View File

@@ -1,10 +1,39 @@
from __future__ import annotations
import os
from pathlib import Path
import subprocess
import sys
import traceback
import pytest
from src.core import config as config_module
from src.core.config import load_settings
APP_ROOT = Path(__file__).resolve().parents[3]
_SECRET_VARIABLES = (
"BOT_TOKEN",
"BOT_TOKEN_FILE",
"DB_PASSWORD",
"DB_PASSWORD_FILE",
"EXCHANGE_API_KEY",
"EXCHANGE_API_KEY_FILE",
"EXCHANGE_API_SECRET",
"EXCHANGE_API_SECRET_FILE",
)
_SECRET_ATTRIBUTES = (
("BOT_TOKEN", "bot_token"),
("DB_PASSWORD", "db_password"),
("EXCHANGE_API_KEY", "exchange_api_key"),
("EXCHANGE_API_SECRET", "exchange_api_secret"),
)
_TRADE_STREAM_VARIABLES = (
"TRADE_STREAM_ENABLED",
"TRADE_STREAM_WS_URL",
@@ -30,6 +59,9 @@ _MARKET_DATA_STORAGE_VARIABLES = (
def prepare_environment(
monkeypatch: pytest.MonkeyPatch,
) -> None:
for variable in _SECRET_VARIABLES:
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv("BOT_TOKEN", "test-token")
monkeypatch.delenv("EXCHANGE_BASE_URL", raising=False)
monkeypatch.delenv("EXCHANGE_ENABLED", raising=False)
@@ -389,3 +421,274 @@ def test_pool_max_size_must_not_be_smaller_than_min_size(
match="POOL_MAX_SIZE",
):
load_settings()
@pytest.mark.parametrize(
("variable", "attribute"),
_SECRET_ATTRIBUTES,
)
def test_secret_can_be_loaded_from_file(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
variable: str,
attribute: str,
) -> None:
prepare_environment(monkeypatch)
secret_file = tmp_path / f"{variable.lower()}.secret"
secret_file.write_text("file-secret\n", encoding="utf-8")
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv(f"{variable}_FILE", str(secret_file))
settings = load_settings()
assert getattr(settings, attribute) == "file-secret"
@pytest.mark.parametrize(
("variable", "attribute"),
_SECRET_ATTRIBUTES,
)
def test_direct_secret_remains_supported(
monkeypatch: pytest.MonkeyPatch,
variable: str,
attribute: str,
) -> None:
prepare_environment(monkeypatch)
monkeypatch.setenv(variable, " direct-secret ")
settings = load_settings()
assert getattr(settings, attribute) == "direct-secret"
@pytest.mark.parametrize(
"variable",
tuple(variable for variable, _ in _SECRET_ATTRIBUTES),
)
def test_direct_and_file_secret_are_mutually_exclusive(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
variable: str,
) -> None:
prepare_environment(monkeypatch)
secret_file = tmp_path / "secret"
secret_file.write_text("file-secret\n", encoding="utf-8")
monkeypatch.setenv(variable, "direct-secret")
monkeypatch.setenv(f"{variable}_FILE", str(secret_file))
with pytest.raises(
RuntimeError,
match=f"{variable} and {variable}_FILE",
):
load_settings()
@pytest.mark.parametrize(
"variable",
tuple(variable for variable, _ in _SECRET_ATTRIBUTES),
)
def test_secret_file_path_must_not_be_empty(
monkeypatch: pytest.MonkeyPatch,
variable: str,
) -> None:
prepare_environment(monkeypatch)
monkeypatch.delenv(variable, raising=False)
monkeypatch.setenv(f"{variable}_FILE", " ")
with pytest.raises(
RuntimeError,
match=f"{variable}_FILE must contain a non-empty file path",
):
load_settings()
def test_secret_file_read_error_is_fail_fast(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
prepare_environment(monkeypatch)
missing_file = tmp_path / "missing-secret"
monkeypatch.setenv("DB_PASSWORD_FILE", str(missing_file))
with pytest.raises(RuntimeError) as error_info:
load_settings()
assert str(error_info.value) == (
"Unable to read secret configured by DB_PASSWORD_FILE"
)
assert str(missing_file) not in str(error_info.value)
assert error_info.value.__cause__ is None
assert error_info.value.__suppress_context__ is True
formatted_traceback = "".join(
traceback.format_exception(error_info.value)
)
assert str(missing_file) not in formatted_traceback
@pytest.mark.parametrize(
"content",
(
"",
"\n",
"\r\n",
" \n",
),
)
def test_secret_file_rejects_empty_content(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
content: str,
) -> None:
prepare_environment(monkeypatch)
secret_file = tmp_path / "empty-secret"
secret_file.write_text(content, encoding="utf-8")
monkeypatch.setenv("DB_PASSWORD_FILE", str(secret_file))
with pytest.raises(
RuntimeError,
match="DB_PASSWORD_FILE contains an empty secret",
):
load_settings()
@pytest.mark.parametrize(
("content", "expected"),
(
("secret\n", "secret"),
("secret\r\n", "secret"),
("secret\n\n", "secret\n"),
(" secret \n", " secret "),
),
)
def test_secret_file_removes_only_one_trailing_newline(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
content: str,
expected: str,
) -> None:
prepare_environment(monkeypatch)
secret_file = tmp_path / "secret"
secret_file.write_text(content, encoding="utf-8", newline="")
monkeypatch.setenv("DB_PASSWORD_FILE", str(secret_file))
settings = load_settings()
assert settings.db_password == expected
def test_bot_token_requires_direct_or_file_secret(
monkeypatch: pytest.MonkeyPatch,
) -> None:
prepare_environment(monkeypatch)
monkeypatch.delenv("BOT_TOKEN", raising=False)
with pytest.raises(
RuntimeError,
match="BOT_TOKEN or BOT_TOKEN_FILE is required",
):
load_settings()
def test_secret_error_and_logs_do_not_contain_secret_value(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
prepare_environment(monkeypatch)
unique_secret = "secret-never-log-06030-4b7c1a"
secret_file = tmp_path / "bot-token"
secret_file.write_text("file-secret\n", encoding="utf-8")
monkeypatch.setenv("BOT_TOKEN", unique_secret)
monkeypatch.setenv("BOT_TOKEN_FILE", str(secret_file))
with pytest.raises(RuntimeError) as error_info:
load_settings()
assert str(error_info.value) == (
"BOT_TOKEN and BOT_TOKEN_FILE must not be set together"
)
assert unique_secret not in str(error_info.value)
assert unique_secret not in caplog.text
def test_runtime_env_file_can_be_selected_before_config_import(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
runtime_env_file = tmp_path / "runtime.env"
monkeypatch.setenv(
"DZENTRA_RUNTIME_ENV_FILE",
str(runtime_env_file),
)
assert config_module._resolve_env_file() == runtime_env_file
def test_blank_runtime_env_file_is_rejected(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setenv("DZENTRA_RUNTIME_ENV_FILE", " ")
with pytest.raises(
RuntimeError,
match="DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path",
):
config_module._resolve_env_file()
def test_runtime_env_file_is_loaded_at_import_and_shared_with_writer(
tmp_path: Path,
) -> None:
runtime_env_file = tmp_path / "runtime.env"
runtime_env_file.write_text(
"DZENTRA_IMPORT_SENTINEL=loaded-from-runtime-file\n",
encoding="utf-8",
)
environment = os.environ.copy()
environment.pop("DZENTRA_IMPORT_SENTINEL", None)
environment["DZENTRA_RUNTIME_ENV_FILE"] = str(runtime_env_file)
command = (
"import os; "
"import src.telegram.routers; "
"from src.core.config import ENV_FILE; "
"import src.telegram.handlers.system as system_module; "
"print(ENV_FILE); "
"print(system_module.ENV_FILE); "
"print(os.environ.get('DZENTRA_IMPORT_SENTINEL', ''))"
)
completed = subprocess.run(
[sys.executable, "-c", command],
cwd=APP_ROOT,
env=environment,
check=False,
capture_output=True,
text=True,
)
assert completed.returncode == 0, completed.stderr
assert completed.stdout.splitlines() == [
str(runtime_env_file),
str(runtime_env_file),
"loaded-from-runtime-file",
]
def test_blank_runtime_env_file_fails_during_import() -> None:
environment = os.environ.copy()
environment["DZENTRA_RUNTIME_ENV_FILE"] = " "
completed = subprocess.run(
[sys.executable, "-c", "from src.core.config import ENV_FILE"],
cwd=APP_ROOT,
env=environment,
check=False,
capture_output=True,
text=True,
)
assert completed.returncode != 0
assert (
"DZENTRA_RUNTIME_ENV_FILE must contain a non-empty file path"
in completed.stderr
)