1. 删除了 test/unit/external_systems/framework/ 下的废弃空测试目录 2. 修复多处测试断言逻辑、参数传递与测试数据构造 3. 新增日志级别、缓存令牌、流事件等DTO单元测试 4. 补充路由绑定、会话仓储、outbox仓储的测试覆盖 5. 更新测试用例中的异常类型、参数校验与业务逻辑断言
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, 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
|