diff --git a/tests/unit/test_env_utils.py b/tests/unit/test_env_utils.py new file mode 100644 index 000000000..f22196e0b --- /dev/null +++ b/tests/unit/test_env_utils.py @@ -0,0 +1,153 @@ +""" +Tests for utils/env_utils.py +Validates safe_int, safe_float, get_env_int, and get_env_float. +All tests are pure unit tests with no external dependencies. +""" + +import pytest + +from utils.env_utils import get_env_float, get_env_int, safe_float, safe_int + + +# ── safe_int ────────────────────────────────────────────────────────────────── + + +class TestSafeInt: + """safe_int must parse integers and fall back gracefully on bad input.""" + + def test_returns_valid_integer(self): + assert safe_int("42", 0) == 42 + + def test_returns_negative_integer(self): + assert safe_int("-7", 0) == -7 + + def test_returns_zero_string(self): + assert safe_int("0", 99) == 0 + + def test_returns_default_for_none(self): + assert safe_int(None, 5) == 5 + + def test_returns_default_for_empty_string(self): + assert safe_int("", 5) == 5 + + def test_returns_default_for_non_numeric_string(self): + assert safe_int("abc", 10) == 10 + + def test_returns_default_for_float_string(self): + """A bare float string ('3.14') is not a valid int literal.""" + assert safe_int("3.14", 1) == 1 + + def test_returns_default_for_whitespace(self): + """Whitespace-only strings are not valid integers.""" + assert safe_int(" ", 7) == 7 + + def test_accepts_int_value_directly(self): + """Passing an actual int (not a string) should work via int().""" + assert safe_int(100, 0) == 100 + + def test_returns_default_when_none_and_default_is_zero(self): + assert safe_int(None, 0) == 0 + + +# ── safe_float ──────────────────────────────────────────────────────────────── + + +class TestSafeFloat: + """safe_float must parse floats and fall back gracefully on bad input.""" + + def test_returns_valid_float(self): + assert safe_float("3.14", 0.0) == pytest.approx(3.14) + + def test_returns_negative_float(self): + assert safe_float("-2.5", 0.0) == pytest.approx(-2.5) + + def test_returns_integer_string_as_float(self): + assert safe_float("10", 0.0) == pytest.approx(10.0) + + def test_returns_zero_string(self): + assert safe_float("0.0", 99.9) == pytest.approx(0.0) + + def test_returns_default_for_none(self): + assert safe_float(None, 1.5) == pytest.approx(1.5) + + def test_returns_default_for_empty_string(self): + assert safe_float("", 2.0) == pytest.approx(2.0) + + def test_returns_default_for_non_numeric_string(self): + assert safe_float("not_a_number", 9.9) == pytest.approx(9.9) + + def test_returns_default_for_whitespace(self): + assert safe_float(" ", 0.5) == pytest.approx(0.5) + + def test_accepts_float_value_directly(self): + """Passing an actual float (not a string) should work via float().""" + assert safe_float(2.718, 0.0) == pytest.approx(2.718) + + def test_returns_default_when_none_and_default_is_zero(self): + assert safe_float(None, 0.0) == pytest.approx(0.0) + + +# ── get_env_int ─────────────────────────────────────────────────────────────── + + +class TestGetEnvInt: + """get_env_int reads an env var and parses it as an integer.""" + + def test_returns_env_var_as_int(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "25") + assert get_env_int("TEST_INT_VAR", 0) == 25 + + def test_returns_default_when_var_not_set(self, monkeypatch): + monkeypatch.delenv("TEST_INT_VAR", raising=False) + assert get_env_int("TEST_INT_VAR", 42) == 42 + + def test_returns_default_when_var_is_empty(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "") + assert get_env_int("TEST_INT_VAR", 7) == 7 + + def test_returns_default_when_var_is_non_numeric(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "oops") + assert get_env_int("TEST_INT_VAR", 3) == 3 + + def test_returns_negative_env_var(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "-100") + assert get_env_int("TEST_INT_VAR", 0) == -100 + + def test_returns_zero_env_var(self, monkeypatch): + monkeypatch.setenv("TEST_INT_VAR", "0") + assert get_env_int("TEST_INT_VAR", 99) == 0 + + +# ── get_env_float ───────────────────────────────────────────────────────────── + + +class TestGetEnvFloat: + """get_env_float reads an env var and parses it as a float.""" + + def test_returns_env_var_as_float(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "1.5") + assert get_env_float("TEST_FLOAT_VAR", 0.0) == pytest.approx(1.5) + + def test_returns_default_when_var_not_set(self, monkeypatch): + monkeypatch.delenv("TEST_FLOAT_VAR", raising=False) + assert get_env_float("TEST_FLOAT_VAR", 3.14) == pytest.approx(3.14) + + def test_returns_default_when_var_is_empty(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "") + assert get_env_float("TEST_FLOAT_VAR", 2.0) == pytest.approx(2.0) + + def test_returns_default_when_var_is_non_numeric(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "invalid") + assert get_env_float("TEST_FLOAT_VAR", 0.5) == pytest.approx(0.5) + + def test_returns_integer_string_as_float(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "10") + assert get_env_float("TEST_FLOAT_VAR", 0.0) == pytest.approx(10.0) + + def test_returns_negative_env_var(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "-0.75") + assert get_env_float("TEST_FLOAT_VAR", 0.0) == pytest.approx(-0.75) + + def test_returns_zero_env_var(self, monkeypatch): + monkeypatch.setenv("TEST_FLOAT_VAR", "0.0") + assert get_env_float("TEST_FLOAT_VAR", 99.9) == pytest.approx(0.0) diff --git a/tests/unit/test_opensearch_utils.py b/tests/unit/test_opensearch_utils.py new file mode 100644 index 000000000..41556b1f9 --- /dev/null +++ b/tests/unit/test_opensearch_utils.py @@ -0,0 +1,309 @@ +""" +Tests for utils/opensearch_utils.py +Validates wait_for_opensearch retry logic, backoff behavior, and error handling. +All external dependencies (OpenSearch client, sleep, logging) are fully mocked. +""" + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from utils.opensearch_utils import OpenSearchNotReadyError, wait_for_opensearch + + +def _make_health(status: str) -> dict: + """Create a mock cluster health response with the given status.""" + return {"status": status} + + +@pytest.fixture +def mock_opensearch_client(): + """Provide a mocked AsyncOpenSearch client.""" + client = AsyncMock() + client.cluster = AsyncMock() + return client + + +@pytest.fixture(autouse=True) +def no_sleep(): + """Patch asyncio.sleep so tests run instantly.""" + with patch( + "utils.opensearch_utils.asyncio.sleep", new_callable=AsyncMock + ) as mock_sleep: + yield mock_sleep + + +# ── Success on first attempt ────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_ready_on_first_attempt_green(mock_opensearch_client, no_sleep): + """Returns immediately when ping succeeds and cluster status is green.""" + mock_opensearch_client.ping.return_value = True + mock_opensearch_client.cluster.health.return_value = _make_health("green") + + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=3 + ) + + mock_opensearch_client.ping.assert_called_once() + mock_opensearch_client.cluster.health.assert_called_once() + no_sleep.assert_not_called() + + +@pytest.mark.asyncio +async def test_ready_on_first_attempt_yellow(mock_opensearch_client, no_sleep): + """Returns immediately when ping succeeds and cluster status is yellow.""" + mock_opensearch_client.ping.return_value = True + mock_opensearch_client.cluster.health.return_value = _make_health("yellow") + + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=3 + ) + + mock_opensearch_client.ping.assert_called_once() + no_sleep.assert_not_called() + + +# ── Success after transient failures ───────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_ready_after_red_status_then_green(mock_opensearch_client, no_sleep): + """Retries on red cluster status and succeeds when green is returned.""" + mock_opensearch_client.ping.return_value = True + mock_opensearch_client.cluster.health.side_effect = [ + _make_health("red"), + _make_health("green"), + ] + + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=3 + ) + + assert mock_opensearch_client.ping.call_count == 2 + assert mock_opensearch_client.cluster.health.call_count == 2 + assert no_sleep.call_count == 1 + + +@pytest.mark.asyncio +async def test_ready_after_ping_false_then_true(mock_opensearch_client, no_sleep): + """Retries when ping returns False and succeeds when ping returns True.""" + mock_opensearch_client.ping.side_effect = [False, True] + mock_opensearch_client.cluster.health.return_value = _make_health("green") + + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=3 + ) + + assert mock_opensearch_client.ping.call_count == 2 + assert no_sleep.call_count == 1 + + +@pytest.mark.asyncio +async def test_ready_after_exception_then_success(mock_opensearch_client, no_sleep): + """Retries on connection errors and succeeds when the client responds.""" + mock_opensearch_client.ping.side_effect = [ + ConnectionError("refused"), + True, + ] + mock_opensearch_client.cluster.health.return_value = _make_health("yellow") + + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=3 + ) + + assert mock_opensearch_client.ping.call_count == 2 + assert no_sleep.call_count == 1 + + +@pytest.mark.asyncio +async def test_ready_after_mixed_failures(mock_opensearch_client, no_sleep): + """Handles a mix of exceptions, ping=False, and red status before success.""" + mock_opensearch_client.ping.side_effect = [ + ConnectionError("refused"), + False, + True, + True, + ] + mock_opensearch_client.cluster.health.side_effect = [ + _make_health("red"), + _make_health("green"), + ] + + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=5 + ) + + assert mock_opensearch_client.ping.call_count == 4 + assert no_sleep.call_count == 3 + + +# ── Exhausted retries ───────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_raises_after_all_retries_exhausted_red_status( + mock_opensearch_client, +): + """Raises OpenSearchNotReadyError when cluster status is always red.""" + mock_opensearch_client.ping.return_value = True + mock_opensearch_client.cluster.health.return_value = _make_health("red") + + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=3 + ) + + assert mock_opensearch_client.ping.call_count == 3 + + +@pytest.mark.asyncio +async def test_raises_after_all_retries_exhausted_ping_false( + mock_opensearch_client, +): + """Raises OpenSearchNotReadyError when ping always returns False.""" + mock_opensearch_client.ping.return_value = False + + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=3 + ) + + assert mock_opensearch_client.ping.call_count == 3 + mock_opensearch_client.cluster.health.assert_not_called() + + +@pytest.mark.asyncio +async def test_raises_after_all_retries_exhausted_exception( + mock_opensearch_client, +): + """Raises OpenSearchNotReadyError when every attempt raises an exception.""" + mock_opensearch_client.ping.side_effect = ConnectionError("refused") + + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=2 + ) + + assert mock_opensearch_client.ping.call_count == 2 + + +@pytest.mark.asyncio +async def test_single_retry_no_sleep_before_raise(mock_opensearch_client, no_sleep): + """With max_retries=1, fails immediately without sleeping.""" + mock_opensearch_client.ping.return_value = True + mock_opensearch_client.cluster.health.return_value = _make_health("red") + + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=1 + ) + + mock_opensearch_client.ping.assert_called_once() + no_sleep.assert_not_called() + + +# ── Backoff behavior ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_sleep_delay_respects_bounds(mock_opensearch_client, no_sleep): + """Sleep delay stays within [0, max_delay] and never exceeds max_delay.""" + mock_opensearch_client.ping.return_value = False + base_delay = 2.0 + max_delay = 15.0 + + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, + max_retries=5, + base_delay=base_delay, + max_delay=max_delay, + ) + + # 4 sleeps for 5 retries (no sleep after the last attempt) + assert no_sleep.call_count == 4 + + for call in no_sleep.call_args_list: + delay = call.args[0] + assert 0 <= delay <= max_delay + + +@pytest.mark.asyncio +async def test_exponential_backoff_increases(mock_opensearch_client, no_sleep): + """Backoff upper bound doubles each attempt (before capping at max_delay).""" + mock_opensearch_client.ping.return_value = False + + with patch( + "utils.opensearch_utils.random.uniform", side_effect=lambda lo, hi: hi + ): + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, + max_retries=4, + base_delay=2.0, + max_delay=100.0, + ) + + # With jitter pinned to the upper bound, delays should be 2, 4, 8 + delays = [call.args[0] for call in no_sleep.call_args_list] + assert delays == [2.0, 4.0, 8.0] + + +@pytest.mark.asyncio +async def test_max_delay_cap(mock_opensearch_client, no_sleep): + """Delay is capped at max_delay even when exponential growth exceeds it.""" + mock_opensearch_client.ping.return_value = False + + with patch( + "utils.opensearch_utils.random.uniform", side_effect=lambda lo, hi: hi + ): + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, + max_retries=6, + base_delay=2.0, + max_delay=5.0, + ) + + delays = [call.args[0] for call in no_sleep.call_args_list] + # base_delay * 2^attempt: 2, 4, 5(cap), 5(cap), 5(cap) + assert delays == [2.0, 4.0, 5.0, 5.0, 5.0] + + +# ── Edge cases ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_default_parameters(mock_opensearch_client, no_sleep): + """Works correctly with default parameter values.""" + mock_opensearch_client.ping.return_value = True + mock_opensearch_client.cluster.health.return_value = _make_health("green") + + await wait_for_opensearch(opensearch_client=mock_opensearch_client) + + mock_opensearch_client.ping.assert_called_once() + + +@pytest.mark.asyncio +async def test_error_message_content(mock_opensearch_client): + """OpenSearchNotReadyError contains a meaningful message.""" + mock_opensearch_client.ping.return_value = False + + with pytest.raises(OpenSearchNotReadyError, match="Failed to verify"): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=1 + ) + + +@pytest.mark.asyncio +async def test_health_check_skipped_when_ping_false(mock_opensearch_client, no_sleep): + """cluster.health() is never called when ping() returns False.""" + mock_opensearch_client.ping.return_value = False + + with pytest.raises(OpenSearchNotReadyError): + await wait_for_opensearch( + opensearch_client=mock_opensearch_client, max_retries=2 + ) + + mock_opensearch_client.cluster.health.assert_not_called()