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

689 lines
24 KiB
Python

"""Integration tests for channels outbox_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_OUTBOX_ID,
)
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
OUTBOX_URL = f"{BASE_URL}/outbox"
# =============================================================================
# === Auth three-tier for GET /outbox/messages ===
# =============================================================================
async def test_list_outbox_messages_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{OUTBOX_URL}/messages")
# Assert
assert response.status_code == 401
async def test_list_outbox_messages_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{OUTBOX_URL}/messages", headers=standard_user["headers"])
# Assert
assert response.status_code == 403
async def test_admin_can_list_outbox_messages(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{OUTBOX_URL}/messages", 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 /outbox/messages pagination validation ===
# =============================================================================
async def test_list_outbox_messages_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers):
# Act — limit=0 violates ge=1
response = await test_client.get(
f"{OUTBOX_URL}/messages",
params={"limit": 0},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_list_outbox_messages_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers):
# Act — limit=1001 violates le=1000
response = await test_client.get(
f"{OUTBOX_URL}/messages",
params={"limit": 1001},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === GET /outbox/stats ===
# =============================================================================
async def test_admin_can_get_outbox_stats(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{OUTBOX_URL}/stats", headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert isinstance(payload["data"], dict)
data = payload["data"]
assert "top_errors" in data and isinstance(data["top_errors"], list)
assert "avg_latency_ms" in data
assert "oldest_pending_at" in data
# =============================================================================
# === GET /outbox/trend ===
# =============================================================================
async def test_outbox_trend_requires_start_time(test_client: httpx.AsyncClient, admin_headers):
# Act — start_time required
response = await test_client.get(
f"{OUTBOX_URL}/trend",
params={"end_time": ISO_TIME_END},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_outbox_trend_requires_end_time(test_client: httpx.AsyncClient, admin_headers):
# Act — end_time required
response = await test_client.get(
f"{OUTBOX_URL}/trend",
params={"start_time": ISO_TIME_START},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_admin_can_get_outbox_trend(test_client: httpx.AsyncClient, admin_headers):
# Arrange
params = {
"start_time": ISO_TIME_START,
"end_time": ISO_TIME_END,
"channel_type": DEFAULT_CHANNEL_TYPE,
"granularity": "hour",
"metric": "queue_depth",
}
# Act
response = await test_client.get(f"{OUTBOX_URL}/trend", 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)
# =============================================================================
# === GET /outbox/dead-letter ===
# =============================================================================
async def test_admin_can_list_dead_letter(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{OUTBOX_URL}/dead-letter", headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert isinstance(payload["data"], dict)
# =============================================================================
# === GET /outbox/retry-policy ===
# =============================================================================
async def test_admin_can_get_retry_policy(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{OUTBOX_URL}/retry-policy", 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 /outbox/dead-letter/batch-retry ===
# =============================================================================
async def test_admin_can_batch_retry_dead_letter(test_client: httpx.AsyncClient, admin_headers):
# Act — router declares status_code=202 for async batch-retry
response = await test_client.post(
f"{OUTBOX_URL}/dead-letter/batch-retry",
headers=admin_headers,
)
# Assert
assert response.status_code == 202, response.text
payload = response.json()
assert payload["success"] is True
assert isinstance(payload["data"], dict)
# =============================================================================
# === POST /outbox/dead-letter/batch-delete ===
# =============================================================================
async def test_admin_can_batch_delete_dead_letter(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(
f"{OUTBOX_URL}/dead-letter/batch-delete",
headers=admin_headers,
)
# Assert
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert isinstance(payload["data"], dict)
# =============================================================================
# === GET /outbox/dead-letter/export ===
# =============================================================================
async def test_admin_can_export_dead_letter(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(f"{OUTBOX_URL}/dead-letter/export", headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert isinstance(payload["data"], dict)
# =============================================================================
# === PUT /outbox/retry-policy validation ===
# =============================================================================
async def test_admin_can_update_retry_policy(test_client: httpx.AsyncClient, admin_headers):
# Arrange
body = {
"max_retry": 5,
"ttl_seconds": 3600,
"retry_backoff_schedule": [10, 30, 60],
}
# Act
response = await test_client.put(
f"{OUTBOX_URL}/retry-policy",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 200, response.text
payload = response.json()
assert payload["success"] is True
assert isinstance(payload["data"], dict)
async def test_update_retry_policy_rejects_max_retry_zero(test_client: httpx.AsyncClient, admin_headers):
# Act — max_retry=0 violates ge=1
body = {"max_retry": 0, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 30, 60]}
response = await test_client.put(
f"{OUTBOX_URL}/retry-policy",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_update_retry_policy_rejects_max_retry_over_max(test_client: httpx.AsyncClient, admin_headers):
# Act — max_retry=21 violates le=20
body = {"max_retry": 21, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 30, 60]}
response = await test_client.put(
f"{OUTBOX_URL}/retry-policy",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_update_retry_policy_rejects_ttl_too_small(test_client: httpx.AsyncClient, admin_headers):
# Act — ttl_seconds=59 violates ge=60
body = {"max_retry": 5, "ttl_seconds": 59, "retry_backoff_schedule": [10, 30, 60]}
response = await test_client.put(
f"{OUTBOX_URL}/retry-policy",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_update_retry_policy_rejects_ttl_too_large(test_client: httpx.AsyncClient, admin_headers):
# Act — ttl_seconds=2592001 violates le=2592000
body = {"max_retry": 5, "ttl_seconds": 2592001, "retry_backoff_schedule": [10, 30, 60]}
response = await test_client.put(
f"{OUTBOX_URL}/retry-policy",
json=body,
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === GET /outbox/messages/{outbox_id} ===
# =============================================================================
async def test_get_outbox_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.get(
f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === DELETE /outbox/dead-letter/{outbox_id} ===
# =============================================================================
async def test_delete_dead_letter_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.delete(
f"{OUTBOX_URL}/dead-letter/{NON_EXISTENT_OUTBOX_ID}",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === POST /outbox/messages/{outbox_id}/retry ===
# =============================================================================
async def test_retry_outbox_message_returns_404_for_non_existent(test_client: httpx.AsyncClient, admin_headers):
# Act
response = await test_client.post(
f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}/retry",
headers=admin_headers,
)
# Assert
assert response.status_code == 404, response.text
# =============================================================================
# === Static paths not captured by /messages/{outbox_id} ===
# =============================================================================
async def test_stats_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers):
# Act — /stats must route to stats endpoint, not /messages/{outbox_id}
response = await test_client.get(f"{OUTBOX_URL}/stats", headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
async def test_trend_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers):
# Act — /trend must route to trend endpoint
response = await test_client.get(
f"{OUTBOX_URL}/trend",
params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END},
headers=admin_headers,
)
# Assert
assert response.status_code == 200, response.text
async def test_dead_letter_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers):
# Act — /dead-letter must route to dead-letter endpoint
response = await test_client.get(f"{OUTBOX_URL}/dead-letter", headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
async def test_retry_policy_path_not_captured_by_outbox_id(test_client: httpx.AsyncClient, admin_headers):
# Act — /retry-policy must route to retry-policy endpoint
response = await test_client.get(f"{OUTBOX_URL}/retry-policy", headers=admin_headers)
# Assert
assert response.status_code == 200, response.text
# =============================================================================
# === Auth three-tier for GET /outbox/stats ===
# =============================================================================
async def test_get_outbox_stats_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{OUTBOX_URL}/stats")
# Assert
assert response.status_code == 401
async def test_get_outbox_stats_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{OUTBOX_URL}/stats", headers=standard_user["headers"])
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for GET /outbox/trend ===
# =============================================================================
async def test_get_outbox_trend_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{OUTBOX_URL}/trend")
# Assert
assert response.status_code == 401
async def test_get_outbox_trend_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(
f"{OUTBOX_URL}/trend",
params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END},
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for GET /outbox/dead-letter ===
# =============================================================================
async def test_list_dead_letter_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{OUTBOX_URL}/dead-letter")
# Assert
assert response.status_code == 401
async def test_list_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{OUTBOX_URL}/dead-letter", headers=standard_user["headers"])
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for GET /outbox/retry-policy ===
# =============================================================================
async def test_get_retry_policy_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{OUTBOX_URL}/retry-policy")
# Assert
assert response.status_code == 401
async def test_get_retry_policy_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{OUTBOX_URL}/retry-policy", headers=standard_user["headers"])
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for POST /outbox/dead-letter/batch-retry ===
# =============================================================================
async def test_batch_retry_dead_letter_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{OUTBOX_URL}/dead-letter/batch-retry")
# Assert
assert response.status_code == 401
async def test_batch_retry_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(
f"{OUTBOX_URL}/dead-letter/batch-retry",
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for POST /outbox/dead-letter/batch-delete ===
# =============================================================================
async def test_batch_delete_dead_letter_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{OUTBOX_URL}/dead-letter/batch-delete")
# Assert
assert response.status_code == 401
async def test_batch_delete_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(
f"{OUTBOX_URL}/dead-letter/batch-delete",
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for GET /outbox/dead-letter/export ===
# =============================================================================
async def test_export_dead_letter_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{OUTBOX_URL}/dead-letter/export")
# Assert
assert response.status_code == 401
async def test_export_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(f"{OUTBOX_URL}/dead-letter/export", headers=standard_user["headers"])
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for PUT /outbox/retry-policy ===
# =============================================================================
async def test_update_retry_policy_requires_auth(test_client: httpx.AsyncClient):
# Arrange
body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 30, 60]}
# Act
response = await test_client.put(f"{OUTBOX_URL}/retry-policy", json=body)
# Assert
assert response.status_code == 401
async def test_update_retry_policy_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Arrange
body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 30, 60]}
# Act
response = await test_client.put(f"{OUTBOX_URL}/retry-policy", json=body, headers=standard_user["headers"])
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for GET /outbox/messages/{outbox_id} ===
# =============================================================================
async def test_get_outbox_message_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.get(f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}")
# Assert
assert response.status_code == 401
async def test_get_outbox_message_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.get(
f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}",
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for DELETE /outbox/dead-letter/{outbox_id} ===
# =============================================================================
async def test_delete_dead_letter_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.delete(f"{OUTBOX_URL}/dead-letter/{NON_EXISTENT_OUTBOX_ID}")
# Assert
assert response.status_code == 401
async def test_delete_dead_letter_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.delete(
f"{OUTBOX_URL}/dead-letter/{NON_EXISTENT_OUTBOX_ID}",
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403
# =============================================================================
# === Auth three-tier for POST /outbox/messages/{outbox_id}/retry ===
# =============================================================================
async def test_retry_outbox_message_requires_auth(test_client: httpx.AsyncClient):
# Act
response = await test_client.post(f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}/retry")
# Assert
assert response.status_code == 401
async def test_retry_outbox_message_requires_admin(test_client: httpx.AsyncClient, standard_user):
# Act
response = await test_client.post(
f"{OUTBOX_URL}/messages/{NON_EXISTENT_OUTBOX_ID}/retry",
headers=standard_user["headers"],
)
# Assert
assert response.status_code == 403
# =============================================================================
# === GET /outbox/trend — granularity / metric Literal validation (422) ===
# =============================================================================
async def test_outbox_trend_rejects_invalid_granularity(test_client: httpx.AsyncClient, admin_headers):
# Act — granularity="invalid" violates Literal["minute", "hour", "day"]
response = await test_client.get(
f"{OUTBOX_URL}/trend",
params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END, "granularity": "invalid"},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
async def test_outbox_trend_rejects_invalid_metric(test_client: httpx.AsyncClient, admin_headers):
# Act — metric="invalid" violates Literal["queue_depth", "retry_count", "dead_count"]
response = await test_client.get(
f"{OUTBOX_URL}/trend",
params={"start_time": ISO_TIME_START, "end_time": ISO_TIME_END, "metric": "invalid"},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === GET /outbox/dead-letter/export — format Literal validation (422) ===
# =============================================================================
async def test_export_dead_letter_rejects_invalid_format(test_client: httpx.AsyncClient, admin_headers):
# Act — format="xml" violates Literal["json", "csv"]
response = await test_client.get(
f"{OUTBOX_URL}/dead-letter/export",
params={"format": "xml"},
headers=admin_headers,
)
# Assert
assert response.status_code == 422, response.text
# =============================================================================
# === PUT /outbox/retry-policy — retry_backoff_schedule validation (422) ===
# =============================================================================
async def test_update_retry_policy_rejects_empty_backoff_schedule(test_client: httpx.AsyncClient, admin_headers):
# Act — retry_backoff_schedule=[] violates min_length=1
body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": []}
response = await test_client.put(f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
async def test_update_retry_policy_rejects_too_many_backoff_schedule(test_client: httpx.AsyncClient, admin_headers):
# Act — 21 entries violates max_length=20
body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": [10] * 21}
response = await test_client.put(f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text
async def test_update_retry_policy_rejects_non_positive_backoff_schedule(test_client: httpx.AsyncClient, admin_headers):
# Act — 0 in schedule violates field_validator (positive integers only)
body = {"max_retry": 5, "ttl_seconds": 3600, "retry_backoff_schedule": [10, 0, 60]}
response = await test_client.put(f"{OUTBOX_URL}/retry-policy", json=body, headers=admin_headers)
# Assert
assert response.status_code == 422, response.text