新增覆盖以下模块的单元测试: 1. 核心契约与校验函数 2. 持久化层工作单元与ORM映射 3. 运行时处理器注册表 4. 业务用例工具类(退避、抖动、Cron计算) 5. 基础设施容器装配 6. 框架内置清理处理器(运行日志、幂等记录) 7. 测试共享Fixture与桩实现
287 lines
9.8 KiB
Python
287 lines
9.8 KiB
Python
"""ORM ↔ dataclass 映射边界单元测试。
|
||
|
||
覆盖 ``yuxi.scheduler.adapters.persistence.mappers`` 模块:
|
||
- ``orm_to_task``:ORM ScheduledTask → core dataclass
|
||
- ``orm_to_run_log``:ORM ScheduledTaskRunLog → core dataclass
|
||
- ``daily_stat_row_to_dataclass``:仓储层 DailyStat → core dataclass
|
||
- ``handler_summary_row_to_dataclass``:仓储层 HandlerSummary → core dataclass
|
||
- None 入参返回 None
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime
|
||
from types import SimpleNamespace
|
||
|
||
import pytest
|
||
|
||
from yuxi.scheduler.adapters.persistence.mappers import (
|
||
daily_stat_row_to_dataclass,
|
||
handler_summary_row_to_dataclass,
|
||
orm_to_run_log,
|
||
orm_to_task,
|
||
)
|
||
from yuxi.scheduler.core.models import (
|
||
DailyStat,
|
||
HandlerSummary,
|
||
ScheduledTask,
|
||
ScheduledTaskRunLog,
|
||
)
|
||
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
|
||
# ─── 测试辅助 ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _make_task_orm(**overrides) -> SimpleNamespace:
|
||
"""构造类似 ORM ScheduledTask 的 SimpleNamespace。"""
|
||
defaults = {
|
||
"id": 1,
|
||
"task_id": "task-001",
|
||
"handler_name": "my_handler",
|
||
"owner_scope": "system",
|
||
"owner_id": "test:1",
|
||
"schedule_kind": "cron",
|
||
"cron_expression": "*/15 * * * *",
|
||
"run_at": None,
|
||
"tz": "Asia/Shanghai",
|
||
"payload": {"k": "v"},
|
||
"enabled": True,
|
||
"delete_after_run": False,
|
||
"block_strategy": "discard_later",
|
||
"stagger_seconds": 30,
|
||
"consecutive_errors": 0,
|
||
"status": "active",
|
||
"last_run_at": None,
|
||
"next_run_at": datetime(2024, 1, 1, 0, 15, 0),
|
||
"last_error": None,
|
||
"created_by": "system",
|
||
"updated_by": None,
|
||
"created_at": datetime(2024, 1, 1, 0, 0, 0),
|
||
"updated_at": None,
|
||
"is_deleted": 0,
|
||
"deleted_at": None,
|
||
}
|
||
defaults.update(overrides)
|
||
return SimpleNamespace(**defaults)
|
||
|
||
|
||
def _make_run_log_orm(**overrides) -> SimpleNamespace:
|
||
"""构造类似 ORM ScheduledTaskRunLog 的 SimpleNamespace。"""
|
||
defaults = {
|
||
"id": 1,
|
||
"task_id": "task-001",
|
||
"run_id": "run-001",
|
||
"triggered_by": "auto",
|
||
"status": "success",
|
||
"error_message": None,
|
||
"output": {"rows": 10},
|
||
"started_at": datetime(2024, 1, 1, 0, 15, 0),
|
||
"finished_at": datetime(2024, 1, 1, 0, 15, 5),
|
||
"created_by": "system",
|
||
"updated_by": None,
|
||
"created_at": datetime(2024, 1, 1, 0, 0, 0),
|
||
"updated_at": None,
|
||
"is_deleted": 0,
|
||
"deleted_at": None,
|
||
}
|
||
defaults.update(overrides)
|
||
return SimpleNamespace(**defaults)
|
||
|
||
|
||
# ─── orm_to_task ──────────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestOrmToTask:
|
||
def test_none_returns_none(self):
|
||
# Arrange & Act & Assert - 入参为 None 时返回 None
|
||
assert orm_to_task(None) is None
|
||
|
||
def test_basic_mapping(self):
|
||
# Arrange
|
||
orm = _make_task_orm()
|
||
# Act
|
||
task = orm_to_task(orm)
|
||
# Assert
|
||
assert isinstance(task, ScheduledTask)
|
||
assert task.id == 1
|
||
assert task.task_id == "task-001"
|
||
assert task.handler_name == "my_handler"
|
||
assert task.owner_scope == "system"
|
||
assert task.schedule_kind == "cron"
|
||
assert task.cron_expression == "*/15 * * * *"
|
||
assert task.payload == {"k": "v"}
|
||
assert task.status == "active"
|
||
assert task.next_run_at == datetime(2024, 1, 1, 0, 15, 0)
|
||
|
||
def test_none_payload_becomes_empty_dict(self):
|
||
# Arrange - ORM payload 为 None 时 mapper 兜底为空 dict
|
||
orm = _make_task_orm(payload=None)
|
||
# Act
|
||
task = orm_to_task(orm)
|
||
# Assert
|
||
assert task.payload == {}
|
||
|
||
def test_all_fields_mapped(self):
|
||
# Arrange - 验证所有字段都被映射
|
||
orm = _make_task_orm()
|
||
# Act
|
||
task = orm_to_task(orm)
|
||
# Assert
|
||
assert task.id == orm.id
|
||
assert task.task_id == orm.task_id
|
||
assert task.handler_name == orm.handler_name
|
||
assert task.owner_scope == orm.owner_scope
|
||
assert task.owner_id == orm.owner_id
|
||
assert task.schedule_kind == orm.schedule_kind
|
||
assert task.cron_expression == orm.cron_expression
|
||
assert task.run_at == orm.run_at
|
||
assert task.tz == orm.tz
|
||
assert task.payload == orm.payload
|
||
assert task.enabled == orm.enabled
|
||
assert task.delete_after_run == orm.delete_after_run
|
||
assert task.block_strategy == orm.block_strategy
|
||
assert task.stagger_seconds == orm.stagger_seconds
|
||
assert task.consecutive_errors == orm.consecutive_errors
|
||
assert task.status == orm.status
|
||
assert task.last_run_at == orm.last_run_at
|
||
assert task.next_run_at == orm.next_run_at
|
||
assert task.last_error == orm.last_error
|
||
assert task.created_by == orm.created_by
|
||
assert task.updated_by == orm.updated_by
|
||
assert task.created_at == orm.created_at
|
||
assert task.updated_at == orm.updated_at
|
||
assert task.is_deleted == orm.is_deleted
|
||
assert task.deleted_at == orm.deleted_at
|
||
|
||
|
||
# ─── orm_to_run_log ───────────────────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestOrmToRunLog:
|
||
def test_none_returns_none(self):
|
||
assert orm_to_run_log(None) is None
|
||
|
||
def test_basic_mapping(self):
|
||
# Arrange
|
||
orm = _make_run_log_orm()
|
||
# Act
|
||
log = orm_to_run_log(orm)
|
||
# Assert
|
||
assert isinstance(log, ScheduledTaskRunLog)
|
||
assert log.id == 1
|
||
assert log.task_id == "task-001"
|
||
assert log.run_id == "run-001"
|
||
assert log.triggered_by == "auto"
|
||
assert log.status == "success"
|
||
assert log.output == {"rows": 10}
|
||
assert log.started_at == datetime(2024, 1, 1, 0, 15, 0)
|
||
|
||
def test_all_fields_mapped(self):
|
||
orm = _make_run_log_orm()
|
||
log = orm_to_run_log(orm)
|
||
assert log.id == orm.id
|
||
assert log.task_id == orm.task_id
|
||
assert log.run_id == orm.run_id
|
||
assert log.triggered_by == orm.triggered_by
|
||
assert log.status == orm.status
|
||
assert log.error_message == orm.error_message
|
||
assert log.output == orm.output
|
||
assert log.started_at == orm.started_at
|
||
assert log.finished_at == orm.finished_at
|
||
assert log.created_by == orm.created_by
|
||
assert log.updated_by == orm.updated_by
|
||
assert log.created_at == orm.created_at
|
||
assert log.updated_at == orm.updated_at
|
||
assert log.is_deleted == orm.is_deleted
|
||
assert log.deleted_at == orm.deleted_at
|
||
|
||
|
||
# ─── daily_stat_row_to_dataclass ──────────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestDailyStatRowToDataclass:
|
||
def test_basic_mapping(self):
|
||
# Arrange - row 可能是 NamedTuple 或 dataclass 实例
|
||
row = SimpleNamespace(
|
||
stat_date=datetime(2024, 1, 1).date(),
|
||
handler_name="h",
|
||
success_count=10,
|
||
failure_count=2,
|
||
timeout_count=1,
|
||
dead_letter_count=0,
|
||
)
|
||
# Act
|
||
stat = daily_stat_row_to_dataclass(row)
|
||
# Assert
|
||
assert isinstance(stat, DailyStat)
|
||
assert stat.stat_date == datetime(2024, 1, 1).date()
|
||
assert stat.handler_name == "h"
|
||
assert stat.success_count == 10
|
||
assert stat.failure_count == 2
|
||
assert stat.timeout_count == 1
|
||
assert stat.dead_letter_count == 0
|
||
|
||
def test_string_counts_converted_to_int(self):
|
||
# Arrange - 仓储层可能返回字符串计数值
|
||
row = SimpleNamespace(
|
||
stat_date=datetime(2024, 1, 1).date(),
|
||
handler_name="h",
|
||
success_count="10",
|
||
failure_count="2",
|
||
timeout_count="1",
|
||
dead_letter_count="0",
|
||
)
|
||
# Act
|
||
stat = daily_stat_row_to_dataclass(row)
|
||
# Assert - int() 转换
|
||
assert stat.success_count == 10
|
||
assert stat.failure_count == 2
|
||
assert stat.timeout_count == 1
|
||
assert stat.dead_letter_count == 0
|
||
assert isinstance(stat.success_count, int)
|
||
|
||
|
||
# ─── handler_summary_row_to_dataclass ─────────────────────────────────────
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestHandlerSummaryRowToDataclass:
|
||
def test_basic_mapping(self):
|
||
# Arrange
|
||
row = SimpleNamespace(
|
||
name="my_handler",
|
||
task_count=5,
|
||
last_active_at=datetime(2024, 1, 1, 0, 0, 0),
|
||
)
|
||
# Act
|
||
summary = handler_summary_row_to_dataclass(row)
|
||
# Assert
|
||
assert isinstance(summary, HandlerSummary)
|
||
assert summary.name == "my_handler"
|
||
assert summary.task_count == 5
|
||
assert summary.last_active_at == datetime(2024, 1, 1, 0, 0, 0)
|
||
|
||
def test_string_task_count_converted_to_int(self):
|
||
# Arrange
|
||
row = SimpleNamespace(
|
||
name="h",
|
||
task_count="7",
|
||
last_active_at=None,
|
||
)
|
||
# Act
|
||
summary = handler_summary_row_to_dataclass(row)
|
||
# Assert
|
||
assert summary.task_count == 7
|
||
assert isinstance(summary.task_count, int)
|
||
|
||
def test_none_last_active_at(self):
|
||
row = SimpleNamespace(name="h", task_count=0, last_active_at=None)
|
||
summary = handler_summary_row_to_dataclass(row)
|
||
assert summary.last_active_at is None
|