新增并完善多模块接口测试用例,覆盖: 1. 权限校验:认证缺失、普通用户越权访问场景 2. 参数合法性校验:空值、格式错误、边界值、枚举约束 3. 响应字段增强:补充业务字段断言与分页回传校验 4. 异常场景:不存在资源、超限参数、无效格式等400/404/422场景
599 lines
20 KiB
Python
599 lines
20 KiB
Python
"""Integration tests for channels message_router endpoints."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
from uuid import uuid4
|
||
|
||
from .conftest import (
|
||
BASE_URL,
|
||
DEFAULT_CHANNEL_TYPE,
|
||
ISO_TIME_END,
|
||
ISO_TIME_START,
|
||
NON_EXISTENT_ACCOUNT_ID,
|
||
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=1001 violates le=1000
|
||
response = await test_client.get(
|
||
MESSAGES_URL,
|
||
params={"limit": 1001},
|
||
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": uuid4().hex}
|
||
# 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_auth(test_client: httpx.AsyncClient):
|
||
# Arrange — missing Authorization header
|
||
files = {"file": ("test.txt", b"test content", "text/plain")}
|
||
data = {"account_id": NON_EXISTENT_ACCOUNT_ID}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/attachments",
|
||
data=data,
|
||
files=files,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_upload_attachment_forbids_standard_user(test_client: httpx.AsyncClient, standard_user):
|
||
# Arrange — standard user rejected by get_admin_user
|
||
files = {"file": ("test.txt", b"test content", "text/plain")}
|
||
data = {"account_id": NON_EXISTENT_ACCOUNT_ID}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/attachments",
|
||
data=data,
|
||
files=files,
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
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
|
||
|
||
|
||
async def test_upload_attachment_with_non_existent_account_returns_404_or_501(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — 合法文件但 account_id 不存在,适配器未注册 → 404/501
|
||
files = {"file": ("test.txt", b"test content", "text/plain")}
|
||
data = {"account_id": NON_EXISTENT_ACCOUNT_ID, "purpose": "test"}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/attachments",
|
||
data=data,
|
||
files=files,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code in (404, 501), response.text
|
||
|
||
|
||
async def test_upload_attachment_rejects_oversized_file(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024,超出限制触发 ValidationError → 400
|
||
oversized_content = b"x" * (10 * 1024 * 1024 + 1)
|
||
files = {"file": ("oversized.txt", oversized_content, "text/plain")}
|
||
data = {"account_id": NON_EXISTENT_ACCOUNT_ID}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/attachments",
|
||
data=data,
|
||
files=files,
|
||
headers=admin_headers,
|
||
)
|
||
# Assert — 超限在 router 层抛 ValidationError → 400
|
||
assert response.status_code == 400, 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
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for GET /messages/search ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_search_messages_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{MESSAGES_URL}/search")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_search_messages_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/search",
|
||
params={"keyword": "foo"},
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for POST /messages/batch-recall ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_batch_recall_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.post(f"{MESSAGES_URL}/batch-recall", json={"message_ids": ["m1"]})
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_batch_recall_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{MESSAGES_URL}/batch-recall",
|
||
json={"message_ids": ["m1"]},
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for GET /messages/{message_id} ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_get_message_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{MESSAGES_URL}/{NON_EXISTENT_MESSAGE_ID}")
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_get_message_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/{NON_EXISTENT_MESSAGE_ID}",
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for POST /{channel_type}/messages/{message_id}/recall ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_recall_message_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/recall",
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_recall_message_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/recall",
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for POST /{channel_type}/messages/{message_id}/resend ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_resend_message_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/resend",
|
||
json={"target": "test_target"},
|
||
)
|
||
# Assert
|
||
assert response.status_code == 401
|
||
|
||
|
||
async def test_resend_message_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages/{NON_EXISTENT_MESSAGE_ID}/resend",
|
||
json={"target": "test_target"},
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403
|
||
|
||
|
||
# =============================================================================
|
||
# === GET /messages/search — limit validation (422) ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_search_messages_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=0 violates ge=1
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/search",
|
||
params={"keyword": "pytest", "limit": 0},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_search_messages_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=101 violates le=100
|
||
response = await test_client.get(
|
||
f"{MESSAGES_URL}/search",
|
||
params={"keyword": "pytest", "limit": 101},
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === POST /{channel_type}/messages — body validation (422) ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_send_admin_message_rejects_empty_target(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — target="" violates min_length=1
|
||
body = {"target": "", "content": {"text": "msg"}}
|
||
headers = {**admin_headers, "X-Idempotency-Key": uuid4().hex}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages",
|
||
json=body,
|
||
headers=headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_send_admin_message_rejects_invalid_conversation_policy(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — conversation_policy="invalid" violates Literal["reuse", "reuse-or-create", "new"]
|
||
body = {"target": "t", "content": {"text": "msg"}, "conversation_policy": "invalid"}
|
||
headers = {**admin_headers, "X-Idempotency-Key": uuid4().hex}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages",
|
||
json=body,
|
||
headers=headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_send_admin_message_rejects_invalid_target_type(
|
||
test_client: httpx.AsyncClient, admin_headers
|
||
):
|
||
# Arrange — target_type="invalid" violates Literal["session_id", "peer_id"]
|
||
body = {"target": "t", "content": {"text": "msg"}, "target_type": "invalid"}
|
||
headers = {**admin_headers, "X-Idempotency-Key": uuid4().hex}
|
||
# Act
|
||
response = await test_client.post(
|
||
f"{BASE_URL}/{DEFAULT_CHANNEL_TYPE}/messages",
|
||
json=body,
|
||
headers=headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|