""" Tests for Yuntu API Service (T-023) """ import pytest from datetime import datetime from unittest.mock import AsyncMock, patch, MagicMock import httpx from app.services.yuntu_api import ( call_yuntu_api, get_video_analysis, parse_analysis_response, YuntuAPIError, SessionInvalidError, ) class TestCallYuntuAPI: """Tests for call_yuntu_api function.""" async def test_call_success(self): """Test successful API call.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "code": 0, "message": "success", "data": { "total_show_cnt": 100000, "a3_increase_cnt": 500, }, } mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): result = await call_yuntu_api( item_id="test_item_123", publish_time=datetime(2025, 1, 1), industry_id="20", session_id="test_session", ) assert result["code"] == 0 assert result["data"]["total_show_cnt"] == 100000 async def test_call_with_correct_parameters(self): """Test that API is called with correct parameters.""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"code": 0, "data": {}} mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): await call_yuntu_api( item_id="video_001", publish_time=datetime(2025, 1, 15), industry_id="30", session_id="session_abc", ) mock_client.post.assert_called_once() call_args = mock_client.post.call_args # 验证URL assert "GetContentMaterialAnalysisInfo" in call_args.args[0] # 验证请求体 json_data = call_args.kwargs["json"] assert json_data["object_id"] == "video_001" assert json_data["start_date"] == "2025-01-15" assert json_data["end_date"] == "2025-02-14" # +30天 assert json_data["industry_id_list"] == ["30"] # 验证headers包含sessionid headers = call_args.kwargs["headers"] assert "Cookie" in headers assert "sessionid=session_abc" in headers["Cookie"] async def test_call_session_invalid_401(self): """Test handling of 401 response (session invalid).""" mock_response = MagicMock() mock_response.status_code = 401 mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(SessionInvalidError) as exc_info: await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", session_id="invalid_session", ) assert exc_info.value.status_code == 401 async def test_call_session_invalid_403(self): """Test handling of 403 response (session invalid).""" mock_response = MagicMock() mock_response.status_code = 403 mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(SessionInvalidError): await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", session_id="invalid_session", ) async def test_call_api_error_500(self): """Test handling of 500 response.""" mock_response = MagicMock() mock_response.status_code = 500 mock_response.text = "Internal Server Error" mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(YuntuAPIError) as exc_info: await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", session_id="session", ) assert exc_info.value.status_code == 500 async def test_call_business_error(self): """Test handling of business error (code != 0).""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "code": 1001, "message": "Invalid parameter", } mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(YuntuAPIError) as exc_info: await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", session_id="session", ) assert "Invalid parameter" in exc_info.value.message async def test_call_timeout(self): """Test handling of timeout.""" mock_client = AsyncMock() mock_client.post.side_effect = httpx.TimeoutException("Timeout") mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(YuntuAPIError) as exc_info: await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", session_id="session", ) assert "timeout" in exc_info.value.message.lower() async def test_call_request_error(self): """Test handling of request error.""" mock_client = AsyncMock() mock_client.post.side_effect = httpx.RequestError("Connection failed") mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): with pytest.raises(YuntuAPIError): await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", session_id="session", ) async def test_call_without_session_id(self): """Test API call without providing session_id (gets from pool).""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"code": 0, "data": {}} mock_client = AsyncMock() mock_client.post.return_value = mock_response mock_client.__aenter__.return_value = mock_client mock_client.__aexit__.return_value = None with patch("httpx.AsyncClient", return_value=mock_client): with patch( "app.services.yuntu_api.get_session_with_retry" ) as mock_get_session: mock_get_session.return_value = "pool_session" result = await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", ) assert result["code"] == 0 mock_get_session.assert_called_once() async def test_call_no_session_available(self): """Test API call when no session is available.""" with patch( "app.services.yuntu_api.get_session_with_retry" ) as mock_get_session: mock_get_session.return_value = None with pytest.raises(YuntuAPIError) as exc_info: await call_yuntu_api( item_id="test", publish_time=datetime.now(), industry_id="20", ) assert "session" in exc_info.value.message.lower() class TestGetVideoAnalysis: """Tests for get_video_analysis function with retry logic (T-022).""" async def test_success_first_try(self): """Test successful call on first attempt.""" with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: mock_session.return_value = "valid_session" with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: mock_call.return_value = {"code": 0, "data": {"a3_increase_cnt": 100}} result = await get_video_analysis( item_id="test", publish_time=datetime.now(), industry_id="20", ) assert result["data"]["a3_increase_cnt"] == 100 assert mock_call.call_count == 1 async def test_retry_on_session_invalid(self): """Test retry when session is invalid.""" with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: mock_session.side_effect = ["session_1", "session_2", "session_3"] with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: # 前两次失败,第三次成功 mock_call.side_effect = [ SessionInvalidError("Invalid"), SessionInvalidError("Invalid"), {"code": 0, "data": {}}, ] with patch("app.services.yuntu_api.session_pool") as mock_pool: result = await get_video_analysis( item_id="test", publish_time=datetime.now(), industry_id="20", max_retries=3, ) assert result["code"] == 0 assert mock_call.call_count == 3 # 验证失效的session被移除 assert mock_pool.remove.call_count == 2 async def test_max_retries_exceeded(self): """Test that error is raised after max retries.""" with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: mock_session.return_value = "session" with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: mock_call.side_effect = SessionInvalidError("Invalid") with patch("app.services.yuntu_api.session_pool"): with pytest.raises(SessionInvalidError): await get_video_analysis( item_id="test", publish_time=datetime.now(), industry_id="20", max_retries=3, ) assert mock_call.call_count == 3 async def test_no_retry_on_api_error(self): """Test that non-session errors don't trigger retry.""" with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: mock_session.return_value = "session" with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: mock_call.side_effect = YuntuAPIError("Server error", status_code=500) with pytest.raises(YuntuAPIError) as exc_info: await get_video_analysis( item_id="test", publish_time=datetime.now(), industry_id="20", ) assert mock_call.call_count == 1 assert exc_info.value.status_code == 500 async def test_no_session_available(self): """Test error when no session is available.""" with patch("app.services.yuntu_api.get_session_with_retry") as mock_session: mock_session.return_value = None with pytest.raises(YuntuAPIError): await get_video_analysis( item_id="test", publish_time=datetime.now(), industry_id="20", ) class TestParseAnalysisResponse: """Tests for parse_analysis_response function.""" def test_parse_complete_response(self): """Test parsing complete response data.""" response = { "data": { "total_show_cnt": 100000, "natural_show_cnt": 80000, "ad_show_cnt": 20000, "total_play_cnt": 50000, "natural_play_cnt": 40000, "ad_play_cnt": 10000, "effective_play_cnt": 30000, "a3_increase_cnt": 500, "ad_a3_increase_cnt": 100, "natural_a3_increase_cnt": 400, "after_view_search_uv": 1000, "after_view_search_pv": 1500, "brand_search_uv": 200, "product_search_uv": 300, "return_search_cnt": 50, "cost": 10000.5, "natural_cost": 0, "ad_cost": 10000.5, } } result = parse_analysis_response(response) assert result["total_show_cnt"] == 100000 assert result["natural_show_cnt"] == 80000 assert result["a3_increase_cnt"] == 500 assert result["after_view_search_uv"] == 1000 assert result["cost"] == 10000.5 def test_parse_empty_response(self): """Test parsing empty response.""" response = {"data": {}} result = parse_analysis_response(response) assert result["total_show_cnt"] == 0 assert result["a3_increase_cnt"] == 0 assert result["cost"] == 0 def test_parse_missing_data_key(self): """Test parsing response without data key.""" response = {} result = parse_analysis_response(response) assert result["total_show_cnt"] == 0 def test_parse_partial_response(self): """Test parsing partial response.""" response = { "data": { "total_show_cnt": 50000, "a3_increase_cnt": 100, } } result = parse_analysis_response(response) assert result["total_show_cnt"] == 50000 assert result["a3_increase_cnt"] == 100 assert result["natural_show_cnt"] == 0 # Default value assert result["cost"] == 0 # Default value