1. 新增channels模块集成测试目录与基础fixture 2. 为capability、doctor、reports、dashboard、directory、webhook、wizard、health、analytics、allowlist、config等路由编写完整的鉴权、参数校验与异常场景测试 3. 修复基础集成测试setup,新增external/scheduler/channel数据库schema初始化步骤
346 lines
11 KiB
Python
346 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
|
|
assert response.status_code == 400, 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
|
|
body = {
|
|
"target": "nonexistent_target",
|
|
"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
|