ForcePilot/backend/test/unit/channels/contract/dtos/test_analytics.py
Kris 3ae9c0bd21 test: 批量修复与新增单元测试用例,清理废弃测试目录
1.  删除了 test/unit/external_systems/framework/ 下的废弃空测试目录
2.  修复多处测试断言逻辑、参数传递与测试数据构造
3.  新增日志级别、缓存令牌、流事件等DTO单元测试
4.  补充路由绑定、会话仓储、outbox仓储的测试覆盖
5.  更新测试用例中的异常类型、参数校验与业务逻辑断言
2026-07-11 21:43:16 +08:00

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, timezone
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(timezone.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=timezone.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=timezone.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=timezone.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