503 lines
16 KiB
Python
503 lines
16 KiB
Python
|
|
"""领域模型(dataclass) ↔ Output DTO(Pydantic) 转换单元测试。
|
|||
|
|
|
|||
|
|
覆盖 ``yuxi.scheduler.use_cases.mappers`` 模块的全部公开函数:
|
|||
|
|
- ``to_task_output`` / ``to_run_log_output``:dataclass → Output DTO
|
|||
|
|
- ``to_handler_summary_output`` / ``to_list_handler_summary_output``
|
|||
|
|
- ``to_count_by_status_output``:状态分布字典 → DTO
|
|||
|
|
- ``to_get_health_output``:健康检查参数 → DTO
|
|||
|
|
- ``daily_stat_to_dict`` / ``to_daily_stat_output`` / ``to_list_daily_stats_output``
|
|||
|
|
- ``to_list_upcoming_output``
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
from datetime import datetime
|
|||
|
|
|
|||
|
|
import pytest
|
|||
|
|
|
|||
|
|
from yuxi.scheduler.core.models import (
|
|||
|
|
DailyStat,
|
|||
|
|
HandlerSummary,
|
|||
|
|
ScheduledTask,
|
|||
|
|
ScheduledTaskRunLog,
|
|||
|
|
)
|
|||
|
|
from yuxi.scheduler.use_cases.mappers import (
|
|||
|
|
daily_stat_to_dict,
|
|||
|
|
to_count_by_status_output,
|
|||
|
|
to_daily_stat_output,
|
|||
|
|
to_get_health_output,
|
|||
|
|
to_handler_summary_output,
|
|||
|
|
to_list_daily_stats_output,
|
|||
|
|
to_list_handler_summary_output,
|
|||
|
|
to_list_upcoming_output,
|
|||
|
|
to_run_log_output,
|
|||
|
|
to_task_output,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
pytestmark = pytest.mark.unit
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_task_output ───────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToTaskOutput:
|
|||
|
|
def test_basic_mapping(self):
|
|||
|
|
# Arrange
|
|||
|
|
task = ScheduledTask(
|
|||
|
|
task_id="task-001",
|
|||
|
|
handler_name="my_handler",
|
|||
|
|
owner_scope="system",
|
|||
|
|
owner_id="test:1",
|
|||
|
|
schedule_kind="cron",
|
|||
|
|
cron_expression="*/15 * * * *",
|
|||
|
|
tz="Asia/Shanghai",
|
|||
|
|
payload={"k": "v"},
|
|||
|
|
enabled=True,
|
|||
|
|
stagger_seconds=30,
|
|||
|
|
status="active",
|
|||
|
|
next_run_at=datetime(2024, 1, 1, 0, 15, 0),
|
|||
|
|
id=1,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_task_output(task)
|
|||
|
|
# Assert
|
|||
|
|
assert output.task_id == "task-001"
|
|||
|
|
assert output.handler_name == "my_handler"
|
|||
|
|
assert output.owner_scope == "system"
|
|||
|
|
assert output.schedule_kind == "cron"
|
|||
|
|
assert output.cron_expression == "*/15 * * * *"
|
|||
|
|
assert output.payload == {"k": "v"}
|
|||
|
|
assert output.stagger_seconds == 30
|
|||
|
|
assert output.status == "active"
|
|||
|
|
assert output.id == 1
|
|||
|
|
|
|||
|
|
def test_datetime_fields_formatted_as_iso(self):
|
|||
|
|
# Arrange - naive datetime 按 UTC 处理(与 DB 存储约定一致)
|
|||
|
|
next_run = datetime(2024, 1, 1, 0, 15, 0)
|
|||
|
|
task = ScheduledTask(
|
|||
|
|
task_id="t1",
|
|||
|
|
handler_name="h",
|
|||
|
|
owner_scope="s",
|
|||
|
|
owner_id="o",
|
|||
|
|
schedule_kind="cron",
|
|||
|
|
next_run_at=next_run,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_task_output(task)
|
|||
|
|
# Assert - DateTime 字段格式化为 ISO 8601 字符串,naive UTC 不偏移
|
|||
|
|
assert isinstance(output.next_run_at, str)
|
|||
|
|
assert output.next_run_at == "2024-01-01T00:15:00Z"
|
|||
|
|
|
|||
|
|
def test_none_datetime_fields_return_none(self):
|
|||
|
|
# Arrange
|
|||
|
|
task = ScheduledTask(
|
|||
|
|
task_id="t1",
|
|||
|
|
handler_name="h",
|
|||
|
|
owner_scope="s",
|
|||
|
|
owner_id="o",
|
|||
|
|
schedule_kind="cron",
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_task_output(task)
|
|||
|
|
# Assert
|
|||
|
|
assert output.next_run_at is None
|
|||
|
|
assert output.last_run_at is None
|
|||
|
|
assert output.run_at is None
|
|||
|
|
assert output.created_at is None
|
|||
|
|
assert output.updated_at is None
|
|||
|
|
assert output.deleted_at is None
|
|||
|
|
|
|||
|
|
def test_none_payload_returns_empty_dict(self):
|
|||
|
|
# Arrange - dataclass payload 为 None 时 mapper 兜底为空 dict
|
|||
|
|
task = ScheduledTask(
|
|||
|
|
task_id="t1",
|
|||
|
|
handler_name="h",
|
|||
|
|
owner_scope="s",
|
|||
|
|
owner_id="o",
|
|||
|
|
schedule_kind="cron",
|
|||
|
|
payload=None, # type: ignore[arg-type]
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_task_output(task)
|
|||
|
|
# Assert
|
|||
|
|
assert output.payload == {}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_run_log_output ────────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToRunLogOutput:
|
|||
|
|
def test_basic_mapping(self):
|
|||
|
|
# Arrange
|
|||
|
|
log = ScheduledTaskRunLog(
|
|||
|
|
task_id="task-001",
|
|||
|
|
run_id="run-001",
|
|||
|
|
triggered_by="auto",
|
|||
|
|
status="success",
|
|||
|
|
output={"rows": 10},
|
|||
|
|
started_at=datetime(2024, 1, 1, 0, 15, 0),
|
|||
|
|
finished_at=datetime(2024, 1, 1, 0, 15, 5),
|
|||
|
|
id=1,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_run_log_output(log)
|
|||
|
|
# Assert
|
|||
|
|
assert output.task_id == "task-001"
|
|||
|
|
assert output.run_id == "run-001"
|
|||
|
|
assert output.triggered_by == "auto"
|
|||
|
|
assert output.status == "success"
|
|||
|
|
assert output.output == {"rows": 10}
|
|||
|
|
assert output.id == 1
|
|||
|
|
|
|||
|
|
def test_datetime_fields_formatted_as_iso(self):
|
|||
|
|
# Arrange - naive datetime 按 UTC 处理(与 DB 存储约定一致)
|
|||
|
|
started = datetime(2024, 1, 1, 0, 15, 0)
|
|||
|
|
log = ScheduledTaskRunLog(
|
|||
|
|
task_id="t",
|
|||
|
|
run_id="r",
|
|||
|
|
started_at=started,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_run_log_output(log)
|
|||
|
|
# Assert - naive UTC 不偏移
|
|||
|
|
assert isinstance(output.started_at, str)
|
|||
|
|
assert output.started_at == "2024-01-01T00:15:00Z"
|
|||
|
|
|
|||
|
|
def test_none_datetime_fields_return_none(self):
|
|||
|
|
log = ScheduledTaskRunLog(task_id="t", run_id="r")
|
|||
|
|
output = to_run_log_output(log)
|
|||
|
|
assert output.started_at is None
|
|||
|
|
assert output.finished_at is None
|
|||
|
|
|
|||
|
|
def test_none_output_returns_none(self):
|
|||
|
|
# Arrange - output 为 None 时 mapper 透传 None
|
|||
|
|
log = ScheduledTaskRunLog(task_id="t", run_id="r", output=None)
|
|||
|
|
# Act
|
|||
|
|
output = to_run_log_output(log)
|
|||
|
|
# Assert
|
|||
|
|
assert output.output is None
|
|||
|
|
|
|||
|
|
def test_none_error_message_returns_none(self):
|
|||
|
|
# Arrange - error_message 为 None 时 mapper 透传 None
|
|||
|
|
log = ScheduledTaskRunLog(task_id="t", run_id="r", error_message=None)
|
|||
|
|
# Act
|
|||
|
|
output = to_run_log_output(log)
|
|||
|
|
# Assert
|
|||
|
|
assert output.error_message is None
|
|||
|
|
|
|||
|
|
def test_all_fields_mapped(self):
|
|||
|
|
# Arrange - 验证全字段映射
|
|||
|
|
log = ScheduledTaskRunLog(
|
|||
|
|
task_id="t1",
|
|||
|
|
run_id="r1",
|
|||
|
|
triggered_by="manual",
|
|||
|
|
status="failure",
|
|||
|
|
error_message="boom",
|
|||
|
|
output={"error": True},
|
|||
|
|
started_at=datetime(2024, 1, 1, 0, 15, 0),
|
|||
|
|
finished_at=datetime(2024, 1, 1, 0, 15, 5),
|
|||
|
|
created_by="admin",
|
|||
|
|
updated_by="operator",
|
|||
|
|
id=42,
|
|||
|
|
created_at=datetime(2024, 1, 1, 0, 0, 0),
|
|||
|
|
updated_at=datetime(2024, 1, 1, 0, 0, 1),
|
|||
|
|
is_deleted=0,
|
|||
|
|
deleted_at=None,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_run_log_output(log)
|
|||
|
|
# Assert
|
|||
|
|
assert output.task_id == "t1"
|
|||
|
|
assert output.run_id == "r1"
|
|||
|
|
assert output.triggered_by == "manual"
|
|||
|
|
assert output.status == "failure"
|
|||
|
|
assert output.error_message == "boom"
|
|||
|
|
assert output.output == {"error": True}
|
|||
|
|
assert output.created_by == "admin"
|
|||
|
|
assert output.updated_by == "operator"
|
|||
|
|
assert output.id == 42
|
|||
|
|
assert output.is_deleted == 0
|
|||
|
|
assert output.deleted_at is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_handler_summary_output ────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToHandlerSummaryOutput:
|
|||
|
|
def test_basic_mapping(self):
|
|||
|
|
# Arrange
|
|||
|
|
summary = HandlerSummary(
|
|||
|
|
name="my_handler",
|
|||
|
|
task_count=5,
|
|||
|
|
last_active_at=datetime(2024, 1, 1, 0, 0, 0),
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_handler_summary_output(summary)
|
|||
|
|
# Assert
|
|||
|
|
assert output.handler_name == "my_handler"
|
|||
|
|
assert output.task_count == 5
|
|||
|
|
assert isinstance(output.last_active_at, str)
|
|||
|
|
|
|||
|
|
def test_none_last_active_at(self):
|
|||
|
|
summary = HandlerSummary(name="h", task_count=0, last_active_at=None)
|
|||
|
|
output = to_handler_summary_output(summary)
|
|||
|
|
assert output.last_active_at is None
|
|||
|
|
|
|||
|
|
def test_description_defaults_empty(self):
|
|||
|
|
# Arrange - description 由调用方按需补充,mapper 不聚合
|
|||
|
|
summary = HandlerSummary(name="h", task_count=0)
|
|||
|
|
# Act
|
|||
|
|
output = to_handler_summary_output(summary)
|
|||
|
|
# Assert
|
|||
|
|
assert output.description == ""
|
|||
|
|
|
|||
|
|
def test_active_task_count_defaults_zero(self):
|
|||
|
|
summary = HandlerSummary(name="h", task_count=0)
|
|||
|
|
output = to_handler_summary_output(summary)
|
|||
|
|
assert output.active_task_count == 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_list_handler_summary_output ───────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToListHandlerSummaryOutput:
|
|||
|
|
def test_empty_list(self):
|
|||
|
|
output = to_list_handler_summary_output([])
|
|||
|
|
assert output.items == []
|
|||
|
|
|
|||
|
|
def test_multiple_items(self):
|
|||
|
|
summaries = [
|
|||
|
|
HandlerSummary(name="h1", task_count=1),
|
|||
|
|
HandlerSummary(name="h2", task_count=2),
|
|||
|
|
]
|
|||
|
|
output = to_list_handler_summary_output(summaries)
|
|||
|
|
assert len(output.items) == 2
|
|||
|
|
assert output.items[0].handler_name == "h1"
|
|||
|
|
assert output.items[1].handler_name == "h2"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_count_by_status_output ────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToCountByStatusOutput:
|
|||
|
|
def test_all_statuses_present(self):
|
|||
|
|
# Arrange
|
|||
|
|
dist = {"active": 10, "paused": 5, "dead_letter": 2}
|
|||
|
|
# Act
|
|||
|
|
output = to_count_by_status_output(dist)
|
|||
|
|
# Assert
|
|||
|
|
assert output.active == 10
|
|||
|
|
assert output.paused == 5
|
|||
|
|
assert output.dead_letter == 2
|
|||
|
|
|
|||
|
|
def test_missing_statuses_default_zero(self):
|
|||
|
|
# Arrange - 仓储层未出现的状态不在字典中
|
|||
|
|
dist = {"active": 10}
|
|||
|
|
# Act
|
|||
|
|
output = to_count_by_status_output(dist)
|
|||
|
|
# Assert
|
|||
|
|
assert output.active == 10
|
|||
|
|
assert output.paused == 0
|
|||
|
|
assert output.dead_letter == 0
|
|||
|
|
|
|||
|
|
def test_empty_dict(self):
|
|||
|
|
output = to_count_by_status_output({})
|
|||
|
|
assert output.active == 0
|
|||
|
|
assert output.paused == 0
|
|||
|
|
assert output.dead_letter == 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_get_health_output ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToGetHealthOutput:
|
|||
|
|
def test_healthy_status(self):
|
|||
|
|
# Arrange
|
|||
|
|
last_tick = datetime(2024, 1, 1, 0, 0, 0)
|
|||
|
|
# Act
|
|||
|
|
output = to_get_health_output(
|
|||
|
|
last_tick_at=last_tick,
|
|||
|
|
active_task_count=10,
|
|||
|
|
dead_letter_count=2,
|
|||
|
|
running_count=3,
|
|||
|
|
healthy=True,
|
|||
|
|
)
|
|||
|
|
# Assert
|
|||
|
|
assert output.status == "healthy"
|
|||
|
|
assert output.active_task_count == 10
|
|||
|
|
assert output.dead_letter_count == 2
|
|||
|
|
assert output.running_count == 3
|
|||
|
|
assert isinstance(output.last_tick_at, str)
|
|||
|
|
|
|||
|
|
def test_unhealthy_status(self):
|
|||
|
|
output = to_get_health_output(
|
|||
|
|
last_tick_at=None,
|
|||
|
|
active_task_count=0,
|
|||
|
|
dead_letter_count=0,
|
|||
|
|
running_count=0,
|
|||
|
|
healthy=False,
|
|||
|
|
)
|
|||
|
|
assert output.status == "unhealthy"
|
|||
|
|
assert output.last_tick_at is None
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── daily_stat_to_dict ───────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestDailyStatToDict:
|
|||
|
|
def test_basic_mapping(self):
|
|||
|
|
# Arrange
|
|||
|
|
stat = DailyStat(
|
|||
|
|
stat_date="2024-01-01",
|
|||
|
|
handler_name="h",
|
|||
|
|
success_count=10,
|
|||
|
|
failure_count=2,
|
|||
|
|
timeout_count=1,
|
|||
|
|
dead_letter_count=0,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
result = daily_stat_to_dict(stat)
|
|||
|
|
# Assert
|
|||
|
|
assert result["stat_date"] == "2024-01-01"
|
|||
|
|
assert result["handler_name"] == "h"
|
|||
|
|
assert result["success_count"] == 10
|
|||
|
|
assert result["failure_count"] == 2
|
|||
|
|
assert result["timeout_count"] == 1
|
|||
|
|
assert result["dead_letter_count"] == 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_daily_stat_output ─────────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToDailyStatOutput:
|
|||
|
|
def test_basic_mapping_with_derived_fields(self):
|
|||
|
|
# Arrange
|
|||
|
|
stat = DailyStat(
|
|||
|
|
stat_date=datetime(2024, 1, 1).date(),
|
|||
|
|
handler_name="h",
|
|||
|
|
success_count=10,
|
|||
|
|
failure_count=2,
|
|||
|
|
timeout_count=1,
|
|||
|
|
dead_letter_count=0,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_daily_stat_output(stat)
|
|||
|
|
# Assert
|
|||
|
|
assert output.stat_date == "2024-01-01"
|
|||
|
|
assert output.handler_name == "h"
|
|||
|
|
assert output.success_count == 10
|
|||
|
|
assert output.failure_count == 2
|
|||
|
|
assert output.timeout_count == 1
|
|||
|
|
assert output.dead_letter_count == 0
|
|||
|
|
# 派生字段:total = 10 + 2 + 1 = 13
|
|||
|
|
assert output.total_count == 13
|
|||
|
|
# success_rate = 10 / 13
|
|||
|
|
assert output.success_rate == pytest.approx(10 / 13)
|
|||
|
|
|
|||
|
|
def test_zero_total_count_success_rate_zero(self):
|
|||
|
|
# Arrange - 所有计数为 0
|
|||
|
|
stat = DailyStat(
|
|||
|
|
stat_date=datetime(2024, 1, 1).date(),
|
|||
|
|
handler_name="h",
|
|||
|
|
success_count=0,
|
|||
|
|
failure_count=0,
|
|||
|
|
timeout_count=0,
|
|||
|
|
dead_letter_count=0,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_daily_stat_output(stat)
|
|||
|
|
# Assert
|
|||
|
|
assert output.total_count == 0
|
|||
|
|
assert output.success_rate == 0.0
|
|||
|
|
|
|||
|
|
def test_dead_letter_count_excluded_from_total(self):
|
|||
|
|
# Arrange - dead_letter_count 不计入 total_count
|
|||
|
|
stat = DailyStat(
|
|||
|
|
stat_date=datetime(2024, 1, 1).date(),
|
|||
|
|
handler_name="h",
|
|||
|
|
success_count=5,
|
|||
|
|
failure_count=2,
|
|||
|
|
timeout_count=1,
|
|||
|
|
dead_letter_count=3,
|
|||
|
|
)
|
|||
|
|
# Act
|
|||
|
|
output = to_daily_stat_output(stat)
|
|||
|
|
# Assert - total = 5 + 2 + 1 = 8(不含 dead_letter)
|
|||
|
|
assert output.total_count == 8
|
|||
|
|
assert output.success_rate == pytest.approx(5 / 8)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_list_daily_stats_output ───────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToListDailyStatsOutput:
|
|||
|
|
def test_empty_list(self):
|
|||
|
|
output = to_list_daily_stats_output([])
|
|||
|
|
assert output.items == []
|
|||
|
|
|
|||
|
|
def test_multiple_items(self):
|
|||
|
|
stats = [
|
|||
|
|
DailyStat(
|
|||
|
|
stat_date=datetime(2024, 1, 1).date(),
|
|||
|
|
handler_name="h1",
|
|||
|
|
success_count=1,
|
|||
|
|
failure_count=0,
|
|||
|
|
timeout_count=0,
|
|||
|
|
dead_letter_count=0,
|
|||
|
|
),
|
|||
|
|
DailyStat(
|
|||
|
|
stat_date=datetime(2024, 1, 2).date(),
|
|||
|
|
handler_name="h2",
|
|||
|
|
success_count=2,
|
|||
|
|
failure_count=0,
|
|||
|
|
timeout_count=0,
|
|||
|
|
dead_letter_count=0,
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
output = to_list_daily_stats_output(stats)
|
|||
|
|
assert len(output.items) == 2
|
|||
|
|
assert output.items[0].handler_name == "h1"
|
|||
|
|
assert output.items[1].handler_name == "h2"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ─── to_list_upcoming_output ──────────────────────────────────────────────
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestToListUpcomingOutput:
|
|||
|
|
def test_empty_list(self):
|
|||
|
|
output = to_list_upcoming_output([])
|
|||
|
|
assert output.items == []
|
|||
|
|
|
|||
|
|
def test_multiple_tasks(self):
|
|||
|
|
tasks = [
|
|||
|
|
ScheduledTask(
|
|||
|
|
task_id="t1",
|
|||
|
|
handler_name="h",
|
|||
|
|
owner_scope="s",
|
|||
|
|
owner_id="o",
|
|||
|
|
schedule_kind="cron",
|
|||
|
|
),
|
|||
|
|
ScheduledTask(
|
|||
|
|
task_id="t2",
|
|||
|
|
handler_name="h",
|
|||
|
|
owner_scope="s",
|
|||
|
|
owner_id="o",
|
|||
|
|
schedule_kind="cron",
|
|||
|
|
),
|
|||
|
|
]
|
|||
|
|
output = to_list_upcoming_output(tasks)
|
|||
|
|
assert len(output.items) == 2
|
|||
|
|
assert output.items[0].task_id == "t1"
|
|||
|
|
assert output.items[1].task_id == "t2"
|