mirror of
https://github.com/hoshikawa2/compass_backoffice.git
synced 2026-07-09 22:04:20 +00:00
289 lines
12 KiB
Python
289 lines
12 KiB
Python
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
|