"""Unit-тесты исполняемого поведения healthcheck-one-liner'а (ET-015). Тестируем именно тот код, который зашит в `docker-compose.yml`, чтобы гарантировать поведение exit-кода в трёх сценариях (UT-01..UT-03): UT-01 (AC-06): exit 0 при HTTP 200. UT-02 (AC-05/AC-06): exit ≠ 0 при недоступном порту. UT-03 (AC-06): exit ≠ 0 при HTTP 500. URL в one-liner подменяется на адрес мок-сервера, остальной код выполняется ровно тот же, что и внутри контейнера. """ from __future__ import annotations import socket import subprocess import sys import threading from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path import pytest import yaml REPO_ROOT = Path(__file__).resolve().parents[2] COMPOSE_PATH = REPO_ROOT / "docker-compose.yml" PROD_HEALTH_URL = "http://localhost:5556/api/health" def _load_oneliner() -> str: """Возвращает 4-й элемент массива test (сам python-код), как в compose.""" data = yaml.safe_load(COMPOSE_PATH.read_text(encoding="utf-8")) test = data["services"]["app"]["healthcheck"]["test"] assert isinstance(test, list) and len(test) >= 4, f"unexpected healthcheck.test: {test!r}" code = test[3] assert isinstance(code, str) return code def _pick_unused_port() -> int: """Свободный TCP-порт на 127.0.0.1 (грязно, но достаточно для теста).""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] def _retarget(code: str, url: str) -> str: """Заменяет prod-URL внутри one-liner на тестовый. Используем именно подмену строки (а не отдельный код), чтобы под тест шла та же логика урлопен + проверки статуса, что и в проде. """ assert PROD_HEALTH_URL in code, ( f"one-liner должен содержать {PROD_HEALTH_URL!r}, иначе ретаргет небезопасен. " f"Код: {code!r}" ) return code.replace(PROD_HEALTH_URL, url) def _run(code: str, timeout: float = 10.0) -> subprocess.CompletedProcess: return subprocess.run( [sys.executable, "-c", code], capture_output=True, text=True, timeout=timeout, ) class _FixedStatusHandler(BaseHTTPRequestHandler): status_code = 200 body = b'{"status":"ok"}' def do_GET(self): # noqa: N802 — имя задано BaseHTTPRequestHandler self.send_response(self.status_code) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(self.body))) self.end_headers() self.wfile.write(self.body) def log_message(self, *_args, **_kwargs): # тишина в pytest-логах pass @pytest.fixture() def mock_server(): """Поднимает локальный HTTPServer с настраиваемым статусом.""" started = [] def _start(status_code: int): handler = type( "_H", (_FixedStatusHandler,), {"status_code": status_code}, ) server = HTTPServer(("127.0.0.1", 0), handler) thread = threading.Thread(target=server.serve_forever, daemon=True) thread.start() started.append((server, thread)) port = server.server_address[1] return f"http://127.0.0.1:{port}/" yield _start for server, thread in started: server.shutdown() server.server_close() thread.join(timeout=2) # ───────────────────────── UT-01 (AC-06) ───────────────────────── def test_ut01_returns_zero_on_http_200(mock_server): url = mock_server(200) code = _retarget(_load_oneliner(), url) result = _run(code) assert result.returncode == 0, ( f"ожидался exit code 0 при HTTP 200, получили {result.returncode}. " f"stderr={result.stderr!r}" ) # ───────────────────────── UT-02 (AC-05/AC-06) ───────────────────────── def test_ut02_returns_nonzero_when_port_unused(): port = _pick_unused_port() code = _retarget(_load_oneliner(), f"http://127.0.0.1:{port}/") result = _run(code) assert result.returncode != 0, ( f"ожидался ненулевой exit code, когда никто не слушает порт, " f"но получили 0. stderr={result.stderr!r}" ) # ───────────────────────── UT-03 (AC-06) ───────────────────────── @pytest.mark.parametrize("status_code", [301, 404, 500, 503]) def test_ut03_returns_nonzero_on_non_2xx(mock_server, status_code): """Любой не-200 ответ должен трактоваться как unhealthy. one-liner из ADR-020 проверяет `status == 200`, всё остальное → exit 1 (либо HTTPError → ненулевой exit). Параметризация — защита от регресса, если кто-то сменит условие на `< 400` и т.п. """ url = mock_server(status_code) code = _retarget(_load_oneliner(), url) result = _run(code) assert result.returncode != 0, ( f"ожидался ненулевой exit code при HTTP {status_code}, " f"получили 0. stderr={result.stderr!r}" )