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>
288 lines
12 KiB
Python
288 lines
12 KiB
Python
import pytest
|
||
import asyncio
|
||
from unittest.mock import AsyncMock, patch, MagicMock
|
||
import httpx
|
||
|
||
from app.services.brand_api import get_brand_names, fetch_brand_name
|
||
|
||
|
||
class TestBrandAPI:
|
||
"""Tests for Brand API integration."""
|
||
|
||
async def test_get_brand_names_success(self):
|
||
"""Test successful brand name fetching."""
|
||
with patch("app.services.brand_api.fetch_brand_name") as mock_fetch:
|
||
mock_fetch.side_effect = [
|
||
("brand_001", "品牌A"),
|
||
("brand_002", "品牌B"),
|
||
]
|
||
|
||
result = await get_brand_names(["brand_001", "brand_002"])
|
||
|
||
assert result["brand_001"] == "品牌A"
|
||
assert result["brand_002"] == "品牌B"
|
||
|
||
async def test_get_brand_names_empty_list(self):
|
||
"""Test with empty brand ID list."""
|
||
result = await get_brand_names([])
|
||
assert result == {}
|
||
|
||
async def test_get_brand_names_with_none_values(self):
|
||
"""Test filtering out None values."""
|
||
with patch("app.services.brand_api.fetch_brand_name") as mock_fetch:
|
||
mock_fetch.return_value = ("brand_001", "品牌A")
|
||
|
||
result = await get_brand_names(["brand_001", None, ""])
|
||
|
||
assert "brand_001" in result
|
||
assert len(result) == 1
|
||
|
||
async def test_get_brand_names_deduplication(self):
|
||
"""Test that duplicate brand IDs are deduplicated."""
|
||
with patch("app.services.brand_api.fetch_brand_name") as mock_fetch:
|
||
mock_fetch.return_value = ("brand_001", "品牌A")
|
||
|
||
result = await get_brand_names(["brand_001", "brand_001", "brand_001"])
|
||
|
||
# Should only call once due to deduplication
|
||
assert mock_fetch.call_count == 1
|
||
|
||
async def test_get_brand_names_partial_failure(self):
|
||
"""Test that partial failures don't break the whole batch."""
|
||
with patch("app.services.brand_api.fetch_brand_name") as mock_fetch:
|
||
mock_fetch.side_effect = [
|
||
("brand_001", "品牌A"),
|
||
("brand_002", "brand_002"), # Fallback to ID
|
||
("brand_003", "品牌C"),
|
||
]
|
||
|
||
result = await get_brand_names(["brand_001", "brand_002", "brand_003"])
|
||
|
||
assert result["brand_001"] == "品牌A"
|
||
assert result["brand_002"] == "brand_002" # Fallback
|
||
assert result["brand_003"] == "品牌C"
|
||
|
||
async def test_fetch_brand_name_success(self):
|
||
"""Test successful single brand fetch via get_brand_names."""
|
||
# 使用更高层的 mock,测试整个流程
|
||
with patch("app.services.brand_api.fetch_brand_name") as mock_fetch:
|
||
mock_fetch.return_value = ("test_id", "测试品牌")
|
||
|
||
result = await get_brand_names(["test_id"])
|
||
|
||
assert result["test_id"] == "测试品牌"
|
||
|
||
async def test_fetch_brand_name_failure(self):
|
||
"""Test brand fetch failure returns ID as fallback."""
|
||
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):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("test_id", semaphore)
|
||
|
||
assert brand_id == "test_id"
|
||
assert brand_name == "test_id" # Fallback to ID
|
||
|
||
async def test_fetch_brand_name_404(self):
|
||
"""Test brand fetch with 404 returns ID as fallback."""
|
||
mock_response = AsyncMock()
|
||
mock_response.status_code = 404
|
||
|
||
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):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("nonexistent", semaphore)
|
||
|
||
assert brand_id == "nonexistent"
|
||
assert brand_name == "nonexistent"
|
||
|
||
async def test_concurrency_limit(self):
|
||
"""Test that concurrency is limited."""
|
||
with patch("app.services.brand_api.fetch_brand_name") as mock_fetch:
|
||
# 创建 15 个品牌 ID
|
||
brand_ids = [f"brand_{i:03d}" for i in range(15)]
|
||
mock_fetch.side_effect = [(id, f"名称_{id}") for id in brand_ids]
|
||
|
||
result = await get_brand_names(brand_ids)
|
||
|
||
assert len(result) == 15
|
||
# 验证所有调用都完成了
|
||
assert mock_fetch.call_count == 15
|
||
|
||
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 = {
|
||
"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
|
||
mock_client.__aenter__.return_value = mock_client
|
||
mock_client.__aexit__.return_value = None
|
||
|
||
with patch("httpx.AsyncClient", return_value=mock_client):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("533661", semaphore)
|
||
|
||
assert brand_id == "533661"
|
||
assert brand_name == "Giving/启初"
|
||
|
||
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 = {
|
||
"total": 0,
|
||
"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):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("unknown_brand", semaphore)
|
||
|
||
assert brand_id == "unknown_brand"
|
||
assert brand_name == "unknown_brand" # Fallback
|
||
|
||
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 = {
|
||
"total": 1,
|
||
"data": [{"brand_id": 123}] # No brand_name field
|
||
}
|
||
|
||
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):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("brand_no_name", semaphore)
|
||
|
||
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()
|
||
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):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("brand_error", semaphore)
|
||
|
||
assert brand_id == "brand_error"
|
||
assert brand_name == "brand_error" # Fallback
|
||
|
||
async def test_fetch_brand_name_unexpected_error(self):
|
||
"""Test brand fetch with unexpected error."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.side_effect = ValueError("Invalid JSON")
|
||
|
||
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):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("brand_json_error", semaphore)
|
||
|
||
assert brand_id == "brand_json_error"
|
||
assert brand_name == "brand_json_error" # Fallback
|
||
|
||
async def test_get_brand_names_with_exception_in_gather(self):
|
||
"""Test that exceptions in asyncio.gather are handled."""
|
||
with patch("app.services.brand_api.fetch_brand_name") as mock_fetch:
|
||
# 第二个调用抛出异常
|
||
mock_fetch.side_effect = [
|
||
("brand_001", "品牌A"),
|
||
Exception("Unexpected error"),
|
||
("brand_003", "品牌C"),
|
||
]
|
||
|
||
result = await get_brand_names(["brand_001", "brand_002", "brand_003"])
|
||
|
||
# 成功的应该在结果中
|
||
assert result["brand_001"] == "品牌A"
|
||
assert result["brand_003"] == "品牌C"
|
||
# 失败的不应该在结果中(因为是 Exception,不是 tuple)
|
||
assert "brand_002" not in result
|
||
|
||
async def test_fetch_brand_name_non_dict_response(self):
|
||
"""Test brand fetch with non-dict response."""
|
||
mock_response = MagicMock()
|
||
mock_response.status_code = 200
|
||
mock_response.json.return_value = ["not", "a", "dict"] # Array instead of 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):
|
||
semaphore = asyncio.Semaphore(10)
|
||
brand_id, brand_name = await fetch_brand_name("brand_array", semaphore)
|
||
|
||
assert brand_id == "brand_array"
|
||
assert brand_name == "brand_array" # Fallback because response is not dict
|