Bug 修复:
- T-019: 修复品牌API响应解析,正确解析 data[0].brand_name
- T-020: 添加品牌API Bearer Token认证
视频分析功能:
- T-021: SessionID池服务,从内部API获取Cookie列表
- T-022: SessionID自动重试,失效时自动切换重试
- T-023: 巨量云图API封装,支持超时和错误处理
- T-024: 视频分析数据接口 GET /api/v1/videos/{item_id}/analysis
- T-025: 数据库A3指标更新
- T-026: 视频分析前端页面,展示6大类25+指标
测试覆盖率:
- brand_api.py: 100%
- session_pool.py: 100%
- yuntu_api.py: 100%
- video_analysis.py: 99%
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
417 lines
15 KiB
Python
417 lines
15 KiB
Python
"""
|
|
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
|