2026-07-02 03:25:27 +08:00
|
|
|
|
"""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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_analyze_messages_requires_admin(test_client: httpx.AsyncClient, standard_user):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_analyze_messages(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_analyze_messages_rejects_missing_start_time(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# Arrange — omit start_time
|
|
|
|
|
|
params = {"end_time": ISO_TIME_END}
|
|
|
|
|
|
# Act
|
2026-07-04 00:18:04 +08:00
|
|
|
|
response = await test_client.get(f"{ANALYTICS_URL}/messages", params=params, headers=admin_headers)
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# Assert
|
|
|
|
|
|
assert response.status_code == 422, response.text
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_analyze_messages_rejects_missing_end_time(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# Arrange — omit end_time
|
|
|
|
|
|
params = {"start_time": ISO_TIME_START}
|
|
|
|
|
|
# Act
|
2026-07-04 00:18:04 +08:00
|
|
|
|
response = await test_client.get(f"{ANALYTICS_URL}/messages", params=params, headers=admin_headers)
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# Assert
|
|
|
|
|
|
assert response.status_code == 422, response.text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
# === All analytics endpoints with valid params return 200 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_get_message_distribution(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_analyze_sessions(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_analyze_delivery(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_get_delivery_latency(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_get_delivery_funnel(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_analyze_accounts(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
2026-07-04 00:18:04 +08:00
|
|
|
|
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"}
|
2026-07-02 03:25:27 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_admin_can_analyze_peers(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
2026-07-04 00:18:04 +08:00
|
|
|
|
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):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
2026-07-04 00:18:04 +08:00
|
|
|
|
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):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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 ===
|
|
|
|
|
|
# =============================================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_analyze_peers_rejects_limit_zero(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
async def test_analyze_peers_rejects_limit_over_max(test_client: httpx.AsyncClient, admin_headers):
|
2026-07-02 03:25:27 +08:00
|
|
|
|
# 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
|