主要更新: - 前端改用 Ant Design 组件(Table、Modal、Select 等) - 支持三种搜索方式:星图ID、达人unique_id、达人昵称模糊匹配 - 列表页实时调用云图 API 获取 A3 数据和成本指标 - 详情弹窗显示完整 6 大类指标,支持文字复制 - 品牌 API URL 格式修复为查询参数形式 - 优化云图 API 参数格式和会话池管理 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
415 lines
15 KiB
Python
415 lines
15 KiB
Python
"""
|
||
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
|