本次提交修复了多个测试文件中的问题: 1. 将 ChannelType 枚举调用改为字符串实例化方式 2. 修正了日志断言、异步mock使用、配置参数等多处测试细节 3. 新增了会话聚合根、跨渠道关联策略等单元测试用例 4. 修复了路由测试中的路径方法错误与断言逻辑 5. 调整了依赖导入与测试夹具的兼容性 6. 统一了重试回退调度的列表/元组使用规范
349 lines
11 KiB
Python
349 lines
11 KiB
Python
"""Integration tests for channels message_router endpoints."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from .conftest import (
|
||
BASE_URL,
|
||
DEFAULT_CHANNEL_TYPE,
|
||
ISO_TIME_END,
|
||
ISO_TIME_START,
|
||
NON_EXISTENT_MESSAGE_ID,
|
||
)
|
||
|
||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||
|
||
MESSAGES_URL = f"{BASE_URL}/messages"
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for GET /messages ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_list_messages_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(MESSAGES_URL)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_list_messages_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(MESSAGES_URL, headers=standard_user["headers"])
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
async def test_admin_can_list_messages(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(MESSAGES_URL, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], dict)
|
||
assert isinstance(payload["data"]["items"], list)
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /messages pagination validation ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_list_messages_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=0 violates ge=1
|
||
response = await test_client.get(
|
||
MESSAGES_URL,
|
||
params={"limit": 0},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_list_messages_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=201 violates le=200
|
||
response = await test_client.get(
|
||
MESSAGES_URL,
|
||
params={"limit": 201},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_admin_can_list_messages_with_time_range(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
params = {
|
||
"channel_type": DEFAULT_CHANNEL_TYPE,
|
||
"start_time": ISO_TIME_START,
|
||
"end_time": ISO_TIME_END,
|
||
"limit": 10,
|
||
"offset": 0,
|
||
}
|
||
# Act
|
||
response = await test_client.get(MESSAGES_URL, params=params, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], dict)
|
||
assert isinstance(payload["data"]["items"], list)
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /messages/search ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_search_messages_requires_keyword(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — keyword is required
|
||
response = await test_client.get(f"{MESSAGES_URL}/search", headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_search_messages_rejects_short_keyword(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — keyword shorter than min_length=2
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/search",
|
||
params={"keyword": "a"},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_admin_can_search_messages(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange
|
||
params = {
|
||
"keyword": "pytest",
|
||
"channel_type": DEFAULT_CHANNEL_TYPE,
|
||
"limit": 5,
|
||
"offset": 0,
|
||
}
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/search",
|
||
params=params,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert isinstance(payload["data"], dict)
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /messages/batch-recall ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_batch_recall_rejects_empty_message_ids(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — message_ids min_length=1
|
||
body = {"message_ids": []}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{MESSAGES_URL}/batch-recall",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_batch_recall_rejects_too_many_message_ids(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — message_ids max_length=500
|
||
body = {"message_ids": [f"msg_{i}" for i in range(501)]}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{MESSAGES_URL}/batch-recall",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_batch_recall_with_non_existent_ids_returns_partial_success(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — non-existent message_ids, partial success semantics
|
||
body = {"message_ids": [NON_EXISTENT_MESSAGE_ID], "reason": "pytest recall"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{MESSAGES_URL}/batch-recall",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /messages/{message_id} ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/{NON_EXISTENT_MESSAGE_ID}",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
async def test_get_message_status_returns_404_for_non_existent(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/{NON_EXISTENT_MESSAGE_ID}/status",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === Static paths not captured by /{message_id} ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_search_path_not_captured_by_message_id(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — /search must route to search endpoint, not /{message_id}
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/search",
|
||
params={"keyword": "pytest"},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
|
||
async def test_batch_recall_path_not_captured_by_message_id(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Act — /batch-recall must route to batch-recall endpoint
|
||
response = await test_client.post(
|
||
f"{MESSAGES_URL}/batch-recall",
|
||
json={"message_ids": [NON_EXISTENT_MESSAGE_ID]},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /{channel_type}/messages (admin send) ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_send_admin_message_requires_idempotency_key(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — missing X-Idempotency-Key header
|
||
body = {
|
||
"target": "test_target",
|
||
"content": {"text": "pytest message"},
|
||
"conversation_policy": "reuse-or-create",
|
||
}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert — FastAPI Header(...) 缺失返回 422(RequestValidationError)
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_send_admin_message_with_non_existent_account_returns_404_or_400(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — idempotency key present but target references non-existent account.
|
||
# target 必须为 ``channel_type:account_id:session_id`` 三段格式,否则
|
||
# _resolveTarget 返回 channel_type=None 直接进入 per-target failures 分支
|
||
# (返回 200),无法触发 NotFoundError(404)路径。
|
||
body = {
|
||
"target": f"{DEFAULT_CHANNEL_TYPE}:nonexistent_account:session123",
|
||
"content": {"text": "pytest message"},
|
||
"conversation_policy": "reuse-or-create",
|
||
}
|
||
headers = {**admin_headers, "X-Idempotency-Key": "test-key-send-non-existent"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages",
|
||
json=body,
|
||
headers=headers,
|
||
)
|
||
# Assert — non-existent target / account yields 404 or 400
|
||
assert response.status_code in (400, 404), response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /{channel_type}/messages/attachments ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_upload_attachment_requires_file(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — missing file upload
|
||
data = {"account_id": "test_account", "purpose": "test"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/attachments",
|
||
data=data,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /{channel_type}/messages/{message_id}/recall ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_recall_message_returns_404_for_non_existent(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/recall",
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /{channel_type}/messages/{message_id}/resend ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_resend_message_returns_404_for_non_existent(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange
|
||
body = {"target": "test_target", "reason": "pytest resend"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/resend",
|
||
json=body,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 404, response.text
|