新增并完善多模块接口测试用例,覆盖: 1. 权限校验:认证缺失、普通用户越权访问场景 2. 参数合法性校验:空值、格式错误、边界值、枚举约束 3. 响应字段增强:补充业务字段断言与分页回传校验 4. 异常场景:不存在资源、超限参数、无效格式等400/404/422场景
290 lines
9.6 KiB
Python
290 lines
9.6 KiB
Python
"""Integration tests for channels analytics_router endpoints."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import httpx
|
||
import pytest
|
||
|
||
from .conftest import BASE_URL, DEFAULT_CHANNEL_TYPE, ISO_TIME_END, ISO_TIME_START
|
||
|
||
pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
|
||
|
||
ANALYTICS_URL = f"{BASE_URL}/analytics"
|
||
|
||
|
||
def _valid_params(**overrides) -> dict:
|
||
"""Build valid query params (start_time + end_time) for analytics endpoints."""
|
||
params = {"start_time": ISO_TIME_START, "end_time": ISO_TIME_END}
|
||
params.update(overrides)
|
||
return params
|
||
|
||
|
||
# =============================================================================
|
||
# === Auth three-tier for /analytics/messages ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_analyze_messages_requires_auth(test_client: httpx.AsyncClient):
|
||
# Act
|
||
response = await test_client.get(f"{ANALYTICS_URL}/messages", params=_valid_params())
|
||
# Assert
|
||
assert response.status_code == 401, response.text
|
||
|
||
|
||
async def test_analyze_messages_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/messages",
|
||
params=_valid_params(),
|
||
headers=standard_user["headers"],
|
||
)
|
||
# Assert
|
||
assert response.status_code == 403, response.text
|
||
|
||
|
||
async def test_admin_can_analyze_messages(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/messages",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert "data" in payload
|
||
|
||
|
||
# =============================================================================
|
||
# === Parameter validation for /analytics/messages ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_analyze_messages_rejects_missing_start_time(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — omit start_time
|
||
params = {"end_time": ISO_TIME_END}
|
||
# Act
|
||
response = await test_client.get(f"{ANALYTICS_URL}/messages", params=params, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_analyze_messages_rejects_missing_end_time(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — omit end_time
|
||
params = {"start_time": ISO_TIME_START}
|
||
# Act
|
||
response = await test_client.get(f"{ANALYTICS_URL}/messages", params=params, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_analyze_messages_rejects_invalid_time_format(test_client: httpx.AsyncClient, admin_headers):
|
||
# Arrange — "not-a-date" is not ISO 8601, parse_datetime raises ValidationError (400)
|
||
params = _valid_params(start_time="not-a-date")
|
||
# Act
|
||
response = await test_client.get(f"{ANALYTICS_URL}/messages", params=params, headers=admin_headers)
|
||
# Assert
|
||
assert response.status_code == 400, response.text
|
||
|
||
|
||
# =============================================================================
|
||
# === All analytics endpoints with valid params return 200 ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_admin_can_get_message_distribution(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/messages/distribution",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert "data" in payload
|
||
|
||
|
||
async def test_admin_can_analyze_sessions(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/sessions",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert "data" in payload
|
||
|
||
|
||
async def test_admin_can_analyze_delivery(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/delivery",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert "data" in payload
|
||
|
||
|
||
async def test_admin_can_get_delivery_latency(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/delivery/latency",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert "data" in payload
|
||
|
||
|
||
async def test_admin_can_get_delivery_funnel(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/delivery/funnel",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert "data" in payload
|
||
|
||
|
||
async def test_admin_can_analyze_accounts(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/accounts",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
data = payload["data"]
|
||
# ANL-ACCOUNTS 序列化必须经 DTO.to_dict(),trend 项使用 active_accounts
|
||
# 字段名(非 TrendDataPoint.value),捕获序列化路径回归。
|
||
assert set(data.keys()) == {"by_account", "trend"}
|
||
assert isinstance(data["by_account"], list)
|
||
assert isinstance(data["trend"], list)
|
||
for point in data["trend"]:
|
||
assert set(point.keys()) == {"timestamp", "active_accounts"}
|
||
|
||
|
||
async def test_admin_can_analyze_peers(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/peers",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
data = payload["data"]
|
||
# ANL-PEERS 序列化必须经 DTO.to_dict(),top_active 项的 first_seen /
|
||
# last_seen 使用 format_utc_datetime(带 Z 后缀),捕获序列化路径回归。
|
||
assert set(data.keys()) == {"top_active"}
|
||
assert isinstance(data["top_active"], list)
|
||
for stat in data["top_active"]:
|
||
assert set(stat.keys()) == {
|
||
"peer_id",
|
||
"channel_type",
|
||
"message_count",
|
||
"session_count",
|
||
"first_seen",
|
||
"last_seen",
|
||
}
|
||
|
||
|
||
async def test_admin_can_analyze_content_review(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/content-review",
|
||
params=_valid_params(),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
data = payload["data"]
|
||
# ANL-CR 序列化必须经 DTO.to_dict(),by_category 项含派生字段 rate
|
||
# (dataclasses.asdict 不会生成),捕获序列化路径回归。
|
||
assert set(data.keys()) == {
|
||
"total_reviews",
|
||
"block_count",
|
||
"block_rate",
|
||
"by_category",
|
||
"trend",
|
||
}
|
||
assert isinstance(data["by_category"], list)
|
||
for item in data["by_category"]:
|
||
assert set(item.keys()) == {"category", "count", "rate"}
|
||
|
||
|
||
async def test_admin_can_analyze_messages_with_channel_type(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/messages",
|
||
params=_valid_params(channel_type=DEFAULT_CHANNEL_TYPE),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 200, response.text
|
||
payload = response.json()
|
||
assert payload["success"] is True
|
||
assert "data" in payload
|
||
|
||
|
||
# =============================================================================
|
||
# === /analytics/peers limit validation ===
|
||
# =============================================================================
|
||
|
||
|
||
async def test_analyze_peers_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=0 violates ge=1
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/peers",
|
||
params=_valid_params(limit=0),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_analyze_peers_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — limit=501 violates le=500
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/peers",
|
||
params=_valid_params(limit=501),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|
||
|
||
|
||
async def test_analyze_peers_rejects_empty_account_id(test_client: httpx.AsyncClient, admin_headers):
|
||
# Act — account_id="" violates min_length=1
|
||
response = await test_client.get(
|
||
f"{ANALYTICS_URL}/peers",
|
||
params=_valid_params(account_id=""),
|
||
headers=admin_headers,
|
||
)
|
||
# Assert
|
||
assert response.status_code == 422, response.text
|