1. 清理测试文件中未使用的导入与冗余代码 2. 修复审计日志与批量操作的空值约束,统一填充"global"作为默认渠道 3. 调整批量消息撤回的响应语义,对齐其他端点的部分成功契约 4. 修复访问规则批量克隆的唯一约束问题,新增后缀自动处理逻辑 5. 替换anyio为asyncio并行调用,修正时间UTC导入路径 6. 优化前端外部系统概览页的刷新状态提示与缓存逻辑 7. 修复测试用例中的断言与请求方式问题,适配httpx删除请求特性 8. 重构后端路由的依赖注入,移除冗余的数据库会话依赖 9. 调整测试用例的权限校验逻辑,修正强制登出的权限判断 10. 修复语义分块测试的numpy依赖问题,清理冗余导入
560 lines
17 KiB
Python
560 lines
17 KiB
Python
"""analytics.py DTO 单元测试。
|
|
|
|
覆盖 ``TimeSeriesPoint`` / ``MessageAnalytics`` / ``MessageDistribution`` /
|
|
``SessionAnalytics`` / ``DeliveryAnalytics`` / ``DeliveryLatencyDistribution`` /
|
|
``DeliveryFunnel`` / ``AccountAnalyticsQuery`` / ``AccountActivityStat`` /
|
|
``AccountAnalyticsResult`` / ``PeerAnalyticsQuery`` / ``PeerActivityStat`` /
|
|
``PeerAnalyticsResult`` / ``ContentReviewAnalyticsQuery`` /
|
|
``ContentReviewAnalyticsResult`` 的字段赋值、默认值、不可变语义、
|
|
``__post_init__`` 校验逻辑与 ``to_dict`` 序列化方法。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
from datetime import datetime, timedelta, UTC
|
|
|
|
import pytest
|
|
from yuxi.channels.contract.dtos.analytics import (
|
|
MAX_LIMIT,
|
|
MAX_TIME_RANGE_DAYS,
|
|
AccountActivityStat,
|
|
AccountAnalyticsQuery,
|
|
AccountAnalyticsResult,
|
|
ContentReviewAnalyticsQuery,
|
|
ContentReviewAnalyticsResult,
|
|
DeliveryAnalytics,
|
|
DeliveryFunnel,
|
|
DeliveryLatencyDistribution,
|
|
MessageAnalytics,
|
|
MessageDistribution,
|
|
PeerActivityStat,
|
|
PeerAnalyticsQuery,
|
|
PeerAnalyticsResult,
|
|
SessionAnalytics,
|
|
TimeSeriesPoint,
|
|
)
|
|
from yuxi.channels.contract.dtos.common import CategoryStat, TrendDataPoint
|
|
from yuxi.channels.contract.errors import ValidationError
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
def _aware(days_ago: int) -> datetime:
|
|
return datetime.now(UTC) - timedelta(days=days_ago)
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestTimeSeriesPoint:
|
|
"""时间序列数据点 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Arrange
|
|
# Act
|
|
point = TimeSeriesPoint(timestamp="2024-01-01T00:00:00Z", value=42)
|
|
# Assert
|
|
assert point.timestamp == "2024-01-01T00:00:00Z"
|
|
assert point.value == 42
|
|
|
|
def test_to_dict(self):
|
|
# Arrange
|
|
point = TimeSeriesPoint(timestamp="2024-01-01", value=10)
|
|
# Act
|
|
result = point.to_dict()
|
|
# Assert
|
|
assert result == {"timestamp": "2024-01-01", "value": 10}
|
|
|
|
def test_is_frozen(self):
|
|
# Arrange
|
|
point = TimeSeriesPoint(timestamp="t", value=1)
|
|
# Act / Assert
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
point.value = 2 # type: ignore[misc]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessageAnalytics:
|
|
"""消息深度分析结果 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Arrange
|
|
ts = (TimeSeriesPoint(timestamp="t1", value=5),)
|
|
# Act
|
|
analytics = MessageAnalytics(
|
|
total=100,
|
|
timeseries=ts,
|
|
by_channel={"wechat": 60},
|
|
by_role={"user": 40, "assistant": 60},
|
|
by_delivery_status={"sent": 90},
|
|
)
|
|
# Assert
|
|
assert analytics.total == 100
|
|
assert analytics.timeseries == ts
|
|
assert analytics.by_channel == {"wechat": 60}
|
|
|
|
def test_to_dict_serializes_timeseries(self):
|
|
# Arrange
|
|
ts = (TimeSeriesPoint(timestamp="t1", value=5), TimeSeriesPoint(timestamp="t2", value=10))
|
|
analytics = MessageAnalytics(
|
|
total=15,
|
|
timeseries=ts,
|
|
by_channel={},
|
|
by_role={},
|
|
by_delivery_status={},
|
|
)
|
|
# Act
|
|
result = analytics.to_dict()
|
|
# Assert
|
|
assert result["total"] == 15
|
|
assert result["timeseries"] == [
|
|
{"timestamp": "t1", "value": 5},
|
|
{"timestamp": "t2", "value": 10},
|
|
]
|
|
|
|
def test_is_frozen(self):
|
|
# Arrange
|
|
analytics = MessageAnalytics(
|
|
total=0, timeseries=(), by_channel={}, by_role={}, by_delivery_status={}
|
|
)
|
|
# Act / Assert
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
analytics.total = 1 # type: ignore[misc]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestMessageDistribution:
|
|
"""消息类型分布结果 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Act
|
|
dist = MessageDistribution(
|
|
total=100,
|
|
by_type={"text": 80, "card": 20},
|
|
by_type_percent={"text": 80.0, "card": 20.0},
|
|
)
|
|
# Assert
|
|
assert dist.total == 100
|
|
assert dist.by_type["text"] == 80
|
|
|
|
def test_to_dict(self):
|
|
# Arrange
|
|
dist = MessageDistribution(total=50, by_type={"text": 50}, by_type_percent={"text": 100.0})
|
|
# Act
|
|
result = dist.to_dict()
|
|
# Assert
|
|
assert result == {"total": 50, "by_type": {"text": 50}, "by_type_percent": {"text": 100.0}}
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestSessionAnalytics:
|
|
"""会话分析结果 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Arrange
|
|
ts = (TimeSeriesPoint(timestamp="t1", value=3),)
|
|
# Act
|
|
analytics = SessionAnalytics(
|
|
total=10,
|
|
timeseries=ts,
|
|
by_channel={"wechat": 10},
|
|
by_is_temporary={"true": 2, "false": 8},
|
|
by_message_count_bucket={"0-10": 5, "11-50": 5},
|
|
)
|
|
# Assert
|
|
assert analytics.total == 10
|
|
assert analytics.by_is_temporary["true"] == 2
|
|
|
|
def test_to_dict_serializes_timeseries(self):
|
|
# Arrange
|
|
analytics = SessionAnalytics(
|
|
total=0,
|
|
timeseries=(TimeSeriesPoint(timestamp="t", value=1),),
|
|
by_channel={},
|
|
by_is_temporary={},
|
|
by_message_count_bucket={},
|
|
)
|
|
# Act
|
|
result = analytics.to_dict()
|
|
# Assert
|
|
assert result["timeseries"] == [{"timestamp": "t", "value": 1}]
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestDeliveryAnalytics:
|
|
"""投递链路分析结果 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Act
|
|
analytics = DeliveryAnalytics(
|
|
total=200,
|
|
success_rate=95.0,
|
|
failure_rate=5.0,
|
|
retry_distribution={"0": 180, "1": 15},
|
|
avg_latency_ms=120.5,
|
|
)
|
|
# Assert
|
|
assert analytics.total == 200
|
|
assert analytics.avg_latency_ms == 120.5
|
|
|
|
def test_avg_latency_ms_can_be_none(self):
|
|
# Act
|
|
analytics = DeliveryAnalytics(
|
|
total=0, success_rate=0.0, failure_rate=0.0, retry_distribution={}, avg_latency_ms=None
|
|
)
|
|
# Assert
|
|
assert analytics.avg_latency_ms is None
|
|
|
|
def test_to_dict_preserves_none(self):
|
|
# Arrange
|
|
analytics = DeliveryAnalytics(
|
|
total=0, success_rate=0.0, failure_rate=0.0, retry_distribution={}, avg_latency_ms=None
|
|
)
|
|
# Act
|
|
result = analytics.to_dict()
|
|
# Assert
|
|
assert result["avg_latency_ms"] is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestDeliveryLatencyDistribution:
|
|
"""投递延迟分布结果 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Act
|
|
dist = DeliveryLatencyDistribution(
|
|
p50_ms=50.0,
|
|
p90_ms=200.0,
|
|
p99_ms=500.0,
|
|
max_ms=1000.0,
|
|
histogram={"0-100ms": 80, "100-500ms": 20},
|
|
)
|
|
# Assert
|
|
assert dist.p50_ms == 50.0
|
|
assert dist.histogram["0-100ms"] == 80
|
|
|
|
def test_percentiles_can_be_none(self):
|
|
# Act
|
|
dist = DeliveryLatencyDistribution(
|
|
p50_ms=None, p90_ms=None, p99_ms=None, max_ms=None, histogram={}
|
|
)
|
|
# Assert
|
|
assert dist.p50_ms is None
|
|
|
|
def test_to_dict_preserves_none(self):
|
|
# Arrange
|
|
dist = DeliveryLatencyDistribution(
|
|
p50_ms=None, p90_ms=100.0, p99_ms=None, max_ms=None, histogram={}
|
|
)
|
|
# Act
|
|
result = dist.to_dict()
|
|
# Assert
|
|
assert result["p50_ms"] is None
|
|
assert result["p90_ms"] == 100.0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestDeliveryFunnel:
|
|
"""投递漏斗分析结果 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Act
|
|
funnel = DeliveryFunnel(
|
|
enter=100,
|
|
sent=90,
|
|
suppressed=5,
|
|
failed=4,
|
|
dead=1,
|
|
conversion_rate={"sent": 90.0, "suppressed": 5.0, "failed": 4.0, "dead": 1.0},
|
|
)
|
|
# Assert
|
|
assert funnel.enter == 100
|
|
assert funnel.conversion_rate["sent"] == 90.0
|
|
|
|
def test_conversion_rate_values_can_be_none(self):
|
|
# Act
|
|
funnel = DeliveryFunnel(
|
|
enter=0,
|
|
sent=0,
|
|
suppressed=0,
|
|
failed=0,
|
|
dead=0,
|
|
conversion_rate={"sent": None},
|
|
)
|
|
# Assert
|
|
assert funnel.conversion_rate["sent"] is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAccountAnalyticsQuery:
|
|
"""账户活跃度分析查询 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned_with_defaults(self):
|
|
# Arrange
|
|
start = _aware(7)
|
|
end = _aware(0)
|
|
# Act
|
|
query = AccountAnalyticsQuery(start_time=start, end_time=end)
|
|
# Assert
|
|
assert query.granularity == "day"
|
|
assert query.channel_type is None
|
|
assert query.limit == 100
|
|
|
|
def test_is_frozen(self):
|
|
# Arrange
|
|
query = AccountAnalyticsQuery(start_time=_aware(7), end_time=_aware(0))
|
|
# Act / Assert
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
query.limit = 200 # type: ignore[misc]
|
|
|
|
def test_start_must_be_before_end(self):
|
|
# Arrange
|
|
start = _aware(0)
|
|
end = _aware(7)
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
AccountAnalyticsQuery(start_time=start, end_time=end)
|
|
assert exc_info.value.field == "time_range"
|
|
|
|
def test_time_range_must_not_exceed_max(self):
|
|
# Arrange
|
|
start = _aware(MAX_TIME_RANGE_DAYS + 1)
|
|
end = _aware(0)
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
AccountAnalyticsQuery(start_time=start, end_time=end)
|
|
assert exc_info.value.field == "time_range"
|
|
|
|
def test_invalid_granularity_raises(self):
|
|
# Arrange
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
AccountAnalyticsQuery(
|
|
start_time=_aware(7),
|
|
end_time=_aware(0),
|
|
granularity="minute", # type: ignore[arg-type]
|
|
)
|
|
assert exc_info.value.field == "granularity"
|
|
|
|
def test_limit_below_min_raises(self):
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
AccountAnalyticsQuery(start_time=_aware(7), end_time=_aware(0), limit=0)
|
|
assert exc_info.value.field == "limit"
|
|
|
|
def test_limit_above_max_raises(self):
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
AccountAnalyticsQuery(start_time=_aware(7), end_time=_aware(0), limit=MAX_LIMIT + 1)
|
|
assert exc_info.value.field == "limit"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAccountActivityStat:
|
|
"""账户活跃统计 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned(self):
|
|
# Act
|
|
stat = AccountActivityStat(
|
|
account_id="acc-1",
|
|
channel_type="wechat",
|
|
message_count=100,
|
|
session_count=20,
|
|
active_days=5,
|
|
avg_daily_messages=20.0,
|
|
)
|
|
# Assert
|
|
assert stat.account_id == "acc-1"
|
|
assert stat.avg_daily_messages == 20.0
|
|
|
|
def test_to_dict(self):
|
|
# Arrange
|
|
stat = AccountActivityStat(
|
|
account_id="a1",
|
|
channel_type="wechat",
|
|
message_count=10,
|
|
session_count=2,
|
|
active_days=1,
|
|
avg_daily_messages=10.0,
|
|
)
|
|
# Act
|
|
result = stat.to_dict()
|
|
# Assert
|
|
assert result["account_id"] == "a1"
|
|
assert result["avg_daily_messages"] == 10.0
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestAccountAnalyticsResult:
|
|
"""账户活跃度分析结果 DTO 测试。"""
|
|
|
|
def test_to_dict_maps_trend_value_to_active_accounts(self):
|
|
# Arrange
|
|
stat = AccountActivityStat(
|
|
account_id="a1",
|
|
channel_type="wechat",
|
|
message_count=10,
|
|
session_count=2,
|
|
active_days=1,
|
|
avg_daily_messages=10.0,
|
|
)
|
|
trend = (TrendDataPoint(timestamp=datetime(2024, 1, 1, tzinfo=UTC), value=5),)
|
|
result_dto = AccountAnalyticsResult(by_account=(stat,), trend=trend)
|
|
# Act
|
|
result = result_dto.to_dict()
|
|
# Assert
|
|
assert result["trend"][0]["active_accounts"] == 5
|
|
assert "timestamp" in result["trend"][0]
|
|
assert result["by_account"][0]["account_id"] == "a1"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPeerAnalyticsQuery:
|
|
"""对端活跃度分析查询 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned_with_defaults(self):
|
|
# Act
|
|
query = PeerAnalyticsQuery(start_time=_aware(7), end_time=_aware(0))
|
|
# Assert
|
|
assert query.limit == 100
|
|
assert query.channel_type is None
|
|
|
|
def test_start_must_be_before_end(self):
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
PeerAnalyticsQuery(start_time=_aware(0), end_time=_aware(7))
|
|
assert exc_info.value.field == "time_range"
|
|
|
|
def test_limit_below_min_raises(self):
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
PeerAnalyticsQuery(start_time=_aware(7), end_time=_aware(0), limit=0)
|
|
assert exc_info.value.field == "limit"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPeerActivityStat:
|
|
"""对端活跃统计 DTO 测试。"""
|
|
|
|
def test_to_dict_formats_datetimes(self):
|
|
# Arrange
|
|
ts = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
|
stat = PeerActivityStat(
|
|
peer_id="p1",
|
|
channel_type="wechat",
|
|
message_count=10,
|
|
session_count=2,
|
|
first_seen=ts,
|
|
last_seen=ts,
|
|
)
|
|
# Act
|
|
result = stat.to_dict()
|
|
# Assert
|
|
assert result["first_seen"] is not None
|
|
assert result["last_seen"] is not None
|
|
|
|
def test_to_dict_preserves_none_datetimes(self):
|
|
# Arrange
|
|
stat = PeerActivityStat(
|
|
peer_id="p1",
|
|
channel_type="wechat",
|
|
message_count=0,
|
|
session_count=0,
|
|
first_seen=None,
|
|
last_seen=None,
|
|
)
|
|
# Act
|
|
result = stat.to_dict()
|
|
# Assert
|
|
assert result["first_seen"] is None
|
|
assert result["last_seen"] is None
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestPeerAnalyticsResult:
|
|
"""对端活跃度分析结果 DTO 测试。"""
|
|
|
|
def test_to_dict(self):
|
|
# Arrange
|
|
stat = PeerActivityStat(
|
|
peer_id="p1",
|
|
channel_type="wechat",
|
|
message_count=10,
|
|
session_count=2,
|
|
first_seen=None,
|
|
last_seen=None,
|
|
)
|
|
result_dto = PeerAnalyticsResult(top_active=(stat,))
|
|
# Act
|
|
result = result_dto.to_dict()
|
|
# Assert
|
|
assert result["top_active"][0]["peer_id"] == "p1"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestContentReviewAnalyticsQuery:
|
|
"""内容审核分析查询 DTO 测试。"""
|
|
|
|
def test_fields_are_assigned_with_defaults(self):
|
|
# Act
|
|
query = ContentReviewAnalyticsQuery(start_time=_aware(7), end_time=_aware(0))
|
|
# Assert
|
|
assert query.granularity == "day"
|
|
|
|
def test_invalid_granularity_raises(self):
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ContentReviewAnalyticsQuery(
|
|
start_time=_aware(7),
|
|
end_time=_aware(0),
|
|
granularity="minute", # type: ignore[arg-type]
|
|
)
|
|
assert exc_info.value.field == "granularity"
|
|
|
|
def test_start_must_be_before_end(self):
|
|
# Act / Assert
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
ContentReviewAnalyticsQuery(start_time=_aware(0), end_time=_aware(7))
|
|
assert exc_info.value.field == "time_range"
|
|
|
|
|
|
@pytest.mark.unit
|
|
class TestContentReviewAnalyticsResult:
|
|
"""内容审核分析结果 DTO 测试。"""
|
|
|
|
def test_to_dict_computes_rate(self):
|
|
# Arrange
|
|
by_category = (CategoryStat(category="politics", count=10),)
|
|
trend = (
|
|
{
|
|
"timestamp": datetime(2024, 1, 1, tzinfo=UTC),
|
|
"reviewed": 100,
|
|
"blocked": 10,
|
|
},
|
|
)
|
|
result_dto = ContentReviewAnalyticsResult(
|
|
total_reviews=100,
|
|
block_count=10,
|
|
block_rate=10.0,
|
|
by_category=by_category,
|
|
trend=trend,
|
|
)
|
|
# Act
|
|
result = result_dto.to_dict()
|
|
# Assert
|
|
assert result["by_category"][0]["rate"] == 10.0
|
|
assert result["trend"][0]["reviewed"] == 100
|
|
|
|
def test_to_dict_rate_zero_when_no_reviews(self):
|
|
# Arrange
|
|
result_dto = ContentReviewAnalyticsResult(
|
|
total_reviews=0,
|
|
block_count=0,
|
|
block_rate=0.0,
|
|
by_category=(CategoryStat(category="spam", count=0),),
|
|
trend=(),
|
|
)
|
|
# Act
|
|
result = result_dto.to_dict()
|
|
# Assert
|
|
assert result["by_category"][0]["rate"] == 0.0
|