""" Tests for Yuntu API Service (T-023, T-027) T-027 更新: - call_yuntu_api 参数改为 auth_token(完整 cookie 值) - 日期格式改为 YYYYMMDD - industry_id 改为字符串 """ 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 = { "status": 0, "msg": "ok", "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", aadvid="1648829117232140", auth_token="sessionid=test_session", ) assert result["status"] == 0 assert result["data"]["total_show_cnt"] == 100000 async def test_call_with_correct_parameters(self): """Test that API is called with correct parameters (T-027 format).""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"status": 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", aadvid="1648829117232140", auth_token="sessionid=session_abc", ) mock_client.post.assert_called_once() call_args = mock_client.post.call_args # 验证URL包含aadvid assert "GetContentMaterialAnalysisInfo" in call_args.args[0] assert "aadvid=1648829117232140" in call_args.args[0] # 验证请求体 - T-027: 日期格式 YYYYMMDD json_data = call_args.kwargs["json"] assert json_data["object_id"] == "video_001" assert json_data["start_date"] == "20250115" # YYYYMMDD assert json_data["end_date"] == "20250214" # +30天 assert json_data["industry_id_list"] == ["30"] # 字符串数组 # 验证headers - T-027: 直接使用 auth_token headers = call_args.kwargs["headers"] assert headers["Cookie"] == "sessionid=session_abc" 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", aadvid="123", auth_token="sessionid=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", aadvid="123", auth_token="sessionid=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", aadvid="123", auth_token="sessionid=session", ) assert exc_info.value.status_code == 500 async def test_call_business_error(self): """Test handling of business error (status != 0).""" mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = { "status": 1001, "msg": "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", aadvid="123", auth_token="sessionid=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", aadvid="123", auth_token="sessionid=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", aadvid="123", auth_token="sessionid=session", ) class TestGetVideoAnalysis: """Tests for get_video_analysis function with retry logic (T-022, T-027).""" async def test_success_first_try(self): """Test successful call on first attempt.""" with patch("app.services.yuntu_api.get_random_config") as mock_config: mock_config.return_value = { "aadvid": "123", "auth_token": "sessionid=valid_session", } with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: mock_call.return_value = {"status": 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_random_config") as mock_config: mock_config.side_effect = [ {"aadvid": "123", "auth_token": "sessionid=session_1"}, {"aadvid": "456", "auth_token": "sessionid=session_2"}, {"aadvid": "789", "auth_token": "sessionid=session_3"}, ] with patch("app.services.yuntu_api.call_yuntu_api") as mock_call: # 前两次失败,第三次成功 mock_call.side_effect = [ SessionInvalidError("Invalid"), SessionInvalidError("Invalid"), {"status": 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["status"] == 0 assert mock_call.call_count == 3 # 验证失效的session被移除 assert mock_pool.remove_by_auth_token.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_random_config") as mock_config: mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=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_random_config") as mock_config: mock_config.return_value = {"aadvid": "123", "auth_token": "sessionid=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_config_available(self): """Test error when no config is available.""" with patch("app.services.yuntu_api.get_random_config") as mock_config: mock_config.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 (T-027: handles string values).""" 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, "natural_cost": 0, "ad_cost": 10000, } } 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["ad_a3_increase_cnt"] == 100 assert result["natural_a3_increase_cnt"] == 400 assert result["after_view_search_uv"] == 1000 assert result["cost"] == 10000 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 def test_parse_string_numbers(self): """Test parsing string numbers to int (T-027).""" response = { "data": { "a3_increase_cnt": "1689071", "ad_a3_increase_cnt": "36902", "natural_a3_increase_cnt": "1652169", "cost": 785000, } } result = parse_analysis_response(response) assert result["a3_increase_cnt"] == 1689071 assert result["ad_a3_increase_cnt"] == 36902 assert result["natural_a3_increase_cnt"] == 1652169 assert result["cost"] == 785000