ForcePilot/backend/test/unit/scheduler/use_cases/test_mappers.py

728 lines
24 KiB
Python
Raw Normal View History

"""领域模型(dataclass) ↔ Output DTO(Pydantic) 转换单元测试。
覆盖 ``yuxi.scheduler.use_cases.mappers`` 模块的全部公开函数
- ``to_task_output`` / ``to_run_log_output``dataclass Output DTO
- ``to_trigger_task_output``dataclass + run_id TriggerTaskOutput 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``
- ``to_list_anomalies_output``异常任务分组映射 DTO
"""
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.dto.scheduler import SchedulerConfigOutput
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_anomalies_output,
to_list_daily_stats_output,
to_list_handler_summary_output,
to_list_upcoming_output,
to_run_log_output,
to_task_output,
to_trigger_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_trigger_task_output ───────────────────────────────────────────────
@pytest.mark.unit
class TestToTriggerTaskOutput:
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",
)
run_id = "run-abc"
# Act
output = to_trigger_task_output(task, run_id)
# Assert
assert output.run_id == "run-abc"
assert output.task.task_id == "task-001"
assert output.task.handler_name == "my_handler"
def test_task_snapshot_is_full_task_output(self):
# Arrange - task 字段为完整 TaskOutput包含所有映射字段
task = ScheduledTask(
task_id="t1",
handler_name="h",
owner_scope="business",
owner_id="o",
schedule_kind="at",
run_at=datetime(2024, 1, 1, 0, 0, 0),
status="active",
id=42,
)
# Act
output = to_trigger_task_output(task, "run-1")
# Assert
assert output.task.schedule_kind == "at"
assert output.task.run_at == "2024-01-01T00:00:00Z"
assert output.task.id == 42
# ─── 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",
handler_name="email_handler",
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.handler_name == "email_handler"
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
def test_duration_seconds_calculated_from_started_and_finished(self):
# Arrange - finished_at - started_at = 5.0s
log = ScheduledTaskRunLog(
task_id="t",
run_id="r",
started_at=datetime(2024, 1, 1, 0, 15, 0),
finished_at=datetime(2024, 1, 1, 0, 15, 5),
)
# Act
output = to_run_log_output(log)
# Assert
assert output.duration_seconds == 5.0
def test_duration_seconds_none_when_finished_at_missing(self):
# Arrange - running 状态 finished_at 为 None
log = ScheduledTaskRunLog(
task_id="t",
run_id="r",
started_at=datetime(2024, 1, 1, 0, 15, 0),
finished_at=None,
)
# Act
output = to_run_log_output(log)
# Assert
assert output.duration_seconds is None
def test_duration_seconds_with_milliseconds(self):
# Arrange - 1.5s 时长,验证毫秒精度(保留 3 位小数)
log = ScheduledTaskRunLog(
task_id="t",
run_id="r",
started_at=datetime(2024, 1, 1, 0, 15, 0, 0),
finished_at=datetime(2024, 1, 1, 0, 15, 1, 500000),
)
# Act
output = to_run_log_output(log)
# Assert
assert output.duration_seconds == 1.5
# ─── to_handler_summary_output ────────────────────────────────────────────
@pytest.mark.unit
class TestToHandlerSummaryOutput:
def test_basic_mapping(self):
# Arrange
summary = HandlerSummary(
name="my_handler",
task_count=5,
active_task_count=3,
dead_letter_count=1,
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 output.active_task_count == 3
assert output.dead_letter_count == 1
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_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
def test_dead_letter_count_defaults_zero(self):
# Arrange - dead_letter_count 未提供时默认 0
summary = HandlerSummary(name="h", task_count=0)
# Act
output = to_handler_summary_output(summary)
# Assert
assert output.dead_letter_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:
@staticmethod
def _make_config() -> SchedulerConfigOutput:
return SchedulerConfigOutput(
enabled=True,
tick_interval_seconds=60,
task_timeout_seconds=600,
max_consecutive_errors=5,
backoff_schedule=[10, 30, 120, 600, 3600],
stagger_max_seconds=300,
run_log_retention_days=90,
idempotency_retention_hours=24,
min_cron_interval_minutes=10,
)
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,
config=self._make_config(),
)
# 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)
assert output.config.enabled is True
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,
config=self._make_config(),
)
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_included_in_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 + 3 = 11含 dead_letter
assert output.total_count == 11
assert output.success_rate == pytest.approx(5 / 11)
# ─── 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"
# ─── to_list_anomalies_output ─────────────────────────────────────────────
@pytest.mark.unit
class TestToListAnomaliesOutput:
def test_empty_groups_returns_empty_lists(self):
# Arrange & Act - 空分组字典
output = to_list_anomalies_output({})
# Assert - 三个列表都应为空
assert output.dead_letter == []
assert output.consecutive_failure == []
assert output.stale == []
def test_dead_letter_group_mapped(self):
# Arrange
task = ScheduledTask(
task_id="t1",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
status="dead_letter",
)
# Act
output = to_list_anomalies_output({"dead_letter": [task]})
# Assert
assert len(output.dead_letter) == 1
assert output.dead_letter[0].task_id == "t1"
assert output.consecutive_failure == []
assert output.stale == []
def test_consecutive_failure_group_mapped(self):
# Arrange
task = ScheduledTask(
task_id="t2",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
consecutive_errors=5,
)
# Act
output = to_list_anomalies_output({"consecutive_failure": [task]})
# Assert
assert len(output.consecutive_failure) == 1
assert output.consecutive_failure[0].task_id == "t2"
assert output.dead_letter == []
assert output.stale == []
def test_stale_group_mapped(self):
# Arrange
task = ScheduledTask(
task_id="t3",
handler_name="h",
owner_scope="s",
owner_id="o",
schedule_kind="cron",
last_run_at=datetime(2024, 1, 1, 0, 0, 0),
)
# Act
output = to_list_anomalies_output({"stale": [task]})
# Assert
assert len(output.stale) == 1
assert output.stale[0].task_id == "t3"
assert output.dead_letter == []
assert output.consecutive_failure == []
def test_all_groups_mapped(self):
# Arrange - 三类异常任务同时存在
groups = {
"dead_letter": [
ScheduledTask(
task_id="dl-1", handler_name="h", owner_scope="s",
owner_id="o", schedule_kind="cron", status="dead_letter",
),
],
"consecutive_failure": [
ScheduledTask(
task_id="cf-1", handler_name="h", owner_scope="s",
owner_id="o", schedule_kind="cron", consecutive_errors=3,
),
],
"stale": [
ScheduledTask(
task_id="st-1", handler_name="h", owner_scope="s",
owner_id="o", schedule_kind="cron",
last_run_at=datetime(2024, 1, 1, 0, 0, 0),
),
],
}
# Act
output = to_list_anomalies_output(groups)
# Assert
assert len(output.dead_letter) == 1
assert output.dead_letter[0].task_id == "dl-1"
assert len(output.consecutive_failure) == 1
assert output.consecutive_failure[0].task_id == "cf-1"
assert len(output.stale) == 1
assert output.stale[0].task_id == "st-1"
def test_missing_group_key_defaults_to_empty_list(self):
# Arrange - 分组字典只含部分 key缺失的 key 应默认空列表
groups = {"dead_letter": [
ScheduledTask(
task_id="t1", handler_name="h", owner_scope="s",
owner_id="o", schedule_kind="cron",
),
]}
# Act
output = to_list_anomalies_output(groups)
# Assert - 缺失的 consecutive_failure / stale 默认空列表
assert len(output.dead_letter) == 1
assert output.consecutive_failure == []
assert output.stale == []