新增覆盖以下模块的单元测试: 1. 核心契约与校验函数 2. 持久化层工作单元与ORM映射 3. 运行时处理器注册表 4. 业务用例工具类(退避、抖动、Cron计算) 5. 基础设施容器装配 6. 框架内置清理处理器(运行日志、幂等记录) 7. 测试共享Fixture与桩实现
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"
|