feat(backend): 视频分析模块增加缓存优先策略和并发API调用
- SessionPool 新增 get_distinct_configs 方法,支持获取不同配置用于并发调用 - video_analysis 重构为缓存优先策略:数据库有 A3/Cost 数据时直接使用 - 并发 API 调用预分配不同 cookie,避免 session 冲突 - API 数据写回数据库,实现下次查询缓存命中 - 新增 heated_cost 字段追踪 - 测试全面重写,覆盖缓存/API/混合/降级场景
This commit is contained in:
@@ -17,6 +17,7 @@ from app.services.session_pool import (
|
||||
session_pool,
|
||||
get_session_with_retry,
|
||||
get_random_config,
|
||||
get_distinct_configs,
|
||||
)
|
||||
|
||||
|
||||
@@ -571,3 +572,130 @@ class TestSessionPoolIntegration:
|
||||
result = await pool.refresh()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
def _make_configs(count: int) -> list[CookieConfig]:
|
||||
"""创建 count 个不同的 CookieConfig 用于测试。"""
|
||||
return [
|
||||
CookieConfig(
|
||||
brand_id=f"brand_{i}",
|
||||
aadvid=f"aadvid_{i}",
|
||||
auth_token=f"sessionid=session_{i}",
|
||||
industry_id=20 + i,
|
||||
brand_name=f"Brand{i}",
|
||||
)
|
||||
for i in range(count)
|
||||
]
|
||||
|
||||
|
||||
class TestGetDistinctConfigs:
|
||||
"""Tests for SessionPool.get_distinct_configs and module-level get_distinct_configs."""
|
||||
|
||||
def test_enough_configs_returns_distinct(self):
|
||||
"""池中配置 >= count → 返回不重复的"""
|
||||
pool = SessionPool()
|
||||
pool._configs = _make_configs(5)
|
||||
|
||||
result = pool.get_distinct_configs(3)
|
||||
|
||||
assert len(result) == 3
|
||||
tokens = [r["auth_token"] for r in result]
|
||||
assert len(set(tokens)) == 3
|
||||
|
||||
def test_exact_count(self):
|
||||
"""池中配置 == count → 全部返回"""
|
||||
pool = SessionPool()
|
||||
pool._configs = _make_configs(3)
|
||||
|
||||
result = pool.get_distinct_configs(3)
|
||||
|
||||
assert len(result) == 3
|
||||
tokens = {r["auth_token"] for r in result}
|
||||
assert len(tokens) == 3
|
||||
|
||||
def test_fewer_configs_wraps_around(self):
|
||||
"""池中配置 < count → 循环复用补足"""
|
||||
pool = SessionPool()
|
||||
pool._configs = _make_configs(2)
|
||||
|
||||
result = pool.get_distinct_configs(5)
|
||||
|
||||
assert len(result) == 5
|
||||
# 前 2 个一定不重复
|
||||
first_two_tokens = {result[0]["auth_token"], result[1]["auth_token"]}
|
||||
assert len(first_two_tokens) == 2
|
||||
|
||||
def test_empty_pool_returns_empty(self):
|
||||
"""空池 → 返回空列表"""
|
||||
pool = SessionPool()
|
||||
|
||||
result = pool.get_distinct_configs(3)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_zero_count_returns_empty(self):
|
||||
"""count=0 → 返回空列表"""
|
||||
pool = SessionPool()
|
||||
pool._configs = _make_configs(3)
|
||||
|
||||
result = pool.get_distinct_configs(0)
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_result_contains_all_fields(self):
|
||||
"""验证返回的 dict 包含所有必要字段"""
|
||||
pool = SessionPool()
|
||||
pool._configs = _make_configs(1)
|
||||
|
||||
result = pool.get_distinct_configs(1)
|
||||
|
||||
assert len(result) == 1
|
||||
item = result[0]
|
||||
assert "brand_id" in item
|
||||
assert "aadvid" in item
|
||||
assert "auth_token" in item
|
||||
assert "industry_id" in item
|
||||
assert "brand_name" in item
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_module_level_get_distinct_configs(self):
|
||||
"""测试模块级 get_distinct_configs 异步函数"""
|
||||
pool = SessionPool()
|
||||
pool._configs = _make_configs(3)
|
||||
|
||||
with patch("app.services.session_pool.session_pool", pool):
|
||||
result = await get_distinct_configs(2)
|
||||
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_module_level_refreshes_on_empty(self):
|
||||
"""池为空时自动刷新"""
|
||||
pool = SessionPool()
|
||||
|
||||
with patch("app.services.session_pool.session_pool", pool):
|
||||
with patch.object(pool, "refresh") as mock_refresh:
|
||||
async def refresh_side_effect():
|
||||
pool._configs = _make_configs(3)
|
||||
return True
|
||||
|
||||
mock_refresh.side_effect = refresh_side_effect
|
||||
|
||||
result = await get_distinct_configs(2)
|
||||
|
||||
assert mock_refresh.called
|
||||
assert len(result) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_module_level_returns_empty_on_refresh_failure(self):
|
||||
"""刷新失败 → 返回空列表"""
|
||||
pool = SessionPool()
|
||||
|
||||
with patch("app.services.session_pool.session_pool", pool):
|
||||
with patch.object(pool, "refresh") as mock_refresh:
|
||||
mock_refresh.return_value = False
|
||||
|
||||
result = await get_distinct_configs(2, max_retries=2)
|
||||
|
||||
assert result == []
|
||||
assert mock_refresh.call_count == 2
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"""
|
||||
Tests for Video Analysis Service (T-024)
|
||||
|
||||
覆盖:
|
||||
- calculate_cost_metrics 计算
|
||||
- _needs_api_call 缓存判断
|
||||
- get_video_analysis_data 详情页(缓存命中 / API 调用 / API 失败降级)
|
||||
- update_video_a3_metrics(含 heated_cost)
|
||||
- get_video_list_with_a3 列表页(混合缓存 + 并发 API)
|
||||
"""
|
||||
|
||||
import pytest
|
||||
@@ -7,20 +14,98 @@ from datetime import datetime
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from app.services.video_analysis import (
|
||||
_build_video_list_item,
|
||||
_needs_api_call,
|
||||
calculate_cost_metrics,
|
||||
get_video_base_info,
|
||||
get_video_analysis_data,
|
||||
get_video_list_with_a3,
|
||||
update_video_a3_metrics,
|
||||
get_and_update_video_analysis,
|
||||
)
|
||||
from app.services.yuntu_api import YuntuAPIError
|
||||
|
||||
|
||||
def _make_mock_video(**overrides):
|
||||
"""创建标准 mock video 对象,带合理默认值。"""
|
||||
defaults = {
|
||||
"item_id": "video_123",
|
||||
"title": "测试视频",
|
||||
"video_url": "https://example.com/video",
|
||||
"vid": "vid_123",
|
||||
"star_id": "star_001",
|
||||
"star_unique_id": "unique_001",
|
||||
"star_nickname": "测试达人",
|
||||
"star_uid": "uid_001",
|
||||
"star_fans_cnt": 100000,
|
||||
"star_mcn": "MCN1",
|
||||
"publish_time": datetime(2025, 1, 15),
|
||||
"create_date": datetime(2025, 1, 15),
|
||||
"industry_name": "母婴",
|
||||
"industry_id": "20",
|
||||
"brand_id": "brand_001",
|
||||
"hot_type": "爆款",
|
||||
"viral_type": "爆款",
|
||||
"is_hot": True,
|
||||
"has_cart": False,
|
||||
"total_play_cnt": 50000,
|
||||
"natural_play_cnt": 40000,
|
||||
"heated_play_cnt": 10000,
|
||||
"total_interaction_cnt": 5000,
|
||||
"total_interact": 5000,
|
||||
"natural_interaction_cnt": 3000,
|
||||
"heated_interaction_cnt": 2000,
|
||||
"digg_cnt": 3000,
|
||||
"like_cnt": 3000,
|
||||
"share_cnt": 1000,
|
||||
"comment_cnt": 1000,
|
||||
"play_over_cnt": 20000,
|
||||
"play_over_rate": 0.4,
|
||||
"after_view_search_uv": 1000,
|
||||
"after_view_search_cnt": 1200,
|
||||
"after_view_search_rate": 0.02,
|
||||
"back_search_cnt": 50,
|
||||
"back_search_uv": 50,
|
||||
"return_search_cnt": 50,
|
||||
"new_a3_rate": 0.05,
|
||||
"total_new_a3_cnt": 0,
|
||||
"heated_new_a3_cnt": 0,
|
||||
"natural_new_a3_cnt": 0,
|
||||
"total_cost": 0.0,
|
||||
"heated_cost": 0.0,
|
||||
"star_task_cost": 0.0,
|
||||
"search_cost": 0.0,
|
||||
"ad_hot_roi": 0.0,
|
||||
"estimated_video_cost": 10000.0,
|
||||
"order_id": None,
|
||||
"content_type": None,
|
||||
"industry_tags": None,
|
||||
"ad_hot_type": None,
|
||||
"trend": None,
|
||||
"trend_daily": None,
|
||||
"trend_total": None,
|
||||
"component_metric_list": None,
|
||||
"key_word_after_search_infos": None,
|
||||
"index_map": None,
|
||||
"search_keywords": None,
|
||||
"keywords": None,
|
||||
"price_under_20s": None,
|
||||
"price_20_60s": None,
|
||||
"price_over_60s": None,
|
||||
"video_duration": None,
|
||||
"data_date": None,
|
||||
"created_at": None,
|
||||
"updated_at": None,
|
||||
}
|
||||
defaults.update(overrides)
|
||||
mock = MagicMock()
|
||||
for k, v in defaults.items():
|
||||
setattr(mock, k, v)
|
||||
return mock
|
||||
|
||||
|
||||
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,
|
||||
@@ -30,29 +115,15 @@ class TestCalculateCostMetrics:
|
||||
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,
|
||||
@@ -68,7 +139,6 @@ class TestCalculateCostMetrics:
|
||||
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,
|
||||
@@ -80,11 +150,9 @@ class TestCalculateCostMetrics:
|
||||
|
||||
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,
|
||||
@@ -95,12 +163,10 @@ class TestCalculateCostMetrics:
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -119,7 +185,6 @@ class TestCalculateCostMetrics:
|
||||
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,
|
||||
@@ -129,104 +194,143 @@ class TestCalculateCostMetrics:
|
||||
total_play_cnt=70000,
|
||||
)
|
||||
|
||||
# 验证都是2位小数
|
||||
assert isinstance(result["cpm"], float)
|
||||
assert len(str(result["cpm"]).split(".")[-1]) <= 2
|
||||
|
||||
|
||||
class TestNeedsApiCall:
|
||||
"""Tests for _needs_api_call helper."""
|
||||
|
||||
def test_needs_call_when_no_data(self):
|
||||
"""A3=0 且 cost=0 → 需要调 API"""
|
||||
video = _make_mock_video(total_new_a3_cnt=0, total_cost=0.0)
|
||||
assert _needs_api_call(video) is True
|
||||
|
||||
def test_needs_call_when_none_values(self):
|
||||
"""A3=None 且 cost=None → 需要调 API"""
|
||||
video = _make_mock_video(total_new_a3_cnt=None, total_cost=None)
|
||||
assert _needs_api_call(video) is True
|
||||
|
||||
def test_no_call_when_a3_exists(self):
|
||||
"""有 A3 数据 → 不需要调 API"""
|
||||
video = _make_mock_video(total_new_a3_cnt=500, total_cost=0.0)
|
||||
assert _needs_api_call(video) is False
|
||||
|
||||
def test_no_call_when_cost_exists(self):
|
||||
"""有 cost 数据 → 不需要调 API"""
|
||||
video = _make_mock_video(total_new_a3_cnt=0, total_cost=10000.0)
|
||||
assert _needs_api_call(video) is False
|
||||
|
||||
def test_no_call_when_both_exist(self):
|
||||
"""A3 和 cost 都有 → 不需要调 API"""
|
||||
video = _make_mock_video(total_new_a3_cnt=500, total_cost=10000.0)
|
||||
assert _needs_api_call(video) is False
|
||||
|
||||
|
||||
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
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_db_when_cached(self):
|
||||
"""数据库已有 A3/Cost → 直接使用,不调 API"""
|
||||
mock_video = _make_mock_video(
|
||||
total_new_a3_cnt=500,
|
||||
heated_new_a3_cnt=100,
|
||||
natural_new_a3_cnt=400,
|
||||
total_cost=10000.0,
|
||||
heated_cost=5000.0,
|
||||
)
|
||||
|
||||
# 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
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {"brand_001": "品牌A"}
|
||||
|
||||
with patch("app.services.video_analysis.fetch_yuntu_analysis") as mock_api:
|
||||
result = await get_video_analysis_data(mock_session, "video_123")
|
||||
|
||||
# API 不应被调用
|
||||
mock_api.assert_not_called()
|
||||
|
||||
# 验证使用了数据库数据
|
||||
assert result["a3_metrics"]["total_new_a3_cnt"] == 500
|
||||
assert result["a3_metrics"]["heated_new_a3_cnt"] == 100
|
||||
assert result["a3_metrics"]["natural_new_a3_cnt"] == 400
|
||||
assert result["cost_metrics"]["total_cost"] == 10000.0
|
||||
assert result["cost_metrics"]["heated_cost"] == 5000.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_api_and_saves_to_db(self):
|
||||
"""数据库无数据 → 调 API → 写回 DB"""
|
||||
mock_video = _make_mock_video(
|
||||
total_new_a3_cnt=0,
|
||||
total_cost=0.0,
|
||||
heated_cost=0.0,
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
call_count = [0]
|
||||
|
||||
async def mock_execute(stmt):
|
||||
stmt_str = str(stmt)
|
||||
if "SELECT" in stmt_str.upper() or call_count[0] == 0:
|
||||
call_count[0] += 1
|
||||
return mock_select_result
|
||||
return mock_update_result
|
||||
|
||||
mock_session.execute.side_effect = mock_execute
|
||||
|
||||
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,
|
||||
"a3_increase_cnt": "500",
|
||||
"ad_a3_increase_cnt": "100",
|
||||
"natural_a3_increase_cnt": "400",
|
||||
"cost": 15000,
|
||||
"ad_cost": 8000,
|
||||
"natural_cost": 0,
|
||||
"ad_cost": 10000,
|
||||
},
|
||||
}
|
||||
|
||||
with patch(
|
||||
"app.services.video_analysis.fetch_yuntu_analysis"
|
||||
) as mock_api:
|
||||
mock_api.return_value = api_response
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {"brand_001": "品牌A"}
|
||||
|
||||
result = await get_video_analysis_data(mock_session, "video_123")
|
||||
with patch("app.services.video_analysis.fetch_yuntu_analysis") as mock_api:
|
||||
mock_api.return_value = api_response
|
||||
|
||||
# T-027: 验证使用 industry_id 而不是 brand_id 调用 API
|
||||
mock_api.assert_called_once_with(
|
||||
item_id="video_123",
|
||||
publish_time=datetime(2025, 1, 15),
|
||||
industry_id="20",
|
||||
)
|
||||
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"] == "测试达人"
|
||||
# API 应被调用
|
||||
mock_api.assert_called_once_with(
|
||||
item_id="video_123",
|
||||
publish_time=datetime(2025, 1, 15),
|
||||
industry_id="20",
|
||||
)
|
||||
|
||||
# 验证触达指标
|
||||
assert result["reach_metrics"]["total_show_cnt"] == 100000
|
||||
assert result["reach_metrics"]["natural_play_cnt"] == 40000
|
||||
# 验证 A3 数据
|
||||
assert result["a3_metrics"]["total_new_a3_cnt"] == 500
|
||||
assert result["a3_metrics"]["heated_new_a3_cnt"] == 100
|
||||
assert result["a3_metrics"]["natural_new_a3_cnt"] == 400
|
||||
|
||||
# 验证A3指标
|
||||
assert result["a3_metrics"]["a3_increase_cnt"] == 500
|
||||
assert result["a3_metrics"]["natural_a3_increase_cnt"] == 400
|
||||
# 验证 cost
|
||||
assert result["cost_metrics"]["total_cost"] == 15000
|
||||
assert result["cost_metrics"]["heated_cost"] == 8000
|
||||
|
||||
# 验证搜索指标
|
||||
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
|
||||
# 验证计算指标存在
|
||||
assert "estimated_cpm" in result["calculated_metrics"]
|
||||
assert "estimated_natural_cpm" in result["calculated_metrics"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
@@ -237,87 +341,103 @@ class TestGetVideoAnalysisData:
|
||||
|
||||
assert "not found" in str(exc_info.value).lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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_video.total_new_a3_cnt = 500
|
||||
mock_video.heated_new_a3_cnt = 100
|
||||
mock_video.natural_new_a3_cnt = 400
|
||||
mock_video.total_cost = 10000
|
||||
"""API 失败 → 降级使用数据库数据"""
|
||||
mock_video = _make_mock_video(
|
||||
total_new_a3_cnt=0,
|
||||
heated_new_a3_cnt=0,
|
||||
natural_new_a3_cnt=0,
|
||||
total_cost=0.0,
|
||||
heated_cost=0.0,
|
||||
)
|
||||
|
||||
# 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")
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {}
|
||||
|
||||
result = await get_video_analysis_data(mock_session, "video_123")
|
||||
with patch("app.services.video_analysis.fetch_yuntu_analysis") as mock_api:
|
||||
mock_api.side_effect = YuntuAPIError("API Error")
|
||||
|
||||
# 应该使用数据库数据
|
||||
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
|
||||
result = await get_video_analysis_data(mock_session, "video_123")
|
||||
|
||||
# 降级使用 DB 数据(都是 0)
|
||||
assert result["a3_metrics"]["total_new_a3_cnt"] == 0
|
||||
assert result["cost_metrics"]["total_cost"] == 0.0
|
||||
|
||||
# 基础信息仍然正常
|
||||
assert result["base_info"]["vid"] == "video_123"
|
||||
assert result["reach_metrics"]["total_play_cnt"] == 50000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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_video = _make_mock_video(
|
||||
publish_time=None,
|
||||
create_date=None,
|
||||
total_new_a3_cnt=0,
|
||||
total_cost=0.0,
|
||||
total_play_cnt=0,
|
||||
natural_play_cnt=0,
|
||||
heated_play_cnt=0,
|
||||
after_view_search_uv=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": {}}
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {}
|
||||
|
||||
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"]["create_date"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_structure(self):
|
||||
"""验证返回数据包含所有 6 大类"""
|
||||
mock_video = _make_mock_video(total_new_a3_cnt=500, total_cost=10000.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.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {}
|
||||
|
||||
result = await get_video_analysis_data(mock_session, "video_123")
|
||||
|
||||
assert result["base_info"]["publish_time"] is None
|
||||
assert "base_info" in result
|
||||
assert "reach_metrics" in result
|
||||
assert "a3_metrics" in result
|
||||
assert "search_metrics" in result
|
||||
assert "cost_metrics" in result
|
||||
assert "calculated_metrics" in result
|
||||
|
||||
# base_info 关键字段
|
||||
assert "star_nickname" in result["base_info"]
|
||||
assert "vid" in result["base_info"]
|
||||
assert "brand_name" in result["base_info"]
|
||||
|
||||
# reach_metrics 关键字段
|
||||
assert "total_play_cnt" in result["reach_metrics"]
|
||||
assert "natural_play_cnt" in result["reach_metrics"]
|
||||
|
||||
|
||||
class TestUpdateVideoA3Metrics:
|
||||
"""Tests for update_video_a3_metrics function (T-025)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_success(self):
|
||||
"""Test successful A3 metrics update."""
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.rowcount = 1
|
||||
@@ -335,8 +455,29 @@ class TestUpdateVideoA3Metrics:
|
||||
assert result is True
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_with_heated_cost(self):
|
||||
"""验证 heated_cost 参数正常传递"""
|
||||
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=15000.0,
|
||||
heated_cost=8000.0,
|
||||
)
|
||||
|
||||
assert result is True
|
||||
mock_session.commit.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_video_not_found(self):
|
||||
"""Test update when video not found."""
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.rowcount = 0
|
||||
@@ -353,8 +494,8 @@ class TestUpdateVideoA3Metrics:
|
||||
|
||||
assert result is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_database_error(self):
|
||||
"""Test update with database error."""
|
||||
mock_session = AsyncMock()
|
||||
mock_session.execute.side_effect = Exception("Database error")
|
||||
|
||||
@@ -371,64 +512,249 @@ class TestUpdateVideoA3Metrics:
|
||||
mock_session.rollback.assert_called_once()
|
||||
|
||||
|
||||
class TestGetAndUpdateVideoAnalysis:
|
||||
"""Tests for get_and_update_video_analysis function (T-024 + T-025)."""
|
||||
class TestBuildVideoListItem:
|
||||
"""Tests for _build_video_list_item helper."""
|
||||
|
||||
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
|
||||
def test_build_item_with_full_data(self):
|
||||
video = _make_mock_video(
|
||||
total_play_cnt=50000,
|
||||
natural_play_cnt=40000,
|
||||
after_view_search_uv=1000,
|
||||
estimated_video_cost=10000.0,
|
||||
)
|
||||
|
||||
result = _build_video_list_item(
|
||||
video=video,
|
||||
a3_increase_cnt=500,
|
||||
ad_a3_increase_cnt=100,
|
||||
natural_a3_increase_cnt=400,
|
||||
api_cost=15000.0,
|
||||
brand_name="品牌A",
|
||||
)
|
||||
|
||||
assert result["item_id"] == "video_123"
|
||||
assert result["brand_name"] == "品牌A"
|
||||
assert result["total_new_a3_cnt"] == 500
|
||||
assert result["estimated_natural_cpm"] is not None
|
||||
assert result["estimated_cp_a3"] == 30.0 # 15000/500
|
||||
|
||||
def test_build_item_zero_division(self):
|
||||
"""分母为 0 时应返回 None"""
|
||||
video = _make_mock_video(
|
||||
total_play_cnt=0,
|
||||
natural_play_cnt=0,
|
||||
after_view_search_uv=0,
|
||||
estimated_video_cost=0.0,
|
||||
)
|
||||
|
||||
result = _build_video_list_item(
|
||||
video=video,
|
||||
a3_increase_cnt=0,
|
||||
ad_a3_increase_cnt=0,
|
||||
natural_a3_increase_cnt=0,
|
||||
api_cost=0.0,
|
||||
brand_name="",
|
||||
)
|
||||
|
||||
assert result["estimated_natural_cpm"] is None
|
||||
assert result["estimated_cp_a3"] is None
|
||||
assert result["estimated_natural_cp_a3"] is None
|
||||
assert result["estimated_cp_search"] is None
|
||||
assert result["estimated_natural_cp_search"] is None
|
||||
|
||||
|
||||
class TestGetVideoListWithA3:
|
||||
"""Tests for get_video_list_with_a3 function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_cached(self):
|
||||
"""所有视频都有缓存 → 不调 API"""
|
||||
videos = [
|
||||
_make_mock_video(
|
||||
item_id="v1", total_new_a3_cnt=500, total_cost=10000.0, brand_id="b1"
|
||||
),
|
||||
_make_mock_video(
|
||||
item_id="v2", total_new_a3_cnt=300, total_cost=8000.0, brand_id="b2"
|
||||
),
|
||||
]
|
||||
|
||||
# Mock session
|
||||
mock_session = AsyncMock()
|
||||
mock_select_result = MagicMock()
|
||||
mock_select_result.scalar_one_or_none.return_value = mock_video
|
||||
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {"b1": "品牌1", "b2": "品牌2"}
|
||||
|
||||
with patch("app.services.video_analysis.call_yuntu_api") as mock_api:
|
||||
result = await get_video_list_with_a3(mock_session, videos)
|
||||
|
||||
mock_api.assert_not_called()
|
||||
assert len(result) == 2
|
||||
assert result[0]["item_id"] == "v1"
|
||||
assert result[0]["total_new_a3_cnt"] == 500
|
||||
assert result[1]["item_id"] == "v2"
|
||||
assert result[1]["total_new_a3_cnt"] == 300
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_need_api(self):
|
||||
"""所有视频都需要 API → 并发调用 → 首次即返回正确数据 → gather 后顺序写 DB"""
|
||||
videos = [
|
||||
_make_mock_video(
|
||||
item_id="v1", total_new_a3_cnt=0, total_cost=0.0, brand_id="b1"
|
||||
),
|
||||
_make_mock_video(
|
||||
item_id="v2", total_new_a3_cnt=0, total_cost=0.0, brand_id="b2"
|
||||
),
|
||||
]
|
||||
|
||||
mock_session = AsyncMock()
|
||||
mock_update_result = MagicMock()
|
||||
mock_update_result.rowcount = 1
|
||||
mock_session.execute.return_value = mock_update_result
|
||||
|
||||
# 根据不同的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,
|
||||
},
|
||||
api_response = {
|
||||
"data": {
|
||||
"a3_increase_cnt": "200",
|
||||
"ad_a3_increase_cnt": "50",
|
||||
"natural_a3_increase_cnt": "150",
|
||||
"cost": 5000,
|
||||
"ad_cost": 3000,
|
||||
}
|
||||
}
|
||||
|
||||
result = await get_and_update_video_analysis(mock_session, "video_123")
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {}
|
||||
|
||||
# 验证返回数据
|
||||
assert result["a3_metrics"]["a3_increase_cnt"] == 500
|
||||
with patch("app.services.video_analysis.call_yuntu_api", new_callable=AsyncMock) as mock_api:
|
||||
mock_api.return_value = api_response
|
||||
|
||||
# 验证数据库更新被调用
|
||||
mock_session.commit.assert_called()
|
||||
with patch("app.services.video_analysis.get_distinct_configs", new_callable=AsyncMock) as mock_configs:
|
||||
mock_configs.return_value = [
|
||||
{"aadvid": "aad1", "auth_token": "tok1"},
|
||||
{"aadvid": "aad2", "auth_token": "tok2"},
|
||||
]
|
||||
|
||||
with patch("app.services.video_analysis.update_video_a3_metrics", new_callable=AsyncMock) as mock_update:
|
||||
mock_update.return_value = True
|
||||
|
||||
result = await get_video_list_with_a3(mock_session, videos)
|
||||
|
||||
assert len(result) == 2
|
||||
assert mock_api.call_count == 2
|
||||
|
||||
# 首次查询即返回正确 API 数据(核心:不依赖 DB 写入成功)
|
||||
assert result[0]["total_new_a3_cnt"] == 200
|
||||
assert result[1]["total_new_a3_cnt"] == 200
|
||||
|
||||
# 验证两个视频用了不同 config
|
||||
api_calls = mock_api.call_args_list
|
||||
tokens = {c.kwargs["auth_token"] for c in api_calls}
|
||||
assert len(tokens) == 2
|
||||
|
||||
# DB 写入在 gather 之后顺序执行
|
||||
assert mock_update.call_count == 2
|
||||
update_item_ids = [c.kwargs["item_id"] for c in mock_update.call_args_list]
|
||||
assert "v1" in update_item_ids
|
||||
assert "v2" in update_item_ids
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_cached_and_api(self):
|
||||
"""混合场景:部分缓存,部分需 API → 只对 API 成功的写 DB"""
|
||||
videos = [
|
||||
_make_mock_video(
|
||||
item_id="v1", total_new_a3_cnt=500, total_cost=10000.0, brand_id="b1"
|
||||
),
|
||||
_make_mock_video(
|
||||
item_id="v2", total_new_a3_cnt=0, total_cost=0.0, brand_id="b2"
|
||||
),
|
||||
_make_mock_video(
|
||||
item_id="v3", total_new_a3_cnt=300, total_cost=5000.0, brand_id="b3"
|
||||
),
|
||||
]
|
||||
|
||||
mock_session = AsyncMock()
|
||||
|
||||
api_response = {
|
||||
"data": {
|
||||
"a3_increase_cnt": "200",
|
||||
"ad_a3_increase_cnt": "50",
|
||||
"natural_a3_increase_cnt": "150",
|
||||
"cost": 5000,
|
||||
"ad_cost": 3000,
|
||||
}
|
||||
}
|
||||
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {}
|
||||
|
||||
with patch("app.services.video_analysis.call_yuntu_api", new_callable=AsyncMock) as mock_api:
|
||||
mock_api.return_value = api_response
|
||||
|
||||
with patch("app.services.video_analysis.get_distinct_configs", new_callable=AsyncMock) as mock_configs:
|
||||
mock_configs.return_value = [
|
||||
{"aadvid": "aad1", "auth_token": "tok1"},
|
||||
]
|
||||
|
||||
with patch("app.services.video_analysis.update_video_a3_metrics", new_callable=AsyncMock) as mock_update:
|
||||
mock_update.return_value = True
|
||||
|
||||
result = await get_video_list_with_a3(mock_session, videos)
|
||||
|
||||
# 保持原始排序
|
||||
assert len(result) == 3
|
||||
assert result[0]["item_id"] == "v1"
|
||||
assert result[0]["total_new_a3_cnt"] == 500 # from DB
|
||||
assert result[1]["item_id"] == "v2"
|
||||
assert result[1]["total_new_a3_cnt"] == 200 # from API
|
||||
assert result[2]["item_id"] == "v3"
|
||||
assert result[2]["total_new_a3_cnt"] == 300 # from DB
|
||||
|
||||
# 只有 v2 调了 API
|
||||
assert mock_api.call_count == 1
|
||||
|
||||
# 只对 v2 写回 DB
|
||||
assert mock_update.call_count == 1
|
||||
assert mock_update.call_args.kwargs["item_id"] == "v2"
|
||||
assert mock_update.call_args.kwargs["total_new_a3_cnt"] == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_list(self):
|
||||
"""空列表 → 返回空"""
|
||||
mock_session = AsyncMock()
|
||||
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {}
|
||||
|
||||
result = await get_video_list_with_a3(mock_session, [])
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_failure_fallback(self):
|
||||
"""API 调用失败 → 降级使用 DB 数据 → 不写回 DB"""
|
||||
videos = [
|
||||
_make_mock_video(
|
||||
item_id="v1", total_new_a3_cnt=0, total_cost=0.0, brand_id="b1"
|
||||
),
|
||||
]
|
||||
|
||||
mock_session = AsyncMock()
|
||||
|
||||
with patch("app.services.brand_api.get_brand_names", new_callable=AsyncMock) as mock_brands:
|
||||
mock_brands.return_value = {}
|
||||
|
||||
with patch("app.services.video_analysis.call_yuntu_api", new_callable=AsyncMock) as mock_api:
|
||||
mock_api.side_effect = YuntuAPIError("API Error")
|
||||
|
||||
with patch("app.services.video_analysis.get_distinct_configs", new_callable=AsyncMock) as mock_configs:
|
||||
mock_configs.return_value = [
|
||||
{"aadvid": "aad1", "auth_token": "tok1"},
|
||||
]
|
||||
|
||||
with patch("app.services.video_analysis.update_video_a3_metrics", new_callable=AsyncMock) as mock_update:
|
||||
result = await get_video_list_with_a3(mock_session, videos)
|
||||
|
||||
# 降级到 DB 数据
|
||||
assert len(result) == 1
|
||||
assert result[0]["total_new_a3_cnt"] == 0
|
||||
|
||||
# API 失败不应写回 DB
|
||||
mock_update.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user