增加接口测试功能

This commit is contained in:
mark.tian
2025-12-02 17:51:33 +08:00
parent 4cf817eadf
commit 9cb45204c9
6 changed files with 584 additions and 2 deletions

View File

@@ -1,5 +1,5 @@
# 数据库配置
DATABASE_URL=postgresql+asyncpg://user:password@localhost:5432/ai_talk_callback_db
DATABASE_URL=postgresql+asyncpg://postgres:12345@localhost:5432/ai_talk_callback_db
# Redis配置
REDIS_URL=redis://localhost:6379/0

156
TEST_README.md Normal file
View File

@@ -0,0 +1,156 @@
# AI Talk Callback 接口测试说明
## 概述
本项目为 AI Talk 回调接口编写了完整的测试用例,覆盖了各种场景和边界条件。
## 测试文件
- `test_callback.py` - 主要测试文件,包含所有接口测试用例
- `conftest.py` - pytest配置文件,包含测试fixtures
- `run_tests.py` - 测试运行脚本
## 测试覆盖的场景
### 1. 基础功能测试
- ✅ 根接口测试 (`GET /`)
- ✅ 健康检查接口测试 (`GET /health`)
### 2. 回调接口测试 (`POST /ai-talk/callback/{siteId}`)
#### 2.1 正常业务流程
- ✅ count < 3 且外部API调用成功
- ✅ count >= 3 直接返回(不调用外部API)
- ✅ 空数据列表处理
#### 2.2 异常情况处理
- ✅ 外部API调用失败
- ✅ 数据库连接错误
- ✅ Redis锁获取失败
- ✅ 服务器内部错误
#### 2.3 数据验证
- ✅ 无效请求数据(缺少必需字段)
- ✅ 负数count值验证
## 运行测试
### 方法1: 使用pytest直接运行
```bash
# 安装测试依赖
pip install -r requirements.txt
# 运行所有测试
pytest test_callback.py -v
# 运行特定测试
pytest test_callback.py::TestAITalkCallback::test_callback_count_below_threshold_success -v
# 运行并显示覆盖率
pytest test_callback.py -v --cov=app --cov-report=html
```
### 方法2: 使用运行脚本
```bash
# 运行所有测试
python run_tests.py
# 运行特定测试
python run_tests.py test_callback_count_below_threshold_success
```
## 测试数据
测试使用了模拟的回调数据,包含完整的字段结构:
```python
{
"count": 2,
"data": [
{
"bill": 100,
"duration": 60,
"callid": "test-call-123",
"calldate": "2024-01-01 12:00:00",
"number": "13800138000",
"numberid": "number-123",
"customer_id": "customer-123",
"status": 0,
"status_str": "失败",
"user_id": "user-123",
"type": 1,
"number_data": {
"number": "13800138000",
"province": "广东",
"city": "深圳",
"operator": "移动"
},
"group": {
"id": 1,
"name": "测试组"
},
"task": {
"id": "task-123",
"name": "测试任务"
},
"user": {
"id": "user-123",
"name": "测试用户"
},
"customer_data": {
"name": "测试客户",
"email": "test@example.com",
"company": "测试公司",
"extra": "额外信息"
}
}
]
}
```
## Mock策略
测试中使用了以下Mock策略:
1. **数据库Mock**: 使用 `AsyncMock` 模拟数据库会话
2. **Redis Mock**: 模拟Redis分布式锁操作
3. **外部API Mock**: 模拟外部API调用和重试机制
4. **日志记录Mock**: 模拟数据库日志记录功能
## 测试环境配置
测试使用SQLite内存数据库,避免影响生产数据:
```python
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
```
## 预期测试结果
成功运行时应该看到类似输出:
```
============================= test session starts =============================
collected 10 items
test_callback.py::TestAITalkCallback::test_root_endpoint PASSED [ 10%]
test_callback.py::TestAITalkCallback::test_health_check PASSED [ 20%]
test_callback.py::TestAITalkCallback::test_callback_count_below_threshold_success PASSED [ 30%]
test_callback.py::TestAITalkCallback::test_callback_count_above_threshold_direct_return PASSED [ 40%]
test_callback.py::TestAITalkCallback::test_callback_external_api_failure PASSED [ 50%]
test_callback.py::TestAITalkCallback::test_callback_invalid_request_data PASSED [ 60%]
test_callback.py::TestAITalkCallback::test_callback_invalid_count_negative PASSED [ 70%]
test_callback.py::TestAITalkCallback::test_callback_empty_data_list PASSED [ 80%]
test_callback.py::TestAITalkCallback::test_callback_database_error PASSED [ 90%]
test_callback.py::TestAITalkCallback::test_callback_redis_lock_error PASSED [100%]
============================== 10 passed in 2.34s ==============================
✅ 所有测试通过!
```
## 注意事项
1. 确保安装了所有测试依赖
2. 测试运行时会创建临时SQLite数据库文件
3. 测试使用了Mock,不会实际调用外部API
4. 建议在CI/CD环境中集成这些测试

44
conftest.py Normal file
View File

