import json import pytest import httpx from unittest.mock import patch, MagicMock, AsyncMock from src.components.clients.imdb_client import ImdbClient from src.components.clients.exceptions.imdb_exceptions import ( ImdbClientError, ImdbHttpError, ImdbTimeoutError, ImdbConnectionError, ) # --------------------------------------------------------------------------- # Fixtures # --------------------------------------------------------------------------- @pytest.fixture def client(): return ImdbClient() def _make_mock_response(status_code: int, json_data: dict = None, content: bytes = b"data", headers: dict = None): """Helper to create a mock httpx.Response.""" mock_resp = MagicMock(spec=httpx.Response) mock_resp.status_code = status_code mock_resp.content = content mock_resp.headers = headers or {} if json_data is not None: mock_resp.json.return_value = json_data return mock_resp # --------------------------------------------------------------------------- # Unit tests — helper methods # --------------------------------------------------------------------------- @pytest.mark.unit class TestBuildHeaders: def test_build_headers_without_message_id(self, client): headers = client._build_headers(client_id="test-client") assert headers["clientId"] == "test-client" assert "Authorization" in headers assert headers["Accept"] == "application/json" assert "messageId" not in headers def test_build_headers_with_client_id(self, client): headers = client._build_headers(client_id="test-client") assert headers["clientId"] == "test-client" assert "Authorization" in headers @pytest.mark.unit class TestBuildUrl: def test_build_url_appends_msisdn(self, client): msisdn = "5511999999999" url = client._build_url(msisdn) assert url.endswith(msisdn) assert msisdn in url @pytest.mark.unit class TestParseResponse: def test_parse_response_valid_json(self, client): mock_resp = MagicMock() mock_resp.json.return_value = { "plan": {"name": "TIM Black"}, "statusType": "Active", "statusDescription": "Full Access", "socialSecNo": "06252533106", "extraField": "ignored", } result = client._parse_response(mock_resp) assert result.plan == {"name": "TIM Black"} assert result.statusType == "Active" assert result.statusDescription == "Full Access" assert result.socialSecNo == "06252533106" def test_parse_response_missing_optional_keys(self, client): mock_resp = MagicMock() mock_resp.json.return_value = { "plan": None, "statusType": None, "statusDescription": None } result = client._parse_response(mock_resp) assert result.plan is None assert result.statusType is None assert result.statusDescription is None assert result.socialSecNo is None def test_parse_response_invalid_json_raises_imdb_client_error(self, client): mock_resp = MagicMock() mock_resp.json.side_effect = json.JSONDecodeError("error", "", 0) with pytest.raises(ImdbClientError, match="Invalid JSON response from IMDB API"): client._parse_response(mock_resp) # --------------------------------------------------------------------------- # Unit tests — get_imdb_access_data (async) # --------------------------------------------------------------------------- @pytest.mark.unit @pytest.mark.asyncio class TestGetImdbAccessData: @patch("src.components.clients.imdb_client.traced_async_client") async def test_success_returns_parsed_data(self, mock_async_client_cls, client): # Arrange json_data = {"plan": {"name": "TIM Black"}, "statusType": "Active", "statusDescription": "Full Access"} mock_resp = _make_mock_response(200, json_data=json_data, headers={"Messageid": "msg-123"}) mock_resp.raise_for_status = MagicMock() mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) mock_ctx.__aexit__ = AsyncMock(return_value=False) mock_ctx.get = AsyncMock(return_value=mock_resp) mock_async_client_cls.return_value = mock_ctx # Act result = await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") # Assert assert result.plan == {"name": "TIM Black"} assert result.statusType == "Active" assert result.statusDescription == "Full Access" @patch("src.components.clients.imdb_client.traced_async_client") async def test_204_no_content_returns_none(self, mock_async_client_cls, client): # Arrange mock_resp = _make_mock_response(204, content=b"") mock_resp.content = b"" mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) mock_ctx.__aexit__ = AsyncMock(return_value=False) mock_ctx.get = AsyncMock(return_value=mock_resp) mock_async_client_cls.return_value = mock_ctx # Act result = await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") # Assert assert result is None @patch("src.components.clients.imdb_client.traced_async_client") async def test_http_404_raises_imdb_http_error(self, mock_async_client_cls, client): # Arrange mock_resp = _make_mock_response(404) mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( "Not Found", request=MagicMock(), response=mock_resp ) mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) mock_ctx.__aexit__ = AsyncMock(return_value=False) mock_ctx.get = AsyncMock(return_value=mock_resp) mock_async_client_cls.return_value = mock_ctx # Act & Assert with pytest.raises(ImdbHttpError) as exc_info: await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") assert exc_info.value.status_code == 404 @patch("src.components.clients.imdb_client.traced_async_client") async def test_http_500_raises_imdb_http_error(self, mock_async_client_cls, client): # Arrange mock_resp = _make_mock_response(500) mock_resp.raise_for_status.side_effect = httpx.HTTPStatusError( "Internal Server Error", request=MagicMock(), response=mock_resp ) mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) mock_ctx.__aexit__ = AsyncMock(return_value=False) mock_ctx.get = AsyncMock(return_value=mock_resp) mock_async_client_cls.return_value = mock_ctx # Act & Assert with pytest.raises(ImdbHttpError) as exc_info: await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") assert exc_info.value.status_code == 500 @patch("src.components.clients.imdb_client.traced_async_client") async def test_timeout_raises_imdb_timeout_error(self, mock_async_client_cls, client): # Arrange mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) mock_ctx.__aexit__ = AsyncMock(return_value=False) mock_ctx.get = AsyncMock(side_effect=httpx.TimeoutException("timed out")) mock_async_client_cls.return_value = mock_ctx # Act & Assert with pytest.raises(ImdbTimeoutError): await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") @patch("src.components.clients.imdb_client.traced_async_client") async def test_request_error_raises_imdb_connection_error(self, mock_async_client_cls, client): # Arrange mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) mock_ctx.__aexit__ = AsyncMock(return_value=False) mock_ctx.get = AsyncMock(side_effect=httpx.RequestError("connection refused")) mock_async_client_cls.return_value = mock_ctx # Act & Assert with pytest.raises(ImdbConnectionError): await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") @patch("src.components.clients.imdb_client.traced_async_client") async def test_unexpected_exception_raises_imdb_client_error(self, mock_async_client_cls, client): # Arrange mock_ctx = AsyncMock() mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx) mock_ctx.__aexit__ = AsyncMock(return_value=False) mock_ctx.get = AsyncMock(side_effect=RuntimeError("unexpected")) mock_async_client_cls.return_value = mock_ctx # Act & Assert with pytest.raises(ImdbClientError): await client.get_imdb_access_data(msisdn="5511999999999", client_id="abc") @pytest.mark.unit @pytest.mark.asyncio class TestImdbClientRetry: @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") async def test_retry_on_connection_error_success_second_attempt(self, mock_get_data, client): # Arrange mock_get_data.side_effect = [ ImdbConnectionError("http://test", "timeout"), MagicMock(plan={"name": "TIM Black"}, statusType="Active", statusDescription="Full Access") ] # Act with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()): result = await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc") # Assert assert result.plan == {"name": "TIM Black"} assert mock_get_data.call_count == 2 @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") async def test_retry_on_429_success_second_attempt(self, mock_get_data, client): # Arrange mock_get_data.side_effect = [ ImdbHttpError(429, "Too Many Requests"), MagicMock(plan={"name": "TIM Black"}, statusType="Active", statusDescription="Full Access") ] # Act with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()): result = await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc") # Assert assert result.plan == {"name": "TIM Black"} assert mock_get_data.call_count == 2 @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") async def test_no_retry_on_401_error(self, mock_get_data, client): # Arrange mock_get_data.side_effect = ImdbHttpError(401, "Unauthorized") # Act & Assert with pytest.raises(ImdbHttpError) as exc_info: await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc") assert exc_info.value.status_code == 401 assert mock_get_data.call_count == 1 @patch("src.components.clients.imdb_client.ImdbClient.get_imdb_access_data") async def test_exhaust_retries_raises_exceeded_error(self, mock_get_data, client): # Arrange last_error = ImdbTimeoutError("http://test", 5.0) mock_get_data.side_effect = last_error # Act & Assert from src.components.clients.exceptions.imdb_exceptions import ImdbExceededRetriesError with patch("src.components.clients.imdb_client.asyncio.sleep", AsyncMock()): with pytest.raises(ImdbExceededRetriesError) as exc_info: await client.get_imdb_access_data_with_retry(msisdn="5511999999999", client_id="abc", max_retries=2) assert exc_info.value.attempts == 2 assert mock_get_data.call_count == 3 # Initial + 2 retries