feat(video-analysis): 完成视频分析模块迭代任务

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>
This commit is contained in:
zfc
2026-01-28 17:51:35 +08:00
co-authored by Claude Opus 4.5
parent cdc364cb2a
commit f123f68be3
17 changed files with 2259 additions and 23 deletions
+63 -15
View File
@@ -116,11 +116,24 @@ class TestBrandAPI:
# 验证所有调用都完成了
assert mock_fetch.call_count == 15
async def test_fetch_brand_name_200_with_nested_data(self):
"""Test successful brand fetch with nested data structure."""
async def test_fetch_brand_name_200_with_array_data(self):
"""Test successful brand fetch with array data structure (T-019 fix)."""
# 正确的API响应格式: data是数组,从data[0].brand_name获取品牌名称
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"name": "嵌套品牌名"}}
mock_response.json.return_value = {
"total": 1,
"last_updated": "2025-12-30T11:28:40.738185",
"has_more": 0,
"data": [
{
"industry_id": 20,
"industry_name": "母婴",
"brand_id": 533661,
"brand_name": "Giving/启初"
}
]
}
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
@@ -129,16 +142,19 @@ class TestBrandAPI:
with patch("httpx.AsyncClient", return_value=mock_client):
semaphore = asyncio.Semaphore(10)
brand_id, brand_name = await fetch_brand_name("brand_nested", semaphore)
brand_id, brand_name = await fetch_brand_name("533661", semaphore)
assert brand_id == "brand_nested"
assert brand_name == "嵌套品牌名"
assert brand_id == "533661"
assert brand_name == "Giving/启初"
async def test_fetch_brand_name_200_with_flat_data(self):
"""Test successful brand fetch with flat data structure."""
async def test_fetch_brand_name_200_with_empty_data_array(self):
"""Test brand fetch with 200 but empty data array (T-019 edge case)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"name": "扁平品牌名"}
mock_response.json.return_value = {
"total": 0,
"data": []
}
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
@@ -147,16 +163,19 @@ class TestBrandAPI:
with patch("httpx.AsyncClient", return_value=mock_client):
semaphore = asyncio.Semaphore(10)
brand_id, brand_name = await fetch_brand_name("brand_flat", semaphore)
brand_id, brand_name = await fetch_brand_name("unknown_brand", semaphore)
assert brand_id == "brand_flat"
assert brand_name == "扁平品牌名"
assert brand_id == "unknown_brand"
assert brand_name == "unknown_brand" # Fallback
async def test_fetch_brand_name_200_no_name(self):
"""Test brand fetch with 200 but no name in response."""
async def test_fetch_brand_name_200_no_brand_name_field(self):
"""Test brand fetch with 200 but no brand_name in data item."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": {"id": "123"}} # No name field
mock_response.json.return_value = {
"total": 1,
"data": [{"brand_id": 123}] # No brand_name field
}
mock_client = AsyncMock()
mock_client.get.return_value = mock_response
@@ -170,6 +189,35 @@ class TestBrandAPI:
assert brand_id == "brand_no_name"
assert brand_name == "brand_no_name" # Fallback
async def test_fetch_brand_name_with_auth_header(self):
"""Test that Authorization header is sent (T-020)."""
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"total": 1,
"data": [{"brand_id": 123, "brand_name": "测试品牌"}]
}
mock_client = AsyncMock()
mock_client.get.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.brand_api.settings") as mock_settings:
mock_settings.BRAND_API_TIMEOUT = 3.0
mock_settings.BRAND_API_BASE_URL = "https://api.test.com"
mock_settings.BRAND_API_TOKEN = "test_token_123"
semaphore = asyncio.Semaphore(10)
await fetch_brand_name("123", semaphore)
# 验证请求包含 Authorization header
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert "headers" in call_args.kwargs
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_token_123"
async def test_fetch_brand_name_request_error(self):
"""Test brand fetch with request error."""
mock_client = AsyncMock()
+314
View File
@@ -0,0 +1,314 @@
"""
Tests for SessionID Pool Service (T-021, T-022)
"""
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
import httpx
from app.services.session_pool import (
SessionPool,
session_pool,
get_session_with_retry,
)
class TestSessionPool:
"""Tests for SessionPool class."""
async def test_refresh_success(self):
"""Test successful session pool refresh."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{"sessionid": "session_001", "user": "test1"},
{"sessionid": "session_002", "user": "test2"},
{"sessionid": "session_003", "user": "test3"},
]
}
mock_client = AsyncMock()
mock_client.get.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 pool.refresh()
assert result is True
assert pool.size == 3
assert not pool.is_empty
async def test_refresh_empty_data(self):
"""Test refresh with empty data array."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": []}
mock_client = AsyncMock()
mock_client.get.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 pool.refresh()
assert result is False
assert pool.size == 0
async def test_refresh_api_error(self):
"""Test refresh with API error."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 500
mock_client = AsyncMock()
mock_client.get.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 pool.refresh()
assert result is False
async def test_refresh_timeout(self):
"""Test refresh with timeout."""
pool = SessionPool()
mock_client = AsyncMock()
mock_client.get.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):
result = await pool.refresh()
assert result is False
async def test_refresh_request_error(self):
"""Test refresh with request error."""
pool = SessionPool()
mock_client = AsyncMock()
mock_client.get.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):
result = await pool.refresh()
assert result is False
async def test_refresh_unexpected_error(self):
"""Test refresh with unexpected error."""
pool = SessionPool()
mock_client = AsyncMock()
mock_client.get.side_effect = ValueError("Unexpected")
mock_client.__aenter__.return_value = mock_client
mock_client.__aexit__.return_value = None
with patch("httpx.AsyncClient", return_value=mock_client):
result = await pool.refresh()
assert result is False
async def test_refresh_with_auth_header(self):
"""Test that refresh includes Authorization header."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": [{"sessionid": "test"}]}
mock_client = AsyncMock()
mock_client.get.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.session_pool.settings") as mock_settings:
mock_settings.YUNTU_API_TOKEN = "test_token"
mock_settings.YUNTU_API_TIMEOUT = 10.0
mock_settings.BRAND_API_BASE_URL = "https://api.test.com"
await pool.refresh()
mock_client.get.assert_called_once()
call_args = mock_client.get.call_args
assert "headers" in call_args.kwargs
assert call_args.kwargs["headers"]["Authorization"] == "Bearer test_token"
def test_get_random_from_pool(self):
"""Test getting random session from pool."""
pool = SessionPool()
pool._sessions = ["session_1", "session_2", "session_3"]
session = pool.get_random()
assert session in pool._sessions
def test_get_random_from_empty_pool(self):
"""Test getting random session from empty pool."""
pool = SessionPool()
session = pool.get_random()
assert session is None
def test_remove_session(self):
"""Test removing a session from pool."""
pool = SessionPool()
pool._sessions = ["session_1", "session_2", "session_3"]
pool.remove("session_2")
assert pool.size == 2
assert "session_2" not in pool._sessions
def test_remove_nonexistent_session(self):
"""Test removing a session that doesn't exist."""
pool = SessionPool()
pool._sessions = ["session_1"]
# Should not raise
pool.remove("nonexistent")
assert pool.size == 1
def test_size_property(self):
"""Test size property."""
pool = SessionPool()
assert pool.size == 0
pool._sessions = ["a", "b"]
assert pool.size == 2
def test_is_empty_property(self):
"""Test is_empty property."""
pool = SessionPool()
assert pool.is_empty is True
pool._sessions = ["a"]
assert pool.is_empty is False
class TestGetSessionWithRetry:
"""Tests for get_session_with_retry function (T-022)."""
async def test_get_session_success(self):
"""Test successful session retrieval."""
with patch.object(session_pool, "_sessions", ["session_1", "session_2"]):
result = await get_session_with_retry()
assert result in ["session_1", "session_2"]
async def test_get_session_refresh_on_empty(self):
"""Test that pool is refreshed when empty."""
with patch.object(session_pool, "_sessions", []):
with patch.object(session_pool, "refresh") as mock_refresh:
mock_refresh.return_value = True
# After refresh, pool should have sessions
async def refresh_side_effect():
session_pool._sessions.append("new_session")
return True
mock_refresh.side_effect = refresh_side_effect
result = await get_session_with_retry()
assert mock_refresh.called
assert result == "new_session"
async def test_get_session_retry_on_refresh_failure(self):
"""Test retry behavior when refresh fails."""
original_sessions = session_pool._sessions.copy()
try:
session_pool._sessions = []
with patch.object(session_pool, "refresh") as mock_refresh:
mock_refresh.return_value = False
result = await get_session_with_retry(max_retries=3)
assert result is None
assert mock_refresh.call_count == 3
finally:
session_pool._sessions = original_sessions
async def test_get_session_max_retries(self):
"""Test max retries limit."""
original_sessions = session_pool._sessions.copy()
try:
session_pool._sessions = []
with patch.object(session_pool, "refresh") as mock_refresh:
mock_refresh.return_value = False
result = await get_session_with_retry(max_retries=5)
assert result is None
assert mock_refresh.call_count == 5
finally:
session_pool._sessions = original_sessions
class TestSessionPoolIntegration:
"""Integration tests for session pool."""
async def test_refresh_filters_invalid_items(self):
"""Test that refresh filters out invalid items."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"data": [
{"sessionid": "valid_session"},
{"no_sessionid": "missing"},
None,
{"sessionid": ""}, # Empty string should be filtered
{"sessionid": "another_valid"},
]
}
mock_client = AsyncMock()
mock_client.get.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 pool.refresh()
assert result is True
assert pool.size == 2
assert "valid_session" in pool._sessions
assert "another_valid" in pool._sessions
async def test_refresh_handles_non_dict_data(self):
"""Test refresh with non-dict response."""
pool = SessionPool()
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = ["not", "a", "dict"]
mock_client = AsyncMock()
mock_client.get.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 pool.refresh()
assert result is False
+423
View File
@@ -0,0 +1,423 @@
"""
Tests for Video Analysis Service (T-024)
"""
import pytest
from datetime import datetime
from unittest.mock import AsyncMock, patch, MagicMock
from app.services.video_analysis import (
calculate_cost_metrics,
get_video_base_info,
get_video_analysis_data,
update_video_a3_metrics,
get_and_update_video_analysis,
)
from app.services.yuntu_api import YuntuAPIError
class TestCalculateCostMetrics:
"""Tests for calculate_cost_metrics function."""
def test_all_metrics_calculated(self):
"""Test calculation of all cost metrics."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=40000,
a3_increase_cnt=500,
natural_a3_increase_cnt=400,
after_view_search_uv=1000,
total_play_cnt=50000,
)
# CPM = 10000 / 50000 * 1000 = 200
assert result["cpm"] == 200.0
# 自然CPM = 10000 / 40000 * 1000 = 250
assert result["natural_cpm"] == 250.0
# CPA3 = 10000 / 500 = 20
assert result["cpa3"] == 20.0
# 自然CPA3 = 10000 / 400 = 25
assert result["natural_cpa3"] == 25.0
# CPsearch = 10000 / 1000 = 10
assert result["cp_search"] == 10.0
# 预估自然看后搜人数 = 40000 / 50000 * 1000 = 800
assert result["estimated_natural_search_uv"] == 800.0
# 自然CPsearch = 10000 / 800 = 12.5
assert result["natural_cp_search"] == 12.5
def test_zero_total_play_cnt(self):
"""Test with zero total_play_cnt (division by zero)."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=0,
a3_increase_cnt=500,
natural_a3_increase_cnt=400,
after_view_search_uv=1000,
total_play_cnt=0,
)
assert result["cpm"] is None
assert result["natural_cpm"] is None
assert result["estimated_natural_search_uv"] is None
assert result["natural_cp_search"] is None
def test_zero_a3_counts(self):
"""Test with zero A3 counts."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=40000,
a3_increase_cnt=0,
natural_a3_increase_cnt=0,
after_view_search_uv=1000,
total_play_cnt=50000,
)
assert result["cpa3"] is None
assert result["natural_cpa3"] is None
# 其他指标应该正常计算
assert result["cpm"] == 200.0
def test_zero_search_uv(self):
"""Test with zero after_view_search_uv."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=40000,
a3_increase_cnt=500,
natural_a3_increase_cnt=400,
after_view_search_uv=0,
total_play_cnt=50000,
)
assert result["cp_search"] is None
# 当 after_view_search_uv=0 时,预估自然看后搜人数也应为 None(无意义)
assert result["estimated_natural_search_uv"] is None
assert result["natural_cp_search"] is None
def test_all_zeros(self):
"""Test with all zero values."""
result = calculate_cost_metrics(
cost=0,
natural_play_cnt=0,
a3_increase_cnt=0,
natural_a3_increase_cnt=0,
after_view_search_uv=0,
total_play_cnt=0,
)
assert result["cpm"] is None
assert result["natural_cpm"] is None
assert result["cpa3"] is None
assert result["natural_cpa3"] is None
assert result["cp_search"] is None
assert result["estimated_natural_search_uv"] is None
assert result["natural_cp_search"] is None
def test_decimal_precision(self):
"""Test that results are rounded to 2 decimal places."""
result = calculate_cost_metrics(
cost=10000,
natural_play_cnt=30000,
a3_increase_cnt=333,
natural_a3_increase_cnt=111,
after_view_search_uv=777,
total_play_cnt=70000,
)
# 验证都是2位小数
assert isinstance(result["cpm"], float)
assert len(str(result["cpm"]).split(".")[-1]) <= 2
class TestGetVideoAnalysisData:
"""Tests for get_video_analysis_data function."""
async def test_success_with_api_data(self):
"""Test successful data retrieval with API data."""
# Mock database video
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = "https://example.com/video"
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = datetime(2025, 1, 15)
mock_video.industry_name = "母婴"
mock_video.industry_id = "20"
mock_video.total_play_cnt = 50000
mock_video.natural_play_cnt = 40000
mock_video.heated_play_cnt = 10000
mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000
# Mock session
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_video
mock_session.execute.return_value = mock_result
# Mock API response
api_response = {
"code": 0,
"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,
},
}
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.return_value = api_response
result = await get_video_analysis_data(mock_session, "video_123")
# 验证基础信息
assert result["base_info"]["item_id"] == "video_123"
assert result["base_info"]["title"] == "测试视频"
assert result["base_info"]["star_nickname"] == "测试达人"
# 验证触达指标
assert result["reach_metrics"]["total_show_cnt"] == 100000
assert result["reach_metrics"]["natural_play_cnt"] == 40000
# 验证A3指标
assert result["a3_metrics"]["a3_increase_cnt"] == 500
assert result["a3_metrics"]["natural_a3_increase_cnt"] == 400
# 验证搜索指标
assert result["search_metrics"]["after_view_search_uv"] == 1000
# 验证费用指标
assert result["cost_metrics_raw"]["cost"] == 10000
# 验证计算指标
assert result["cost_metrics_calculated"]["cpm"] is not None
assert result["cost_metrics_calculated"]["cpa3"] is not None
async def test_video_not_found(self):
"""Test error when video is not found."""
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = None
mock_session.execute.return_value = mock_result
with pytest.raises(ValueError) as exc_info:
await get_video_analysis_data(mock_session, "nonexistent")
assert "not found" in str(exc_info.value).lower()
async def test_fallback_on_api_failure(self):
"""Test fallback to database data when API fails."""
# Mock database video
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = None
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = datetime(2025, 1, 15)
mock_video.industry_name = "母婴"
mock_video.industry_id = "20"
mock_video.total_play_cnt = 50000
mock_video.natural_play_cnt = 40000
mock_video.heated_play_cnt = 10000
mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000
# Mock session
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_video
mock_session.execute.return_value = mock_result
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.side_effect = YuntuAPIError("API Error")
result = await get_video_analysis_data(mock_session, "video_123")
# 应该使用数据库数据
assert result["reach_metrics"]["total_play_cnt"] == 50000
assert result["reach_metrics"]["natural_play_cnt"] == 40000
assert result["search_metrics"]["after_view_search_uv"] == 1000
async def test_null_publish_time(self):
"""Test handling of null publish_time."""
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = None
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = None # NULL
mock_video.industry_name = None
mock_video.industry_id = None
mock_video.total_play_cnt = 0
mock_video.natural_play_cnt = 0
mock_video.heated_play_cnt = 0
mock_video.after_view_search_uv = 0
mock_video.return_search_cnt = 0
mock_video.estimated_video_cost = 0
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.scalar_one_or_none.return_value = mock_video
mock_session.execute.return_value = mock_result
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.return_value = {"code": 0, "data": {}}
result = await get_video_analysis_data(mock_session, "video_123")
assert result["base_info"]["publish_time"] is None
class TestUpdateVideoA3Metrics:
"""Tests for update_video_a3_metrics function (T-025)."""
async def test_update_success(self):
"""Test successful A3 metrics update."""
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.rowcount = 1
mock_session.execute.return_value = mock_result
result = await update_video_a3_metrics(
session=mock_session,
item_id="video_123",
total_new_a3_cnt=500,
heated_new_a3_cnt=100,
natural_new_a3_cnt=400,
total_cost=10000.0,
)
assert result is True
mock_session.commit.assert_called_once()
async def test_update_video_not_found(self):
"""Test update when video not found."""
mock_session = AsyncMock()
mock_result = MagicMock()
mock_result.rowcount = 0
mock_session.execute.return_value = mock_result
result = await update_video_a3_metrics(
session=mock_session,
item_id="nonexistent",
total_new_a3_cnt=500,
heated_new_a3_cnt=100,
natural_new_a3_cnt=400,
total_cost=10000.0,
)
assert result is False
async def test_update_database_error(self):
"""Test update with database error."""
mock_session = AsyncMock()
mock_session.execute.side_effect = Exception("Database error")
result = await update_video_a3_metrics(
session=mock_session,
item_id="video_123",
total_new_a3_cnt=500,
heated_new_a3_cnt=100,
natural_new_a3_cnt=400,
total_cost=10000.0,
)
assert result is False
mock_session.rollback.assert_called_once()
class TestGetAndUpdateVideoAnalysis:
"""Tests for get_and_update_video_analysis function (T-024 + T-025)."""
async def test_get_and_update_success(self):
"""Test successful get and update."""
# Mock database video
mock_video = MagicMock()
mock_video.item_id = "video_123"
mock_video.title = "测试视频"
mock_video.video_url = None
mock_video.star_id = "star_001"
mock_video.star_unique_id = "unique_001"
mock_video.star_nickname = "测试达人"
mock_video.publish_time = datetime(2025, 1, 15)
mock_video.industry_name = "母婴"
mock_video.industry_id = "20"
mock_video.total_play_cnt = 50000
mock_video.natural_play_cnt = 40000
mock_video.heated_play_cnt = 10000
mock_video.after_view_search_uv = 1000
mock_video.return_search_cnt = 50
mock_video.estimated_video_cost = 10000
# Mock session
mock_session = AsyncMock()
mock_select_result = MagicMock()
mock_select_result.scalar_one_or_none.return_value = mock_video
mock_update_result = MagicMock()
mock_update_result.rowcount = 1
# 根据不同的SQL语句返回不同的结果
async def mock_execute(stmt):
# 简单判断:如果是 SELECT 返回视频,如果是 UPDATE 返回更新结果
stmt_str = str(stmt)
if "SELECT" in stmt_str.upper():
return mock_select_result
return mock_update_result
mock_session.execute.side_effect = mock_execute
with patch(
"app.services.video_analysis.fetch_yuntu_analysis"
) as mock_api:
mock_api.return_value = {
"code": 0,
"data": {
"a3_increase_cnt": 500,
"ad_a3_increase_cnt": 100,
"natural_a3_increase_cnt": 400,
"cost": 10000,
},
}
result = await get_and_update_video_analysis(mock_session, "video_123")
# 验证返回数据
assert result["a3_metrics"]["a3_increase_cnt"] == 500
# 验证数据库更新被调用
mock_session.commit.assert_called()
+416
View File
@@ -0,0 +1,416 @@
"""
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