ForcePilot/backend/test/integration/api/channels/test_message_router.py
Kris f9f08221fc chore: 整理代码风格与优化细节
本次提交包含多类代码优化:
1. 修复多处单行代码换行格式,统一代码排版
2. 为外部系统模块新增快捷菜单配置
3. 优化前端API请求参数命名一致性
4. 补充媒体下载超限错误码与领域异常类
5. 完善仓储层更新逻辑,支持显式清空字段
6. 优化部分测试用例与工具函数代码结构
7. 为微信插件白名单缓存增加过期时间
2026-07-14 15:13:29 +08:00

595 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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(...) 缺失返回 422RequestValidationError
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无法触发 NotFoundError404路径。
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