import time from unittest.mock import AsyncMock, MagicMock import pytest from src.connectors.sdk.circuit_breaker import CircuitBreaker, CircuitState from src.connectors.sdk.retry import RetryPolicy, with_retry from src.connectors.sdk.context import ConnectorContext from src.connectors.sdk.auth import BasicAuthHandler, BearerAuthHandler, APIKeyAuthHandler from src.core.exceptions import ServiceUnavailableException class TestCircuitBreaker: def test_initial_state_is_closed(self): cb = CircuitBreaker(name="test") assert cb.state == CircuitState.CLOSED def test_opens_after_threshold_failures(self): cb = CircuitBreaker(name="test", failure_threshold=3) for _ in range(3): cb._on_failure(Exception("fail")) assert cb._state == CircuitState.OPEN def test_closed_does_not_raise(self): cb = CircuitBreaker(name="test") async def _run(): coro = cb.call(AsyncMock(return_value="ok")) return await coro import asyncio result = asyncio.get_event_loop().run_until_complete(_run()) assert result == "ok" def test_open_raises_service_unavailable(self): cb = CircuitBreaker(name="test", failure_threshold=1) cb._on_failure(Exception("fail")) assert cb.state == CircuitState.OPEN async def _run(): await cb.call(AsyncMock(return_value="ok")) import asyncio with pytest.raises(ServiceUnavailableException): asyncio.get_event_loop().run_until_complete(_run()) def test_half_open_after_recovery_timeout(self): cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) cb._on_failure(Exception("fail")) assert cb._state == CircuitState.OPEN time.sleep(0.02) assert cb.state == CircuitState.HALF_OPEN def test_reset(self): cb = CircuitBreaker(name="test", failure_threshold=1) cb._on_failure(Exception("fail")) cb.reset() assert cb.state == CircuitState.CLOSED assert cb._failure_count == 0 def test_success_resets_failure_count(self): cb = CircuitBreaker(name="test", failure_threshold=5) cb._on_failure(Exception("fail")) cb._on_failure(Exception("fail")) assert cb._failure_count == 2 cb._on_success() assert cb._failure_count == 0 def test_half_open_success_closes_circuit(self): cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) cb._on_failure(Exception("fail")) time.sleep(0.02) assert cb.state == CircuitState.HALF_OPEN cb._on_success() assert cb._state == CircuitState.CLOSED def test_half_open_failure_reopens(self): cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) cb._on_failure(Exception("fail")) time.sleep(0.02) assert cb.state == CircuitState.HALF_OPEN cb._on_failure(Exception("fail again")) assert cb._state == CircuitState.OPEN def test_get_status(self): cb = CircuitBreaker(name="test") status = cb.get_status() assert status["name"] == "test" assert status["state"] == "closed" class TestRetryPolicy: def test_default_values(self): policy = RetryPolicy() assert policy.max_attempts == 3 assert policy.base_delay == 1.0 assert policy.max_delay == 30.0 def test_custom_values(self): policy = RetryPolicy(max_attempts=5, base_delay=0.5, max_delay=10.0) assert policy.max_attempts == 5 assert policy.base_delay == 0.5 assert policy.max_delay == 10.0 class TestWithRetry: @pytest.mark.asyncio async def test_success_first_try(self): call_count = 0 async def _success(): nonlocal call_count call_count += 1 return "ok" policy = RetryPolicy(max_attempts=3) result = await with_retry(_success, policy=policy, connector_type="test", operation="test_op") assert result == "ok" assert call_count == 1 @pytest.mark.asyncio async def test_retry_on_failure_then_success(self): call_count = 0 async def _flaky(): nonlocal call_count call_count += 1 if call_count < 3: raise Exception("temp fail") return "ok" policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1) result = await with_retry(_flaky, policy=policy, connector_type="test", operation="flaky_op") assert result == "ok" assert call_count == 3 @pytest.mark.asyncio async def test_exhaust_retries(self): call_count = 0 async def _always_fail(): nonlocal call_count call_count += 1 raise Exception("always fail") policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1) with pytest.raises(Exception, match="always fail"): await with_retry(_always_fail, policy=policy, connector_type="test", operation="fail_op") assert call_count == 2 class TestConnectorContext: def test_cache_key(self): ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="farmatic", ) key = ctx.cache_key("stock") assert "farmatic" in key assert "stock" in key def test_get_config(self): ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="mock", config={"base_url": "https://example.com"}, ) assert ctx.get_config("base_url") == "https://example.com" assert ctx.get_config("missing", "default") == "default" def test_get_secret(self): ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="mock", secrets={"api_key": "secret123"}, ) assert ctx.get_secret("api_key") == "secret123" assert ctx.get_secret("missing") is None def test_require_secret_raises(self): ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="mock", ) with pytest.raises(ValueError, match="Required secret"): ctx.require_secret("missing") def test_require_config_raises(self): ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="mock", ) with pytest.raises(ValueError, match="Required config"): ctx.require_config("missing") class TestBasicAuthHandler: @pytest.mark.asyncio async def test_apply_basic_auth(self): import httpx handler = BasicAuthHandler() ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="test", config={"username": "user1", "password": "pass1"}, ) request = httpx.Request("GET", "https://example.com") result = await handler.apply(request, ctx) assert "Basic" in result.headers["Authorization"] class TestBearerAuthHandler: @pytest.mark.asyncio async def test_apply_bearer_auth(self): import httpx handler = BearerAuthHandler() ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="test", config={"access_token": "token123"}, ) request = httpx.Request("GET", "https://example.com") result = await handler.apply(request, ctx) assert result.headers["Authorization"] == "Bearer token123" class TestAPIKeyAuthHandler: @pytest.mark.asyncio async def test_apply_api_key_auth(self): import httpx handler = APIKeyAuthHandler(header_name="X-Custom-Key") ctx = ConnectorContext( connector_id=MagicMock(), pharmacy_id=MagicMock(), erp_system_id=MagicMock(), connector_type="test", secrets={"api_key": "key123"}, ) request = httpx.Request("GET", "https://example.com") result = await handler.apply(request, ctx) assert result.headers["X-Custom-Key"] == "key123"