diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..859f1de --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,168 @@ +"""Pytest configuration and fixtures for FastGPT client tests.""" + +import pytest +from unittest.mock import Mock, MagicMock +import httpx + + +@pytest.fixture +def api_key(): + """Test API key.""" + return "fastgpt-test-key-12345" + + +@pytest.fixture +def base_url(): + """Test base URL.""" + return "http://localhost:3000" + + +@pytest.fixture +def mock_response(): + """Create a mock httpx.Response object.""" + response = Mock(spec=httpx.Response) + response.status_code = 200 + response.headers = {} + response._content = b'{"data": "test"}' + response.request = Mock() + response.request.method = "GET" + response.request.url = "http://test.com" + return response + + +@pytest.fixture +def mock_stream_response(): + """Create a mock streaming httpx.Response object.""" + response = Mock(spec=httpx.Response) + response.status_code = 200 + response.headers = {} + response._content = b'data: {"content": "test"}\n\n' + response.request = Mock() + response.request.method = "POST" + response.request.url = "http://test.com/stream" + response.iter_lines = Mock(return_value=[b'data: {"content": "test"}\n\n']) + return response + + +@pytest.fixture +def mock_httpx_client(): + """Create a mock httpx.Client.""" + client = Mock(spec=httpx.Client) + client.is_closed = False + return client + + +@pytest.fixture +def sample_chat_response(): + """Sample chat completion response data.""" + return { + "id": "chatcmpl-123", + "object": "chat.completion", + "created": 1234567890, + "model": "gpt-3.5-turbo", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "Hello! How can I help you today?" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 20, + "total_tokens": 30 + } + } + + +@pytest.fixture +def sample_chat_histories_response(): + """Sample chat histories response data.""" + return { + "data": [ + { + "chatId": "chat-123", + "customTitle": "Test Chat", + "time": 1234567890, + "top": False + } + ], + "total": 1 + } + + +@pytest.fixture +def sample_chat_records_response(): + """Sample chat records response data.""" + return { + "data": [ + { + "dataId": "msg-123", + "content": { + "text": "Hello!" + }, + "time": 1234567890, + "feedback": { + "userGoodFeedback": "Great!", + "userBadFeedback": None + } + } + ] + } + + +@pytest.fixture +def sample_app_logs_response(): + """Sample app logs chart response data.""" + return { + "data": { + "users": { + "day": [ + {"time": "2024-01-01", "count": 10}, + {"time": "2024-01-02", "count": 15} + ] + }, + "chats": { + "day": [ + {"time": "2024-01-01", "count": 25}, + {"time": "2024-01-02", "count": 30} + ] + }, + "app": { + "day": [ + {"time": "2024-01-01", "count": 100}, + {"time": "2024-01-02", "count": 120} + ] + } + } + } + + +@pytest.fixture +def error_responses(): + """Sample error response data.""" + return { + "authentication_error": { + "code": "invalid_api_key", + "message": "Invalid API key", + "status": 401 + }, + "rate_limit_error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded", + "status": 429 + }, + "validation_error": { + "code": "invalid_parameters", + "message": "Invalid parameters", + "status": 422 + }, + "server_error": { + "code": "internal_error", + "message": "Internal server error", + "status": 500 + } + } diff --git a/tests/test_app_client.py b/tests/test_app_client.py new file mode 100644 index 0000000..051cfd5 --- /dev/null +++ b/tests/test_app_client.py @@ -0,0 +1,180 @@ +"""Tests for AppClient.""" + +from unittest.mock import Mock, patch +import pytest + +import httpx + +from fastgpt_client.client import AppClient + + +class TestAppClientGetAppLogsChart: + """Test suite for AppClient.get_app_logs_chart method.""" + + def test_get_app_logs_chart_basic(self, api_key, sample_app_logs_response): + """Test getting app logs chart with basic parameters.""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json = Mock(return_value=sample_app_logs_response) + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-01-31" + ) + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/proApi/core/app/logs/getChartData" + json_data = call_args[1]['json'] + assert json_data['appId'] == "app-123" + assert json_data['dateStart'] == "2024-01-01" + assert json_data['dateEnd'] == "2024-01-31" + # Default source should be ["api"] + assert json_data['source'] == ["api"] + + def test_get_app_logs_chart_with_offset(self, api_key): + """Test getting app logs chart with custom offset.""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-01-31", + offset=5 + ) + + assert mock_send.call_args[1]['json']['offset'] == 5 + + def test_get_app_logs_chart_with_source_list(self, api_key): + """Test getting app logs chart with custom source list.""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + sources = ["api", "online", "share"] + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-01-31", + source=sources + ) + + assert mock_send.call_args[1]['json']['source'] == sources + + def test_get_app_logs_chart_with_all_timespans(self, api_key): + """Test getting app logs chart with custom timespans.""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-01-31", + userTimespan="week", + chatTimespan="month", + appTimespan="day" + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['userTimespan'] == "week" + assert json_data['chatTimespan'] == "month" + assert json_data['appTimespan'] == "day" + + def test_get_app_logs_chart_all_parameters(self, api_key): + """Test getting app logs chart with all parameters.""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-12-31", + offset=10, + source=["api", "online", "share", "test"], + userTimespan="month", + chatTimespan="week", + appTimespan="day" + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data == { + "appId": "app-123", + "dateStart": "2024-01-01", + "dateEnd": "2024-12-31", + "offset": 10, + "source": ["api", "online", "share", "test"], + "userTimespan": "month", + "chatTimespan": "week", + "appTimespan": "day" + } + + def test_get_app_logs_chart_source_none(self, api_key): + """Test that source=None results in default ['api'].""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-01-31", + source=None + ) + + # When source is None, it defaults to ["api"] + assert mock_send.call_args[1]['json']['source'] == ["api"] + + def test_get_app_logs_chart_default_timespans(self, api_key): + """Test that default timespans are 'day'.""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-01-31" + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['userTimespan'] == "day" + assert json_data['chatTimespan'] == "day" + assert json_data['appTimespan'] == "day" + + def test_get_app_logs_chart_default_offset(self, api_key): + """Test that default offset is 1.""" + client = AppClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_app_logs_chart( + appId="app-123", + dateStart="2024-01-01", + dateEnd="2024-01-31" + ) + + assert mock_send.call_args[1]['json']['offset'] == 1 diff --git a/tests/test_base_client.py b/tests/test_base_client.py new file mode 100644 index 0000000..438fc88 --- /dev/null +++ b/tests/test_base_client.py @@ -0,0 +1,220 @@ +"""Tests for BaseClientMixin.""" + +import time +from unittest.mock import Mock, patch + +import pytest +import httpx + +from fastgpt_client.base_client import BaseClientMixin +from fastgpt_client.exceptions import APIError, ValidationError + + +class TestBaseClientMixin: + """Test suite for BaseClientMixin.""" + + def test_init_default_parameters(self, api_key, base_url): + """Test initialization with default parameters.""" + mixin = BaseClientMixin(api_key, base_url) + + assert mixin.api_key == api_key + assert mixin.base_url == base_url + assert mixin.timeout == 60.0 + assert mixin.max_retries == 3 + assert mixin.retry_delay == 1.0 + assert mixin.enable_logging is False + + def test_init_custom_parameters(self, api_key, base_url): + """Test initialization with custom parameters.""" + mixin = BaseClientMixin( + api_key, + base_url, + timeout=120.0, + max_retries=5, + retry_delay=2.0, + enable_logging=True + ) + + assert mixin.timeout == 120.0 + assert mixin.max_retries == 5 + assert mixin.retry_delay == 2.0 + assert mixin.enable_logging is True + + def test_validate_params_with_valid_params(self, api_key, base_url): + """Test parameter validation with valid parameters.""" + mixin = BaseClientMixin(api_key, base_url) + + # Should not raise any exception + mixin._validate_params( + query="test query", + chatId="chat-123", + appId="app-123", + dataId="data-123", + content="some content" + ) + + def test_validate_params_with_empty_strings(self, api_key, base_url): + """Test parameter validation with empty strings.""" + mixin = BaseClientMixin(api_key, base_url) + + with pytest.raises(ValidationError) as exc_info: + mixin._validate_params(query="") + + assert "query must be a non-empty string" in str(exc_info.value) + + def test_validate_params_with_whitespace_only(self, api_key, base_url): + """Test parameter validation with whitespace-only strings.""" + mixin = BaseClientMixin(api_key, base_url) + + with pytest.raises(ValidationError) as exc_info: + mixin._validate_params(chatId=" ") + + assert "chatId must be a non-empty string" in str(exc_info.value) + + def test_validate_params_with_none_values(self, api_key, base_url): + """Test parameter validation ignores None values.""" + mixin = BaseClientMixin(api_key, base_url) + + # Should not raise any exception for None values + mixin._validate_params( + query=None, + chatId=None, + appId="app-123" # Only one non-None value + ) + + def test_validate_params_with_empty_collections(self, api_key, base_url): + """Test parameter validation with empty collections.""" + mixin = BaseClientMixin( + api_key, + base_url, + enable_logging=True + ) + + # Should not raise exception, just log debug message + mixin._validate_params( + messages=[], + variables={} + ) + + def test_retry_request_success_on_first_attempt(self, api_key, base_url, mock_response): + """Test retry request succeeds on first attempt.""" + mixin = BaseClientMixin(api_key, base_url) + + request_func = Mock(return_value=mock_response) + result = mixin._retry_request(request_func, "GET /api/test") + + assert result == mock_response + assert request_func.call_count == 1 + + def test_retry_request_success_on_second_attempt(self, api_key, base_url, mock_response): + """Test retry request succeeds on second attempt after 500 error.""" + mixin = BaseClientMixin(api_key, base_url, max_retries=3, retry_delay=0.01) + + # First call returns 500, second succeeds + error_response = Mock(spec=httpx.Response) + error_response.status_code = 500 + + request_func = Mock(side_effect=[error_response, mock_response]) + + start_time = time.time() + result = mixin._retry_request(request_func, "GET /api/test") + elapsed_time = time.time() - start_time + + assert result == mock_response + assert request_func.call_count == 2 + # Should have slept at least retry_delay seconds (with some tolerance for test execution) + assert elapsed_time >= 0.005 # Allow some tolerance + + def test_retry_request_exhausted_with_server_errors(self, api_key, base_url): + """Test retry request exhausted by server errors.""" + mixin = BaseClientMixin(api_key, base_url, max_retries=2, retry_delay=0.01) + + error_response = Mock(spec=httpx.Response) + error_response.status_code = 500 + request_func = Mock(return_value=error_response) + + with pytest.raises(APIError) as exc_info: + mixin._retry_request(request_func, "GET /api/test") + + assert "failed after 2 attempts" in str(exc_info.value) + assert request_func.call_count == 2 + + def test_retry_request_with_network_exception(self, api_key, base_url): + """Test retry request with network exception.""" + mixin = BaseClientMixin(api_key, base_url, max_retries=2, retry_delay=0.01) + + request_func = Mock(side_effect=httpx.ConnectError("Connection failed")) + + with pytest.raises(APIError) as exc_info: + mixin._retry_request(request_func, "GET /api/test") + + assert "failed after 2 attempts" in str(exc_info.value) + assert "Connection failed" in str(exc_info.value) + assert request_func.call_count == 2 + + def test_retry_request_exponential_backoff(self, api_key, base_url, mock_response): + """Test exponential backoff in retry logic.""" + mixin = BaseClientMixin(api_key, base_url, max_retries=4, retry_delay=0.05) + + error_response = Mock(spec=httpx.Response) + error_response.status_code = 503 + + # First 3 attempts fail, last succeeds + request_func = Mock(side_effect=[ + error_response, + error_response, + error_response, + mock_response + ]) + + start_time = time.time() + result = mixin._retry_request(request_func, "GET /api/test") + elapsed_time = time.time() - start_time + + assert result == mock_response + assert request_func.call_count == 4 + + # Exponential backoff: 0.05 + 0.10 + 0.20 = 0.35 seconds minimum + # Allow some tolerance for test execution time + assert elapsed_time >= 0.25 + + def test_retry_request_4xx_no_retry(self, api_key, base_url): + """Test that 4xx errors don't trigger retries.""" + mixin = BaseClientMixin(api_key, base_url, max_retries=3, retry_delay=0.01) + + error_response = Mock(spec=httpx.Response) + error_response.status_code = 404 + request_func = Mock(return_value=error_response) + + result = mixin._retry_request(request_func, "GET /api/test") + + # Should return the 4xx response without retrying + assert result == error_response + assert request_func.call_count == 1 + + def test_retry_request_mixed_exception_and_success(self, api_key, base_url, mock_response): + """Test retry request with exception followed by success.""" + mixin = BaseClientMixin(api_key, base_url, max_retries=3, retry_delay=0.01) + + request_func = Mock(side_effect=[ + httpx.TimeoutException("Timeout"), + mock_response + ]) + + result = mixin._retry_request(request_func, "POST /api/test") + + assert result == mock_response + assert request_func.call_count == 2 + + def test_retry_request_all_attempts_with_exceptions(self, api_key, base_url): + """Test retry request when all attempts raise exceptions.""" + mixin = BaseClientMixin(api_key, base_url, max_retries=2, retry_delay=0.01) + + request_func = Mock(side_effect=httpx.TimeoutException("Timeout")) + + with pytest.raises(APIError) as exc_info: + mixin._retry_request(request_func, "POST /api/test") + + assert "failed after 2 attempts" in str(exc_info.value) + assert "Timeout" in str(exc_info.value) + assert request_func.call_count == 2 diff --git a/tests/test_chat_client.py b/tests/test_chat_client.py new file mode 100644 index 0000000..b4e4e57 --- /dev/null +++ b/tests/test_chat_client.py @@ -0,0 +1,626 @@ +"""Tests for ChatClient.""" + +from unittest.mock import Mock, patch +import pytest + +import httpx + +from fastgpt_client.client import ChatClient +from fastgpt_client.exceptions import ValidationError + + +class TestChatClientCreateChatCompletion: + """Test suite for ChatClient.create_chat_completion method.""" + + def test_create_chat_completion_basic(self, api_key, sample_chat_response): + """Test basic chat completion creation.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json = Mock(return_value=sample_chat_response) + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + stream=False + ) + + assert response.status_code == 200 + mock_send.assert_called_once() + call_args = mock_send.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/v1/chat/completions" + assert call_args[1]['json']['messages'] == [{"role": "user", "content": "Hello"}] + assert call_args[1]['stream'] is False + + def test_create_chat_completion_with_chat_id(self, api_key): + """Test chat completion with chatId parameter.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + chatId="chat-123" + ) + + assert response.status_code == 200 + assert mock_send.call_args[1]['json']['chatId'] == "chat-123" + + def test_create_chat_completion_with_variables(self, api_key): + """Test chat completion with variables parameter.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + variables={"name": "John", "city": "NYC"} + ) + + assert response.status_code == 200 + assert mock_send.call_args[1]['json']['variables'] == {"name": "John", "city": "NYC"} + + def test_create_chat_completion_with_detail(self, api_key): + """Test chat completion with detail enabled.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + detail=True + ) + + assert response.status_code == 200 + assert mock_send.call_args[1]['json']['detail'] is True + + def test_create_chat_completion_with_response_chat_item_id(self, api_key): + """Test chat completion with custom responseChatItemId.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + responseChatItemId="custom-id-123" + ) + + assert response.status_code == 200 + assert mock_send.call_args[1]['json']['responseChatItemId'] == "custom-id-123" + + def test_create_chat_completion_streaming(self, api_key, mock_stream_response): + """Test streaming chat completion.""" + client = ChatClient(api_key) + + with patch.object(client, '_send_request', return_value=mock_stream_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + stream=True + ) + + assert response.status_code == 200 + assert mock_send.call_args[1]['stream'] is True + + def test_create_chat_completion_all_parameters(self, api_key): + """Test chat completion with all parameters.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + stream=True, + chatId="chat-123", + detail=True, + variables={"key": "value"}, + responseChatItemId="custom-id" + ) + + assert response.status_code == 200 + json_data = mock_send.call_args[1]['json'] + assert json_data['chatId'] == "chat-123" + assert json_data['detail'] is True + assert json_data['variables'] == {"key": "value"} + assert json_data['responseChatItemId'] == "custom-id" + + def test_create_chat_completion_parameter_validation(self, api_key): + """Test that parameter validation works for known fields.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + # Test with valid chatId (should pass validation) + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.create_chat_completion( + messages=[{"role": "user", "content": "Hello"}], + chatId="valid-chat-id" + ) + + # Verify the request was sent successfully + assert response.status_code == 200 + mock_send.assert_called_once() + + +class TestChatClientGetChatHistories: + """Test suite for ChatClient.get_chat_histories method.""" + + def test_get_chat_histories_basic(self, api_key, sample_chat_histories_response): + """Test getting chat histories with basic parameters.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json = Mock(return_value=sample_chat_histories_response) + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_histories(appId="app-123") + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/core/chat/getHistories" + assert call_args[1]['json']['appId'] == "app-123" + + def test_get_chat_histories_with_pagination(self, api_key): + """Test getting chat histories with pagination.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_histories( + appId="app-123", + offset=10, + pageSize=50 + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['offset'] == 10 + assert json_data['pageSize'] == 50 + + def test_get_chat_histories_with_source(self, api_key): + """Test getting chat histories with source filter.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_histories( + appId="app-123", + source="online" + ) + + assert mock_send.call_args[1]['json']['source'] == "online" + + def test_get_chat_histories_all_parameters(self, api_key): + """Test getting chat histories with all parameters.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_histories( + appId="app-123", + offset=5, + pageSize=25, + source="share" + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data == { + "appId": "app-123", + "offset": 5, + "pageSize": 25, + "source": "share" + } + + +class TestChatClientGetChatInit: + """Test suite for ChatClient.get_chat_init method.""" + + def test_get_chat_init(self, api_key): + """Test getting chat initialization.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_init(appId="app-123", chatId="chat-123") + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/core/chat/init" + assert call_args[1]['params'] == {"appId": "app-123", "chatId": "chat-123"} + + +class TestChatClientGetChatRecords: + """Test suite for ChatClient.get_chat_records method.""" + + def test_get_chat_records_basic(self, api_key, sample_chat_records_response): + """Test getting chat records with basic parameters.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + mock_response.json = Mock(return_value=sample_chat_records_response) + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_records(appId="app-123", chatId="chat-123") + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/core/chat/getPaginationRecords" + json_data = call_args[1]['json'] + assert json_data['appId'] == "app-123" + assert json_data['chatId'] == "chat-123" + + def test_get_chat_records_with_pagination(self, api_key): + """Test getting chat records with pagination.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_records( + appId="app-123", + chatId="chat-123", + offset=20, + pageSize=30 + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['offset'] == 20 + assert json_data['pageSize'] == 30 + + def test_get_chat_records_with_custom_feedbacks(self, api_key): + """Test getting chat records with custom feedbacks loaded.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_records( + appId="app-123", + chatId="chat-123", + loadCustomFeedbacks=True + ) + + assert mock_send.call_args[1]['json']['loadCustomFeedbacks'] is True + + def test_get_chat_records_all_parameters(self, api_key): + """Test getting chat records with all parameters.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_chat_records( + appId="app-123", + chatId="chat-123", + offset=10, + pageSize=20, + loadCustomFeedbacks=True + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data == { + "appId": "app-123", + "chatId": "chat-123", + "offset": 10, + "pageSize": 20, + "loadCustomFeedbacks": True + } + + +class TestChatClientGetRecordDetail: + """Test suite for ChatClient.get_record_detail method.""" + + def test_get_record_detail(self, api_key): + """Test getting record detail.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_record_detail( + appId="app-123", + chatId="chat-123", + dataId="data-123" + ) + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "GET" + assert call_args[0][1] == "/api/core/chat/getResData" + assert call_args[1]['params'] == { + "appId": "app-123", + "chatId": "chat-123", + "dataId": "data-123" + } + + +class TestChatClientUpdateChatHistory: + """Test suite for ChatClient.update_chat_history method.""" + + def test_update_chat_history_title(self, api_key): + """Test updating chat history title.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.update_chat_history( + appId="app-123", + chatId="chat-123", + customTitle="New Title" + ) + + assert response.status_code == 200 + json_data = mock_send.call_args[1]['json'] + assert json_data['appId'] == "app-123" + assert json_data['chatId'] == "chat-123" + assert json_data['customTitle'] == "New Title" + assert 'top' not in json_data + + def test_update_chat_history_pin(self, api_key): + """Test pinning chat history.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.update_chat_history( + appId="app-123", + chatId="chat-123", + top=True + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['top'] is True + assert 'customTitle' not in json_data + + def test_update_chat_history_both_parameters(self, api_key): + """Test updating both title and pin status.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.update_chat_history( + appId="app-123", + chatId="chat-123", + customTitle="Important Chat", + top=True + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['customTitle'] == "Important Chat" + assert json_data['top'] is True + + +class TestChatClientDeleteChatHistory: + """Test suite for ChatClient.delete_chat_history method.""" + + def test_delete_chat_history(self, api_key): + """Test deleting a chat history.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.delete_chat_history(appId="app-123", chatId="chat-123") + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "DELETE" + assert call_args[0][1] == "/api/core/chat/delHistory" + assert call_args[1]['params'] == {"appId": "app-123", "chatId": "chat-123"} + + +class TestChatClientClearChatHistories: + """Test suite for ChatClient.clear_chat_histories method.""" + + def test_clear_chat_histories(self, api_key): + """Test clearing all chat histories.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.clear_chat_histories(appId="app-123") + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "DELETE" + assert call_args[0][1] == "/api/core/chat/clearHistories" + assert call_args[1]['params'] == {"appId": "app-123"} + + +class TestChatClientDeleteChatRecord: + """Test suite for ChatClient.delete_chat_record method.""" + + def test_delete_chat_record(self, api_key): + """Test deleting a single chat record.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.delete_chat_record( + appId="app-123", + chatId="chat-123", + contentId="content-123" + ) + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "DELETE" + assert call_args[0][1] == "/api/core/chat/item/delete" + assert call_args[1]['params'] == { + "appId": "app-123", + "chatId": "chat-123", + "contentId": "content-123" + } + + +class TestChatClientSendFeedback: + """Test suite for ChatClient.send_feedback method.""" + + def test_send_feedback_good(self, api_key): + """Test sending positive feedback.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.send_feedback( + appId="app-123", + chatId="chat-123", + dataId="data-123", + userGoodFeedback="Great response!" + ) + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/core/chat/feedback/updateUserFeedback" + json_data = call_args[1]['json'] + assert json_data['userGoodFeedback'] == "Great response!" + assert 'userBadFeedback' not in json_data + + def test_send_feedback_bad(self, api_key): + """Test sending negative feedback.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.send_feedback( + appId="app-123", + chatId="chat-123", + dataId="data-123", + userBadFeedback="Not helpful" + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['userBadFeedback'] == "Not helpful" + assert 'userGoodFeedback' not in json_data + + def test_send_feedback_cancel_good(self, api_key): + """Test canceling positive feedback by passing empty string.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.send_feedback( + appId="app-123", + chatId="chat-123", + dataId="data-123", + userGoodFeedback="" + ) + + # Empty string is still sent (to cancel the feedback) + json_data = mock_send.call_args[1]['json'] + assert 'userGoodFeedback' in json_data + + def test_send_feedback_only_required_params(self, api_key): + """Test sending feedback with only required parameters.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.send_feedback( + appId="app-123", + chatId="chat-123", + dataId="data-123" + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data == { + "appId": "app-123", + "chatId": "chat-123", + "dataId": "data-123" + } + + +class TestChatClientGetSuggestedQuestions: + """Test suite for ChatClient.get_suggested_questions method.""" + + def test_get_suggested_questions_basic(self, api_key): + """Test getting suggested questions.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_suggested_questions( + appId="app-123", + chatId="chat-123" + ) + + assert response.status_code == 200 + call_args = mock_send.call_args + assert call_args[0][0] == "POST" + assert call_args[0][1] == "/api/core/ai/agent/v2/createQuestionGuide" + json_data = call_args[1]['json'] + assert json_data['appId'] == "app-123" + assert json_data['chatId'] == "chat-123" + assert 'questionGuide' not in json_data + + def test_get_suggested_questions_with_guide(self, api_key): + """Test getting suggested questions with custom question guide.""" + client = ChatClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + question_guide = { + "maxQuestions": 5, + "contextWindow": 10 + } + + with patch.object(client, '_send_request', return_value=mock_response) as mock_send: + response = client.get_suggested_questions( + appId="app-123", + chatId="chat-123", + questionGuide=question_guide + ) + + json_data = mock_send.call_args[1]['json'] + assert json_data['questionGuide'] == question_guide diff --git a/tests/test_client.py b/tests/test_client.py new file mode 100644 index 0000000..995545d --- /dev/null +++ b/tests/test_client.py @@ -0,0 +1,387 @@ +"""Tests for FastGPTClient.""" + +from unittest.mock import Mock, MagicMock, patch +import pytest + +import httpx + +from fastgpt_client.client import FastGPTClient +from fastgpt_client.exceptions import ( + APIError, + AuthenticationError, + RateLimitError, + ValidationError, +) + + +class TestFastGPTClient: + """Test suite for FastGPTClient.""" + + def test_init_default(self, api_key, base_url): + """Test client initialization with defaults.""" + client = FastGPTClient(api_key) + + assert client.api_key == api_key + assert client.base_url == base_url + assert client.timeout == 60.0 + assert client.max_retries == 3 + assert client.retry_delay == 1.0 + assert client.enable_logging is False + assert isinstance(client._client, httpx.Client) + + def test_init_custom_parameters(self, api_key): + """Test client initialization with custom parameters.""" + custom_url = "https://api.fastgpt.com" + client = FastGPTClient( + api_key, + base_url=custom_url, + timeout=120.0, + max_retries=5, + retry_delay=2.0, + enable_logging=True + ) + + assert client.api_key == api_key + assert client.base_url == custom_url + assert client.timeout == 120.0 + assert client.max_retries == 5 + assert client.retry_delay == 2.0 + assert client.enable_logging is True + + def test_context_manager(self, api_key): + """Test using client as context manager.""" + with FastGPTClient(api_key) as client: + assert client.api_key == api_key + assert not client._client.is_closed + + # Client should be closed after exiting context + assert client._client.is_closed + + def test_context_manager_with_exception(self, api_key): + """Test context manager properly closes on exception.""" + client = FastGPTClient(api_key) + + try: + with client: + raise ValueError("Test exception") + except ValueError: + pass + + # Client should still be closed even with exception + assert client._client.is_closed + + def test_close(self, api_key): + """Test closing the client.""" + client = FastGPTClient(api_key) + assert not client._client.is_closed + + client.close() + assert client._client.is_closed + + def test_close_idempotent(self, api_key): + """Test that close can be called multiple times safely.""" + client = FastGPTClient(api_key) + + client.close() + client.close() # Should not raise an exception + assert client._client.is_closed + + +class TestFastGPTClientSendRequest: + """Test suite for FastGPTClient._send_request method.""" + + def test_send_request_get_success(self, api_key, mock_response): + """Test successful GET request.""" + client = FastGPTClient(api_key) + + with patch.object(client._client, 'request', return_value=mock_response): + response = client._send_request("GET", "/api/test") + + assert response.status_code == 200 + + def test_send_request_post_success(self, api_key, mock_response): + """Test successful POST request with JSON body.""" + client = FastGPTClient(api_key) + + with patch.object(client._client, 'request', return_value=mock_response) as mock_request: + response = client._send_request( + "POST", + "/api/test", + json={"key": "value"} + ) + + assert response.status_code == 200 + mock_request.assert_called_once() + call_kwargs = mock_request.call_args.kwargs + assert call_kwargs['json'] == {"key": "value"} + assert call_kwargs['headers']['Authorization'] == f"Bearer {api_key}" + + def test_send_request_with_params(self, api_key, mock_response): + """Test request with query parameters.""" + client = FastGPTClient(api_key) + + with patch.object(client._client, 'request', return_value=mock_response) as mock_request: + response = client._send_request( + "GET", + "/api/test", + params={"page": 1, "limit": 10} + ) + + assert response.status_code == 200 + call_kwargs = mock_request.call_args.kwargs + assert call_kwargs['params'] == {"page": 1, "limit": 10} + + def test_send_request_with_streaming(self, api_key, mock_stream_response): + """Test streaming request.""" + client = FastGPTClient(api_key) + + mock_stream_context = MagicMock() + mock_stream_context.__enter__ = Mock(return_value=mock_stream_response) + mock_stream_context.__exit__ = Mock(return_value=None) + + with patch.object(client._client, 'stream', return_value=mock_stream_context): + response = client._send_request( + "POST", + "/api/test/stream", + stream=True + ) + + assert response.status_code == 200 + # Verify stream context was entered + mock_stream_context.__enter__.assert_called_once() + # Verify response has custom close method + assert hasattr(response, 'close') + + def test_send_request_stream_cleanup(self, api_key): + """Test that streaming response cleanup works correctly.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 200 + + # Track if close was called on the response + original_close_called = [] + + def original_close(): + original_close_called.append(True) + + mock_response.close = original_close + + mock_stream_context = MagicMock() + mock_stream_context.__enter__ = Mock(return_value=mock_response) + mock_stream_context.__exit__ = Mock(return_value=None) + + with patch.object(client._client, 'stream', return_value=mock_stream_context): + response = client._send_request("POST", "/api/stream", stream=True) + response.close() + + # Verify stream context exit was called (this is the key behavior) + mock_stream_context.__exit__.assert_called_once_with(None, None, None) + # Verify the original close was called (through the wrapper) + assert len(original_close_called) == 1 + + def test_send_request_authentication_error(self, api_key, error_responses): + """Test handling of 401 authentication error.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 401 + mock_response.json = Mock(return_value=error_responses['authentication_error']) + + with patch.object(client._client, 'request', return_value=mock_response): + with pytest.raises(AuthenticationError) as exc_info: + client._send_request("GET", "/api/test") + + assert exc_info.value.status_code == 401 + assert "Invalid API key" in exc_info.value.message + + def test_send_request_rate_limit_error(self, api_key, error_responses): + """Test handling of 429 rate limit error.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 429 + mock_response.headers = {"Retry-After": "60"} + mock_response.json = Mock(return_value=error_responses['rate_limit_error']) + + with patch.object(client._client, 'request', return_value=mock_response): + with pytest.raises(RateLimitError) as exc_info: + client._send_request("GET", "/api/test") + + assert exc_info.value.status_code == 429 + assert exc_info.value.retry_after == "60" + + def test_send_request_validation_error(self, api_key, error_responses): + """Test handling of 422 validation error.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 422 + mock_response.json = Mock(return_value=error_responses['validation_error']) + + with patch.object(client._client, 'request', return_value=mock_response): + with pytest.raises(ValidationError) as exc_info: + client._send_request("GET", "/api/test") + + assert exc_info.value.status_code == 422 + assert "Invalid parameters" in exc_info.value.message + + def test_send_request_generic_api_error(self, api_key, error_responses): + """Test handling of generic 4xx API error.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 404 + mock_response.json = Mock(return_value={"message": "Not found"}) + + with patch.object(client._client, 'request', return_value=mock_response): + with pytest.raises(APIError) as exc_info: + client._send_request("GET", "/api/test") + + assert exc_info.value.status_code == 404 + + def test_send_request_with_invalid_json_error_response(self, api_key): + """Test handling of error response with invalid JSON.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + # Use 4xx to avoid retry logic (5xx triggers retries) + mock_response.status_code = 400 + mock_response.json = Mock(side_effect=ValueError("Invalid JSON")) + + with patch.object(client._client, 'request', return_value=mock_response): + # Should handle the error and raise APIError with status code + with pytest.raises(APIError) as exc_info: + client._send_request("GET", "/api/test") + + assert exc_info.value.status_code == 400 + assert "HTTP 400" in exc_info.value.message + + def test_send_request_retry_on_server_error(self, api_key, mock_response): + """Test that request is retried on 5xx errors.""" + client = FastGPTClient(api_key, max_retries=2, retry_delay=0.01) + + error_response = Mock(spec=httpx.Response) + error_response.status_code = 503 + + with patch.object(client._client, 'request', side_effect=[error_response, mock_response]): + response = client._send_request("GET", "/api/test") + + assert response.status_code == 200 + + def test_send_request_validation_of_json_params(self, api_key, mock_response): + """Test that JSON parameters are validated.""" + client = FastGPTClient(api_key) + + with patch.object(client._client, 'request', return_value=mock_response): + with pytest.raises(ValidationError): + client._send_request( + "POST", + "/api/test", + json={"query": ""} # Empty string should fail validation + ) + + def test_send_request_validation_of_query_params(self, api_key, mock_response): + """Test that query parameters are validated.""" + client = FastGPTClient(api_key) + + with patch.object(client._client, 'request', return_value=mock_response): + with pytest.raises(ValidationError): + client._send_request( + "GET", + "/api/test", + params={"chatId": " "} # Whitespace-only should fail + ) + + +class TestFastGPTClientHandleErrorResponse: + """Test suite for FastGPTClient._handle_error_response method.""" + + def test_handle_success_response(self, api_key, mock_response): + """Test handling of successful response (no error).""" + client = FastGPTClient(api_key) + mock_response.status_code = 200 + + # Should not raise any exception + client._handle_error_response(mock_response) + + def test_handle_401_error(self, api_key, error_responses): + """Test handling of 401 error.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 401 + mock_response.json = Mock(return_value=error_responses['authentication_error']) + + with pytest.raises(AuthenticationError) as exc_info: + client._handle_error_response(mock_response) + + assert exc_info.value.status_code == 401 + assert exc_info.value.message == "Invalid API key" + + def test_handle_429_error_without_retry_after(self, api_key, error_responses): + """Test handling of 429 error without Retry-After header.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 429 + mock_response.headers = {} + mock_response.json = Mock(return_value=error_responses['rate_limit_error']) + + with pytest.raises(RateLimitError) as exc_info: + client._handle_error_response(mock_response) + + assert exc_info.value.status_code == 429 + assert exc_info.value.retry_after is None + + def test_handle_422_error(self, api_key, error_responses): + """Test handling of 422 validation error.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 422 + mock_response.json = Mock(return_value=error_responses['validation_error']) + + with pytest.raises(ValidationError) as exc_info: + client._handle_error_response(mock_response) + + assert exc_info.value.status_code == 422 + + def test_handle_500_error(self, api_key): + """Test handling of 500 server error.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 500 + mock_response.json = Mock(return_value={"message": "Internal error"}) + + with pytest.raises(APIError) as exc_info: + client._handle_error_response(mock_response) + + assert exc_info.value.status_code == 500 + + def test_handle_error_without_message(self, api_key): + """Test handling of error response without message field.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 400 + mock_response.json = Mock(return_value={}) + + with pytest.raises(APIError) as exc_info: + client._handle_error_response(mock_response) + + assert "HTTP 400" in exc_info.value.message + + def test_handle_error_with_json_parse_error(self, api_key): + """Test handling of error response that can't be parsed as JSON.""" + client = FastGPTClient(api_key) + + mock_response = Mock(spec=httpx.Response) + mock_response.status_code = 503 + mock_response.json = Mock(side_effect=ValueError("Invalid JSON")) + + with pytest.raises(APIError) as exc_info: + client._handle_error_response(mock_response) + + assert "HTTP 503" in exc_info.value.message diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..0426edd --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,357 @@ +"""Tests for FastGPT exception classes.""" + +import pytest + +from fastgpt_client.exceptions import ( + FastGPTError, + APIError, + AuthenticationError, + RateLimitError, + ValidationError, + StreamParseError, +) + + +class TestFastGPTError: + """Test suite for FastGPTError base exception.""" + + def test_init_with_message_only(self): + """Test initialization with message only.""" + error = FastGPTError("Test error message") + + assert error.message == "Test error message" + assert error.status_code is None + assert error.response_data == {} + + def test_init_with_message_and_status_code(self): + """Test initialization with message and status code.""" + error = FastGPTError("Test error", status_code=404) + + assert error.message == "Test error" + assert error.status_code == 404 + assert error.response_data == {} + + def test_init_with_all_parameters(self): + """Test initialization with all parameters.""" + response_data = {"code": "test_error", "details": "Something went wrong"} + error = FastGPTError( + "Test error", + status_code=500, + response_data=response_data + ) + + assert error.message == "Test error" + assert error.status_code == 500 + assert error.response_data == response_data + + def test_str_representation(self): + """Test string representation of exception.""" + error = FastGPTError("Test error message") + + assert str(error) == "Test error message" + + def test_inheritance(self): + """Test that FastGPTError inherits from Exception.""" + error = FastGPTError("Test") + + assert isinstance(error, Exception) + assert isinstance(error, FastGPTError) + + +class TestAPIError: + """Test suite for APIError exception.""" + + def test_api_error_inheritance(self): + """Test that APIError inherits from FastGPTError.""" + error = APIError("API error occurred") + + assert isinstance(error, FastGPTError) + assert isinstance(error, APIError) + assert isinstance(error, Exception) + + def test_api_error_basic(self): + """Test basic APIError creation.""" + error = APIError("API failed", status_code=500) + + assert error.message == "API failed" + assert error.status_code == 500 + + def test_api_error_with_response_data(self): + """Test APIError with response data.""" + response_data = {"error": "Internal server error"} + error = APIError("Server error", status_code=500, response_data=response_data) + + assert error.response_data == response_data + + +class TestAuthenticationError: + """Test suite for AuthenticationError exception.""" + + def test_auth_error_inheritance(self): + """Test that AuthenticationError inherits from FastGPTError.""" + error = AuthenticationError("Invalid credentials") + + assert isinstance(error, FastGPTError) + assert isinstance(error, AuthenticationError) + + def test_auth_error_default(self): + """Test AuthenticationError with default parameters.""" + error = AuthenticationError("Invalid API key") + + assert error.message == "Invalid API key" + assert error.status_code is None + assert error.response_data == {} + + def test_auth_error_with_status_code(self): + """Test AuthenticationError with status code.""" + error = AuthenticationError( + "Authentication failed", + status_code=401 + ) + + assert error.status_code == 401 + + def test_auth_error_full(self): + """Test AuthenticationError with all parameters.""" + response_data = { + "code": "invalid_api_key", + "message": "The provided API key is invalid" + } + error = AuthenticationError( + "Auth failed", + status_code=401, + response_data=response_data + ) + + assert error.message == "Auth failed" + assert error.status_code == 401 + assert error.response_data == response_data + + +class TestRateLimitError: + """Test suite for RateLimitError exception.""" + + def test_rate_limit_error_inheritance(self): + """Test that RateLimitError inherits from FastGPTError.""" + error = RateLimitError("Rate limit exceeded") + + assert isinstance(error, FastGPTError) + assert isinstance(error, RateLimitError) + + def test_rate_limit_error_basic(self): + """Test RateLimitError with basic parameters.""" + error = RateLimitError("Too many requests") + + assert error.message == "Too many requests" + assert error.retry_after is None + assert error.status_code is None + + def test_rate_limit_error_with_retry_after(self): + """Test RateLimitError with retry_after parameter.""" + error = RateLimitError( + "Rate limit exceeded", + retry_after="60" + ) + + assert error.message == "Rate limit exceeded" + assert error.retry_after == "60" + + def test_rate_limit_error_full(self): + """Test RateLimitError with all parameters.""" + response_data = { + "code": "rate_limit_exceeded", + "limit": 100, + "remaining": 0 + } + error = RateLimitError( + "Rate limit hit", + retry_after="120", + status_code=429, + response_data=response_data + ) + + assert error.message == "Rate limit hit" + assert error.retry_after == "120" + assert error.status_code == 429 + assert error.response_data == response_data + + def test_rate_limit_error_retry_after_integer(self): + """Test RateLimitError with integer retry_after.""" + error = RateLimitError( + "Rate limited", + retry_after=30 + ) + + assert error.retry_after == 30 + + +class TestValidationError: + """Test suite for ValidationError exception.""" + + def test_validation_error_inheritance(self): + """Test that ValidationError inherits from FastGPTError.""" + error = ValidationError("Validation failed") + + assert isinstance(error, FastGPTError) + assert isinstance(error, ValidationError) + + def test_validation_error_basic(self): + """Test ValidationError with basic parameters.""" + error = ValidationError("Invalid parameter") + + assert error.message == "Invalid parameter" + + def test_validation_error_with_status(self): + """Test ValidationError with status code.""" + error = ValidationError( + "Invalid input", + status_code=422 + ) + + assert error.status_code == 422 + + def test_validation_error_with_response_data(self): + """Test ValidationError with response data.""" + response_data = { + "code": "validation_error", + "field": "email", + "reason": "Invalid format" + } + error = ValidationError( + "Validation failed", + status_code=422, + response_data=response_data + ) + + assert error.response_data == response_data + + +class TestStreamParseError: + """Test suite for StreamParseError exception.""" + + def test_stream_parse_error_inheritance(self): + """Test that StreamParseError inherits from FastGPTError.""" + error = StreamParseError("Stream parsing failed") + + assert isinstance(error, FastGPTError) + assert isinstance(error, StreamParseError) + + def test_stream_parse_error_basic(self): + """Test StreamParseError with basic parameters.""" + error = StreamParseError("Invalid stream format") + + assert error.message == "Invalid stream format" + + def test_stream_parse_error_with_status(self): + """Test StreamParseError with status code.""" + error = StreamParseError( + "Parse error", + status_code=500 + ) + + assert error.status_code == 500 + + def test_stream_parse_error_with_response_data(self): + """Test StreamParseError with response data.""" + response_data = { + "error": "stream_error", + "details": "Unexpected EOF" + } + error = StreamParseError( + "Failed to parse stream", + status_code=502, + response_data=response_data + ) + + assert error.response_data == response_data + + +class TestExceptionUsagePatterns: + """Test suite for common exception usage patterns.""" + + def test_catching_base_exception(self): + """Test catching exceptions using base FastGPTError.""" + with pytest.raises(FastGPTError) as exc_info: + raise APIError("API error") + + assert isinstance(exc_info.value, FastGPTError) + assert str(exc_info.value) == "API error" + + def test_catching_specific_exception(self): + """Test catching specific exception types.""" + with pytest.raises(AuthenticationError) as exc_info: + raise AuthenticationError("Auth failed", status_code=401) + + assert exc_info.value.status_code == 401 + + def test_exception_chain(self): + """Test exception chaining.""" + try: + try: + raise ValueError("Original error") + except ValueError as e: + raise APIError("Wrapped error") from e + except APIError as caught: + assert caught.__cause__ is not None + assert isinstance(caught.__cause__, ValueError) + + def test_raising_from_http_response(self): + """Test raising exception from HTTP response data.""" + response_data = { + "code": "invalid_request", + "message": "The request was invalid" + } + + with pytest.raises(APIError) as exc_info: + raise APIError( + "Invalid request", + status_code=400, + response_data=response_data + ) + + assert exc_info.value.response_data == response_data + + def test_exception_with_none_response_data(self): + """Test exception when response_data is None.""" + error = APIError("Error", status_code=500, response_data=None) + + # None should default to empty dict + assert error.response_data == {} + + def test_multiple_exception_types(self): + """Test handling multiple exception types.""" + def raise_specific_error(status_code): + if status_code == 401: + raise AuthenticationError("Unauthorized", status_code=status_code) + elif status_code == 429: + raise RateLimitError("Rate limited", status_code=status_code) + elif status_code == 422: + raise ValidationError("Invalid data", status_code=status_code) + else: + raise APIError("Generic error", status_code=status_code) + + # Test each type + with pytest.raises(AuthenticationError): + raise_specific_error(401) + + with pytest.raises(RateLimitError): + raise_specific_error(429) + + with pytest.raises(ValidationError): + raise_specific_error(422) + + with pytest.raises(APIError): + raise_specific_error(500) + + def test_exception_message_formatting(self): + """Test that exception messages are properly formatted.""" + error1 = FastGPTError("Simple error") + assert str(error1) == "Simple error" + + error2 = FastGPTError("Error with status", status_code=404) + assert str(error2) == "Error with status" + + # Message should be accessible regardless of other parameters + error3 = APIError("Complex error", status_code=500, response_data={"key": "value"}) + assert error3.message == "Complex error" + assert str(error3) == "Complex error"