@@ -0,0 +1,44 @@
import pytest
import asyncio
from unittest.mock import AsyncMock
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
# 测试数据库配置
TEST_DATABASE_URL = "sqlite+aiosqlite:///./test.db"
@pytest.fixture(scope="session")
def event_loop():
"""创建一个事件循环实例用于测试"""
loop = asyncio.get_event_loop_policy().new_event_loop()
yield loop
loop.close()
@pytest.fixture
async def test_engine():
"""创建测试数据库引擎"""
from sqlalchemy.ext.asyncio import create_async_engine
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
yield engine
await engine.dispose()
@pytest.fixture
async def test_db_session(test_engine):
"""创建测试数据库会话"""
from app.database import Base
# 创建所有表
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# 创建会话
async_session = sessionmaker(
test_engine, class_=AsyncSession, expire_on_commit=False
)
async with async_session() as session:
yield session
# 清理:删除所有表
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

View File

@@ -9,4 +9,7 @@ pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
httpx==0.25.2
python-dotenv==1.0.0
python-dotenv==1.0.0
pytest==7.4.3
pytest-asyncio==0.21.1
aiosqlite==0.19.0

77
run_tests.py Normal file
View File

@@ -0,0 +1,77 @@
#!/usr/bin/env python3
"""
测试运行脚本
"""
import subprocess
import sys
import os
def run_tests():
"""运行所有测试"""
print("🧪 开始运行AI Talk回调接口测试...")
# 设置环境变量为测试模式
os.environ["TESTING"] = "1"
try:
# 运行pytest
result = subprocess.run([
sys.executable, "-m", "pytest",
"test_callback.py",
"-v",
"--tb=short",
"--color=yes"
], capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print("错误信息:")
print(result.stderr)
if result.returncode == 0:
print("✅ 所有测试通过!")
return True
else:
print("❌ 测试失败!")
return False
except Exception as e:
print(f"❌ 运行测试时出错: {e}")
return False
def run_specific_test(test_name: str):
"""运行特定测试"""
print(f"🧪 运行特定测试: {test_name}")
os.environ["TESTING"] = "1"
try:
result = subprocess.run([
sys.executable, "-m", "pytest",
"test_callback.py",
f"-k={test_name}",
"-v",
"--tb=short",
"--color=yes"
], capture_output=True, text=True)
print(result.stdout)
if result.stderr:
print("错误信息:")
print(result.stderr)
return result.returncode == 0
except Exception as e:
print(f"❌ 运行测试时出错: {e}")
return False
if __name__ == "__main__":
if len(sys.argv) > 1:
# 运行特定测试
test_name = sys.argv[1]
run_specific_test(test_name)
else:
# 运行所有测试
run_tests()

302
test_callback.py Normal file
View File

@@ -0,0 +1,302 @@
import pytest
import asyncio
from fastapi.testclient import TestClient
from unittest.mock import AsyncMock, patch, MagicMock
from sqlalchemy.ext.asyncio import AsyncSession
from main import app
from app.models import CallbackRequest, CallbackItem, NumberData, Group, Task, User, CustomerData
client = TestClient(app)
class TestAITalkCallback:
"""AI Talk回调接口测试用例"""
def setup_method(self):
"""每个测试方法执行前的设置"""
self.sample_callback_item = CallbackItem(
bill=100,
duration=60,
callid="test-call-123",
calldate="2024-01-01 12:00:00",
number="13800138000",
numberid="number-123",
customer_id="customer-123",
status=0,
status_str="失败",
user_id="user-123",
type=1,
number_data=NumberData(
number="13800138000",
province="广东",
city="深圳",
operator="移动"
),
group=Group(
id=1,
name="测试组"
),
task=Task(
id="task-123",
name="测试任务"
),
user=User(
id="user-123",
name="测试用户"
),
customer_data=CustomerData(
name="测试客户",
email="test@example.com",
company="测试公司",
extra="额外信息"
)
)
@pytest.fixture
def mock_db_session(self):
"""模拟数据库会话"""
return AsyncMock(spec=AsyncSession)
@pytest.fixture
def sample_callback_request(self):
"""示例回调请求数据"""
return CallbackRequest(
count=2,
data=[self.sample_callback_item]
)
@pytest.fixture
def sample_callback_request_high_count(self):
"""高count示例回调请求数据"""
return CallbackRequest(
count=5,
data=[self.sample_callback_item]
)
def test_root_endpoint(self):
"""测试根接口"""
response = client.get("/")
assert response.status_code == 200
assert "message" in response.json()
def test_health_check(self):
"""测试健康检查接口"""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
@patch('app.routes.get_db')
@patch('app.routes.redis_manager')
@patch('app.routes.log_callback_request')
def test_callback_count_below_threshold_success(
self,
mock_log_callback,
mock_redis_manager,
mock_get_db,
sample_callback_request
):
"""测试count小于阈值且外部API调用成功的情况"""
# 设置模拟对象
mock_db = AsyncMock()
mock_get_db.return_value = mock_db
# 模拟Redis锁
mock_lock = AsyncMock()
mock_lock.__aenter__ = AsyncMock(return_value=None)
mock_lock.__aexit__ = AsyncMock(return_value=None)
mock_redis_manager.create_lock.return_value = mock_lock
# 模拟外部API调用成功
with patch('app.routes.call_external_api_with_retry') as mock_api_call:
mock_api_call.return_value = (True, 0)
response = client.post(
"/ai-talk/callback/test-site-123",
json=sample_callback_request.model_dump()
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["processed"] is True
assert data["retry_count"] == 0
assert data["site_id"] == "test-site-123"
assert "外部API调用成功" in data["message"]
@patch('app.routes.get_db')
@patch('app.routes.redis_manager')
@patch('app.routes.log_callback_request')
def test_callback_count_above_threshold_direct_return(
self,
mock_log_callback,
mock_redis_manager,
mock_get_db,
sample_callback_request_high_count
):
"""测试count大于等于阈值直接返回的情况"""
# 设置模拟对象
mock_db = AsyncMock()
mock_get_db.return_value = mock_db
response = client.post(
"/ai-talk/callback/test-site-456",
json=sample_callback_request_high_count.model_dump()
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["processed"] is False
assert data["retry_count"] == 0
assert data["site_id"] == "test-site-456"
assert "count=5 >= 3" in data["message"]
@patch('app.routes.get_db')
@patch('app.routes.redis_manager')
@patch('app.routes.log_callback_request')
def test_callback_external_api_failure(
self,
mock_log_callback,
mock_redis_manager,
mock_get_db,
sample_callback_request
):
"""测试外部API调用失败的情况"""
# 设置模拟对象
mock_db = AsyncMock()
mock_get_db.return_value = mock_db
# 模拟Redis锁
mock_lock = AsyncMock()
mock_lock.__aenter__ = AsyncMock(return_value=None)
mock_lock.__aexit__ = AsyncMock(return_value=None)
mock_redis_manager.create_lock.return_value = mock_lock
# 模拟外部API调用失败
with patch('app.routes.call_external_api_with_retry') as mock_api_call:
mock_api_call.return_value = (False, 3)
response = client.post(
"/ai-talk/callback/test-site-789",
json=sample_callback_request.model_dump()
)
assert response.status_code == 500
assert "外部API调用失败" in response.json()["detail"]
def test_callback_invalid_request_data(self):
"""测试无效的请求数据"""
# 测试缺少必需字段
invalid_request = {
"data": [] # 缺少count字段
}
response = client.post(
"/ai-talk/callback/test-site-invalid",
json=invalid_request
)
assert response.status_code == 422 # Validation error
def test_callback_invalid_count_negative(self):
"""测试负数count值"""
invalid_request = {
"count": -1,
"data": []
}
response = client.post(
"/ai-talk/callback/test-site-negative",
json=invalid_request
)
assert response.status_code == 422 # Validation error
@patch('app.routes.get_db')
@patch('app.routes.redis_manager')
@patch('app.routes.log_callback_request')
def test_callback_empty_data_list(
self,
mock_log_callback,
mock_redis_manager,
mock_get_db
):
"""测试空数据列表的情况"""
# 设置模拟对象
mock_db = AsyncMock()
mock_get_db.return_value = mock_db
# 模拟Redis锁
mock_lock = AsyncMock()
mock_lock.__aenter__ = AsyncMock(return_value=None)
mock_lock.__aexit__ = AsyncMock(return_value=None)
mock_redis_manager.create_lock.return_value = mock_lock
# 模拟外部API调用成功
with patch('app.routes.call_external_api_with_retry') as mock_api_call:
mock_api_call.return_value = (True, 0)
request = CallbackRequest(count=0, data=[])
response = client.post(
"/ai-talk/callback/test-site-empty",
json=request.model_dump()
)
assert response.status_code == 200
data = response.json()
assert data["success"] is True
assert data["processed"] is True
@patch('app.routes.get_db')
@patch('app.routes.redis_manager')
@patch('app.routes.log_callback_request')
def test_callback_database_error(
self,
mock_log_callback,
mock_redis_manager,
mock_get_db,
sample_callback_request
):
"""测试数据库错误的情况"""
# 模拟数据库错误
mock_log_callback.side_effect = Exception("数据库连接失败")
response = client.post(
"/ai-talk/callback/test-site-db-error",
json=sample_callback_request.model_dump()
)
assert response.status_code == 500
assert "服务器内部错误" in response.json()["detail"]
@patch('app.routes.get_db')
@patch('app.routes.redis_manager')
@patch('app.routes.log_callback_request')
def test_callback_redis_lock_error(
self,
mock_log_callback,
mock_redis_manager,
mock_get_db,
sample_callback_request
):
"""测试Redis锁错误的情况"""
# 设置模拟对象
mock_db = AsyncMock()
mock_get_db.return_value = mock_db
# 模拟Redis锁错误
mock_redis_manager.create_lock.side_effect = Exception("Redis连接失败")
response = client.post(
"/ai-talk/callback/test-site-redis-error",
json=sample_callback_request.model_dump()
)
assert response.status_code == 500
assert "服务器内部错误" in response.json()["detail"]
if __name__ == "__main__":
pytest.main([__file__, "-v"])