ForcePilot/backend/test/unit/scheduler/adapters/persistence/test_mappers.py
Kris 9e68234c20 test: 新增 scheduler 限界上下文全量单元测试
新增覆盖以下模块的单元测试:
1. 核心契约与校验函数
2. 持久化层工作单元与ORM映射
3. 运行时处理器注册表
4. 业务用例工具类(退避、抖动、Cron计算)
5. 基础设施容器装配
6. 框架内置清理处理器(运行日志、幂等记录)
7. 测试共享Fixture与桩实现
2026-06-22 21:16:16 +08:00

287 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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