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