feat(backend): 视频分析模块增加缓存优先策略和并发API调用

- SessionPool 新增 get_distinct_configs 方法,支持获取不同配置用于并发调用
- video_analysis 重构为缓存优先策略:数据库有 A3/Cost 数据时直接使用
- 并发 API 调用预分配不同 cookie,避免 session 冲突
- API 数据写回数据库,实现下次查询缓存命中
- 新增 heated_cost 字段追踪
- 测试全面重写,覆盖缓存/API/混合/降级场景
This commit is contained in:
zfc
2026-01-29 18:21:50 +08:00
parent c53b5008df
commit 376f0be6b4
4 changed files with 983 additions and 326 deletions
+128
View File
@@ -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
+526 -200
View File
@@ -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()