diff --git a/backend/test/unit/scheduler/adapters/persistence/test_mappers.py b/backend/test/unit/scheduler/adapters/persistence/test_mappers.py new file mode 100644 index 00000000..8bf045c4 --- /dev/null +++ b/backend/test/unit/scheduler/adapters/persistence/test_mappers.py @@ -0,0 +1,286 @@ +"""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 diff --git a/backend/test/unit/scheduler/adapters/persistence/test_repos.py b/backend/test/unit/scheduler/adapters/persistence/test_repos.py new file mode 100644 index 00000000..29321bf4 --- /dev/null +++ b/backend/test/unit/scheduler/adapters/persistence/test_repos.py @@ -0,0 +1,630 @@ +"""SqlAlchemy 仓储适配器单元测试。 + +覆盖 ``yuxi.scheduler.adapters.persistence`` 下的 4 个仓储适配器: +- ``SqlAlchemyTaskRepository``:委托 ScheduledTaskRepository +- ``SqlAlchemyRunLogRepository``:委托 ScheduledTaskRunLogRepository +- ``SqlAlchemyRunLogDailyRepository``:委托 ScheduledTaskRunLogDailyRepository +- ``SqlAlchemyIdempotencyRepository``:委托 ScheduledTaskIdempotencyRepository + +验证薄委托行为:调用实际仓储方法后通过 mappers 转换为 core dataclass, +``commit`` 等控制参数透传,不包含业务逻辑。 +""" + +from __future__ import annotations + +from datetime import date, datetime +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.scheduler.adapters.persistence.sqlalchemy_idempotency_repo import ( + SqlAlchemyIdempotencyRepository, +) +from yuxi.scheduler.adapters.persistence.sqlalchemy_run_log_daily_repo import ( + SqlAlchemyRunLogDailyRepository, +) +from yuxi.scheduler.adapters.persistence.sqlalchemy_run_log_repo import ( + SqlAlchemyRunLogRepository, +) +from yuxi.scheduler.adapters.persistence.sqlalchemy_task_repo import ( + SqlAlchemyTaskRepository, +) +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: + 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) + + +# ─── SqlAlchemyTaskRepository ───────────────────────────────────────────── + + +@pytest.mark.unit +class TestSqlAlchemyTaskRepository: + @pytest.fixture + def repo_with_mock(self): + """构造 SqlAlchemyTaskRepository,内部 ORM 仓储为 AsyncMock。""" + db = MagicMock() + with patch( + "yuxi.scheduler.adapters.persistence.sqlalchemy_task_repo.ORMScheduledTaskRepository" + ) as mock_cls: + mock_orm_repo = AsyncMock() + mock_cls.return_value = mock_orm_repo + repo = SqlAlchemyTaskRepository(db) + return repo, mock_orm_repo + + @pytest.mark.asyncio + async def test_create_delegates_and_maps(self, repo_with_mock): + # Arrange + repo, mock_orm = repo_with_mock + mock_orm.create = AsyncMock(return_value=_make_task_orm()) + data = {"task_id": "task-001"} + # Act + result = await repo.create(data, commit=False) + # Assert + mock_orm.create.assert_awaited_once_with(data, commit=False) + assert isinstance(result, ScheduledTask) + assert result.task_id == "task-001" + + @pytest.mark.asyncio + async def test_get_by_task_id_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get_by_task_id = AsyncMock(return_value=_make_task_orm()) + # Act + result = await repo.get_by_task_id("task-001", for_update=True) + # Assert + mock_orm.get_by_task_id.assert_awaited_once_with("task-001", for_update=True) + assert isinstance(result, ScheduledTask) + + @pytest.mark.asyncio + async def test_get_by_task_id_returns_none_when_orm_returns_none(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get_by_task_id = AsyncMock(return_value=None) + # Act + result = await repo.get_by_task_id("nonexistent") + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_get_by_id_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get_by_id = AsyncMock(return_value=_make_task_orm()) + # Act + result = await repo.get_by_id(1, for_update=False) + # Assert + mock_orm.get_by_id.assert_awaited_once_with(1, for_update=False) + assert isinstance(result, ScheduledTask) + + @pytest.mark.asyncio + async def test_list_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.list = AsyncMock(return_value=([_make_task_orm(), _make_task_orm()], 2)) + # Act + tasks, total = await repo.list(page=1, page_size=20) + # Assert + assert len(tasks) == 2 + assert total == 2 + assert all(isinstance(t, ScheduledTask) for t in tasks) + + @pytest.mark.asyncio + async def test_update_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.update = AsyncMock(return_value=_make_task_orm(status="paused")) + # Act + result = await repo.update("task-001", {"status": "paused"}, commit=False) + # Assert + mock_orm.update.assert_awaited_once_with("task-001", {"status": "paused"}, commit=False) + assert isinstance(result, ScheduledTask) + assert result.status == "paused" + + @pytest.mark.asyncio + async def test_delete_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.delete = AsyncMock(return_value=True) + # Act + result = await repo.delete("task-001", commit=False) + # Assert + mock_orm.delete.assert_awaited_once_with("task-001", commit=False) + assert result is True + + @pytest.mark.asyncio + async def test_list_due_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.list_due = AsyncMock(return_value=[_make_task_orm()]) + now = datetime(2024, 1, 1, 0, 0, 0) + # Act + result = await repo.list_due(now, limit=500) + # Assert + mock_orm.list_due.assert_awaited_once_with(now, limit=500) + assert len(result) == 1 + assert isinstance(result[0], ScheduledTask) + + @pytest.mark.asyncio + async def test_list_upcoming_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.list_upcoming = AsyncMock(return_value=[_make_task_orm()]) + after = datetime(2024, 1, 1, 0, 0, 0) + before = datetime(2024, 1, 2, 0, 0, 0) + # Act + result = await repo.list_upcoming(after=after, before=before, limit=50) + # Assert + mock_orm.list_upcoming.assert_awaited_once_with( + after=after, before=before, limit=50, handler_name=None + ) + assert len(result) == 1 + + @pytest.mark.asyncio + async def test_list_handler_summary_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + row = SimpleNamespace(name="h", task_count=5, last_active_at=None) + mock_orm.list_handler_summary = AsyncMock(return_value=[row]) + # Act + result = await repo.list_handler_summary() + # Assert + assert len(result) == 1 + assert isinstance(result[0], HandlerSummary) + assert result[0].name == "h" + + @pytest.mark.asyncio + async def test_count_by_status_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.count_by_status = AsyncMock(return_value={"active": 10}) + # Act + result = await repo.count_by_status(owner_scope="system") + # Assert + mock_orm.count_by_status.assert_awaited_once_with( + owner_scope="system", owner_id=None + ) + assert result == {"active": 10} + + @pytest.mark.asyncio + async def test_acquire_for_run_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.acquire_for_run = AsyncMock(return_value=True) + now = datetime(2024, 1, 1, 0, 0, 0) + next_run = datetime(2024, 1, 1, 0, 15, 0) + # Act + result = await repo.acquire_for_run( + "task-001", run_id="run-001", now=now, next_run_at=next_run + ) + # Assert + mock_orm.acquire_for_run.assert_awaited_once_with( + "task-001", run_id="run-001", now=now, next_run_at=next_run + ) + assert result is True + + @pytest.mark.asyncio + async def test_pause_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.pause = AsyncMock(return_value=_make_task_orm(status="paused")) + # Act + result = await repo.pause("task-001", commit=False) + # Assert + mock_orm.pause.assert_awaited_once_with("task-001", commit=False) + assert isinstance(result, ScheduledTask) + assert result.status == "paused" + + @pytest.mark.asyncio + async def test_resume_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.resume = AsyncMock(return_value=_make_task_orm(status="active")) + next_run = datetime(2024, 1, 1, 0, 15, 0) + # Act + result = await repo.resume("task-001", next_run_at=next_run, commit=False) + # Assert + mock_orm.resume.assert_awaited_once_with( + "task-001", next_run_at=next_run, commit=False + ) + assert isinstance(result, ScheduledTask) + + @pytest.mark.asyncio + async def test_mark_dead_letter_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.mark_dead_letter = AsyncMock(return_value=_make_task_orm(status="dead_letter")) + # Act + result = await repo.mark_dead_letter("task-001", commit=False) + # Assert + mock_orm.mark_dead_letter.assert_awaited_once_with("task-001", commit=False) + assert result.status == "dead_letter" + + @pytest.mark.asyncio + async def test_reset_from_dead_letter_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.reset_from_dead_letter = AsyncMock(return_value=_make_task_orm(status="active")) + next_run = datetime(2024, 1, 1, 0, 15, 0) + # Act + result = await repo.reset_from_dead_letter("task-001", next_run_at=next_run) + # Assert + mock_orm.reset_from_dead_letter.assert_awaited_once_with( + "task-001", next_run_at=next_run, commit=True + ) + + @pytest.mark.asyncio + async def test_record_run_result_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.record_run_result = AsyncMock( + return_value=_make_task_orm(consecutive_errors=1) + ) + # Act + result = await repo.record_run_result( + "task-001", success=False, error="boom", commit=False + ) + # Assert + mock_orm.record_run_result.assert_awaited_once_with( + "task-001", success=False, error="boom", backoff_until=None, commit=False + ) + assert result.consecutive_errors == 1 + + @pytest.mark.asyncio + async def test_get_deleted_by_task_id_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get_deleted_by_task_id = AsyncMock(return_value=_make_task_orm(is_deleted=1)) + # Act + result = await repo.get_deleted_by_task_id("task-001") + # Assert + assert isinstance(result, ScheduledTask) + assert result.is_deleted == 1 + + @pytest.mark.asyncio + async def test_list_deleted_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.list_deleted = AsyncMock(return_value=[_make_task_orm(is_deleted=1)]) + # Act + result = await repo.list_deleted(limit=10, offset=0) + # Assert + assert len(result) == 1 + assert isinstance(result[0], ScheduledTask) + + @pytest.mark.asyncio + async def test_hard_delete_by_id_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.hard_delete_by_id = AsyncMock(return_value=1) + # Act + result = await repo.hard_delete_by_id(1) + # Assert + mock_orm.hard_delete_by_id.assert_awaited_once_with(1, commit=True) + assert result == 1 + + +# ─── SqlAlchemyRunLogRepository ─────────────────────────────────────────── + + +@pytest.mark.unit +class TestSqlAlchemyRunLogRepository: + @pytest.fixture + def repo_with_mock(self): + db = MagicMock() + with patch( + "yuxi.scheduler.adapters.persistence.sqlalchemy_run_log_repo.ORMScheduledTaskRunLogRepository" + ) as mock_cls: + mock_orm_repo = AsyncMock() + mock_cls.return_value = mock_orm_repo + repo = SqlAlchemyRunLogRepository(db) + return repo, mock_orm_repo + + @pytest.mark.asyncio + async def test_create_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.create = AsyncMock(return_value=_make_run_log_orm()) + # Act + result = await repo.create({"task_id": "t1"}, commit=False) + # Assert + mock_orm.create.assert_awaited_once_with({"task_id": "t1"}, commit=False) + assert isinstance(result, ScheduledTaskRunLog) + + @pytest.mark.asyncio + async def test_get_by_run_id_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get_by_run_id = AsyncMock(return_value=_make_run_log_orm()) + # Act + result = await repo.get_by_run_id("run-001") + # Assert + mock_orm.get_by_run_id.assert_awaited_once_with("run-001") + assert isinstance(result, ScheduledTaskRunLog) + + @pytest.mark.asyncio + async def test_get_by_run_id_returns_none_when_not_found(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get_by_run_id = AsyncMock(return_value=None) + # Act + result = await repo.get_by_run_id("nonexistent") + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_update_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.update = AsyncMock(return_value=_make_run_log_orm(status="success")) + # Act + result = await repo.update("run-001", {"status": "success"}, commit=False) + # Assert + mock_orm.update.assert_awaited_once_with("run-001", {"status": "success"}, commit=False) + assert result.status == "success" + + @pytest.mark.asyncio + async def test_list_by_task_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.list_by_task = AsyncMock(return_value=([_make_run_log_orm()], 1)) + # Act + logs, total = await repo.list_by_task("task-001", page=1, page_size=20) + # Assert + assert len(logs) == 1 + assert total == 1 + assert isinstance(logs[0], ScheduledTaskRunLog) + + @pytest.mark.asyncio + async def test_list_by_status_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.list_by_status = AsyncMock(return_value=([_make_run_log_orm()], 1)) + # Act + logs, total = await repo.list_by_status(status="running", page=1, page_size=20) + # Assert + assert len(logs) == 1 + assert total == 1 + + @pytest.mark.asyncio + async def test_get_running_by_task_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get_running_by_task = AsyncMock(return_value=_make_run_log_orm(status="running")) + # Act + result = await repo.get_running_by_task("task-001") + # Assert + assert isinstance(result, ScheduledTaskRunLog) + assert result.status == "running" + + @pytest.mark.asyncio + async def test_count_running_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.count_running = AsyncMock(return_value=3) + # Act + result = await repo.count_running() + # Assert + assert result == 3 + + @pytest.mark.asyncio + async def test_reclaim_stale_runs_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.reclaim_stale_runs = AsyncMock(return_value=5) + stale_before = datetime(2024, 1, 1, 0, 0, 0) + # Act + result = await repo.reclaim_stale_runs(stale_before=stale_before) + # Assert + mock_orm.reclaim_stale_runs.assert_awaited_once_with( + stale_before=stale_before, + error_message="reclaimed by scheduler restart", + commit=True, + ) + assert result == 5 + + @pytest.mark.asyncio + async def test_cleanup_old_logs_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.cleanup_old_logs = AsyncMock(return_value=10) + before = datetime(2024, 1, 1, 0, 0, 0) + # Act + result = await repo.cleanup_old_logs(before, commit=False) + # Assert + mock_orm.cleanup_old_logs.assert_awaited_once_with(before, commit=False) + assert result == 10 + + +# ─── SqlAlchemyRunLogDailyRepository ────────────────────────────────────── + + +@pytest.mark.unit +class TestSqlAlchemyRunLogDailyRepository: + @pytest.fixture + def repo_with_mock(self): + db = MagicMock() + with patch( + "yuxi.scheduler.adapters.persistence.sqlalchemy_run_log_daily_repo.ORMScheduledTaskRunLogDailyRepository" + ) as mock_cls: + mock_orm_repo = AsyncMock() + mock_cls.return_value = mock_orm_repo + repo = SqlAlchemyRunLogDailyRepository(db) + return repo, mock_orm_repo + + @pytest.mark.asyncio + async def test_upsert_daily_stat_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.upsert_daily_stat = AsyncMock(return_value=None) + stat_date = date(2024, 1, 1) + # Act + await repo.upsert_daily_stat(stat_date, "h", "success", commit=False) + # Assert + mock_orm.upsert_daily_stat.assert_awaited_once_with( + stat_date, "h", "success", commit=False + ) + + @pytest.mark.asyncio + async def test_list_daily_stat_delegates_and_maps(self, repo_with_mock): + repo, mock_orm = repo_with_mock + row = SimpleNamespace( + stat_date=date(2024, 1, 1), + handler_name="h", + success_count=10, + failure_count=2, + timeout_count=1, + dead_letter_count=0, + ) + mock_orm.list_daily_stat = AsyncMock(return_value=[row]) + # Act + result = await repo.list_daily_stat( + start_date=date(2024, 1, 1), end_date=date(2024, 1, 31) + ) + # Assert + assert len(result) == 1 + assert isinstance(result[0], DailyStat) + assert result[0].success_count == 10 + + @pytest.mark.asyncio + async def test_cleanup_old_daily_stats_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.cleanup_old_daily_stats = AsyncMock(return_value=5) + before = date(2023, 1, 1) + # Act + result = await repo.cleanup_old_daily_stats(before, commit=False) + # Assert + mock_orm.cleanup_old_daily_stats.assert_awaited_once_with(before, commit=False) + assert result == 5 + + @pytest.mark.asyncio + async def test_get_deleted_by_id_passes_through_any(self, repo_with_mock): + # Arrange - core 层无对应 dataclass,按端口契约以 Any 透传 ORM + repo, mock_orm = repo_with_mock + orm_obj = SimpleNamespace(id=1) + mock_orm.get_deleted_by_id = AsyncMock(return_value=orm_obj) + # Act + result = await repo.get_deleted_by_id(1) + # Assert + assert result is orm_obj + + @pytest.mark.asyncio + async def test_list_deleted_passes_through_any(self, repo_with_mock): + repo, mock_orm = repo_with_mock + orm_list = [SimpleNamespace(id=1), SimpleNamespace(id=2)] + mock_orm.list_deleted = AsyncMock(return_value=orm_list) + # Act + result = await repo.list_deleted(limit=10) + # Assert + assert result == orm_list + + +# ─── SqlAlchemyIdempotencyRepository ────────────────────────────────────── + + +@pytest.mark.unit +class TestSqlAlchemyIdempotencyRepository: + @pytest.fixture + def repo_with_mock(self): + db = MagicMock() + with patch( + "yuxi.scheduler.adapters.persistence.sqlalchemy_idempotency_repo.ORMScheduledTaskIdempotencyRepository" + ) as mock_cls: + mock_orm_repo = AsyncMock() + mock_cls.return_value = mock_orm_repo + repo = SqlAlchemyIdempotencyRepository(db) + return repo, mock_orm_repo + + @pytest.mark.asyncio + async def test_acquire_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.acquire = AsyncMock(return_value=True) + # Act + result = await repo.acquire("key-001", task_id="t1", operation="run") + # Assert + mock_orm.acquire.assert_awaited_once_with("key-001", task_id="t1", operation="run") + assert result is True + + @pytest.mark.asyncio + async def test_update_response_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.update_response = AsyncMock(return_value=None) + # Act + await repo.update_response("key-001", {"result": "ok"}) + # Assert + mock_orm.update_response.assert_awaited_once_with("key-001", {"result": "ok"}) + + @pytest.mark.asyncio + async def test_get_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get = AsyncMock(return_value={"result": "ok"}) + # Act + result = await repo.get("key-001") + # Assert + mock_orm.get.assert_awaited_once_with("key-001") + assert result == {"result": "ok"} + + @pytest.mark.asyncio + async def test_get_returns_none_when_not_found(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.get = AsyncMock(return_value=None) + # Act + result = await repo.get("nonexistent") + # Assert + assert result is None + + @pytest.mark.asyncio + async def test_cleanup_old_delegates(self, repo_with_mock): + repo, mock_orm = repo_with_mock + mock_orm.cleanup_old = AsyncMock(return_value=20) + before = datetime(2024, 1, 1, 0, 0, 0) + # Act + result = await repo.cleanup_old(before, commit=False) + # Assert + mock_orm.cleanup_old.assert_awaited_once_with(before, commit=False) + assert result == 20 + + @pytest.mark.asyncio + async def test_get_deleted_by_id_passes_through_any(self, repo_with_mock): + # Arrange - core 层无对应 dataclass,按端口契约以 Any 透传 ORM + repo, mock_orm = repo_with_mock + orm_obj = SimpleNamespace(id=1) + mock_orm.get_deleted_by_id = AsyncMock(return_value=orm_obj) + # Act + result = await repo.get_deleted_by_id(1) + # Assert + assert result is orm_obj diff --git a/backend/test/unit/scheduler/adapters/persistence/test_unit_of_work.py b/backend/test/unit/scheduler/adapters/persistence/test_unit_of_work.py new file mode 100644 index 00000000..ae23827b --- /dev/null +++ b/backend/test/unit/scheduler/adapters/persistence/test_unit_of_work.py @@ -0,0 +1,113 @@ +"""SqlAlchemyUnitOfWork 单元测试。 + +覆盖 ``yuxi.scheduler.adapters.persistence.unit_of_work`` 模块的 +``SqlAlchemyUnitOfWork``: +- ``__aenter__`` 返回 self +- ``__aexit__`` 正常退出调用 commit,异常退出调用 rollback +- ``commit`` / ``rollback`` 委托给 db session +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.scheduler.adapters.persistence.unit_of_work import SqlAlchemyUnitOfWork + + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def db_session() -> MagicMock: + """提供 MagicMock 模拟 AsyncSession。""" + session = MagicMock() + session.commit = AsyncMock() + session.rollback = AsyncMock() + return session + + +# ─── __aenter__ / __aexit__ ─────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSqlAlchemyUnitOfWorkContext: + @pytest.mark.asyncio + async def test_aenter_returns_self(self, db_session: MagicMock): + # Arrange + uow = SqlAlchemyUnitOfWork(db_session) + # Act + async with uow as ctx: + # Assert - __aenter__ 返回 self + assert ctx is uow + + @pytest.mark.asyncio + async def test_aexit_without_exception_commits(self, db_session: MagicMock): + # Arrange + uow = SqlAlchemyUnitOfWork(db_session) + # Act + async with uow: + pass + # Assert - 正常退出调用 commit + db_session.commit.assert_awaited_once() + db_session.rollback.assert_not_awaited() + + @pytest.mark.asyncio + async def test_aexit_with_exception_rolls_back(self, db_session: MagicMock): + # Arrange + uow = SqlAlchemyUnitOfWork(db_session) + # Act & Assert - 异常退出调用 rollback + with pytest.raises(RuntimeError, match="boom"): + async with uow: + raise RuntimeError("boom") + db_session.rollback.assert_awaited_once() + db_session.commit.assert_not_awaited() + + @pytest.mark.asyncio + async def test_aexit_with_keyboard_interrupt_rolls_back(self, db_session: MagicMock): + # Arrange - 任意异常类型都应触发 rollback + uow = SqlAlchemyUnitOfWork(db_session) + # Act & Assert + with pytest.raises(KeyboardInterrupt): + async with uow: + raise KeyboardInterrupt() + db_session.rollback.assert_awaited_once() + + +# ─── commit / rollback ──────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSqlAlchemyUnitOfWorkCommitRollback: + @pytest.mark.asyncio + async def test_commit_delegates_to_session(self, db_session: MagicMock): + # Arrange + uow = SqlAlchemyUnitOfWork(db_session) + # Act + await uow.commit() + # Assert + db_session.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_rollback_delegates_to_session(self, db_session: MagicMock): + # Arrange + uow = SqlAlchemyUnitOfWork(db_session) + # Act + await uow.rollback() + # Assert + db_session.rollback.assert_awaited_once() + + +# ─── 满足 UnitOfWork Protocol ───────────────────────────────────────────── + + +@pytest.mark.unit +class TestSqlAlchemyUnitOfWorkProtocol: + def test_satisfies_unit_of_work_protocol(self, db_session: MagicMock): + # Arrange & Act + from yuxi.scheduler.core.contracts import UnitOfWork + + uow = SqlAlchemyUnitOfWork(db_session) + # Assert - 结构化实现 UnitOfWork Protocol,runtime_checkable 允许 isinstance + assert isinstance(uow, UnitOfWork) diff --git a/backend/test/unit/scheduler/conftest.py b/backend/test/unit/scheduler/conftest.py new file mode 100644 index 00000000..b90e5a82 --- /dev/null +++ b/backend/test/unit/scheduler/conftest.py @@ -0,0 +1,232 @@ +"""scheduler 限界上下文单元测试共享 fixture。 + +本文件仅放置跨多个测试文件复用的 fixture 与工厂函数。单文件独有的 helper +应保留在对应测试文件内,避免过度集中(见 testing-guidelines.md)。 +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from yuxi.scheduler.core.models import ( + DailyStat, + HandlerSummary, + ScheduledTask, + ScheduledTaskRunLog, +) + + +# ─── 工厂函数 ───────────────────────────────────────────────────────────── + + +@pytest.fixture +def make_task() -> type[ScheduledTask]: + """返回 ScheduledTask 工厂类型,调用时按需覆盖字段。 + + 用法:: + + task = make_task(task_id="abc", status="paused") + """ + + def _build(**overrides: Any) -> ScheduledTask: + defaults: dict[str, Any] = { + "task_id": "task-001", + "handler_name": "test_handler", + "owner_scope": "system", + "owner_id": "test:owner-1", + "schedule_kind": "cron", + "cron_expression": "*/15 * * * *", + "tz": "Asia/Shanghai", + "payload": {"key": "value"}, + "enabled": True, + "delete_after_run": False, + "block_strategy": "discard_later", + "stagger_seconds": 30, + "consecutive_errors": 0, + "status": "active", + "next_run_at": datetime(2024, 1, 1, 0, 15, 0), + "id": 1, + } + defaults.update(overrides) + return ScheduledTask(**defaults) + + return _build + + +@pytest.fixture +def make_run_log() -> type[ScheduledTaskRunLog]: + """返回 ScheduledTaskRunLog 工厂类型。""" + + def _build(**overrides: Any) -> ScheduledTaskRunLog: + defaults: dict[str, Any] = { + "task_id": "task-001", + "run_id": "run-001", + "triggered_by": "auto", + "status": "running", + "started_at": datetime(2024, 1, 1, 0, 15, 0), + "id": 1, + } + defaults.update(overrides) + return ScheduledTaskRunLog(**defaults) + + return _build + + +@pytest.fixture +def make_handler_summary() -> type[HandlerSummary]: + """返回 HandlerSummary 工厂类型。""" + + def _build(**overrides: Any) -> HandlerSummary: + defaults: dict[str, Any] = { + "name": "test_handler", + "task_count": 5, + "last_active_at": datetime(2024, 1, 1, 0, 15, 0), + } + defaults.update(overrides) + return HandlerSummary(**defaults) + + return _build + + +@pytest.fixture +def make_daily_stat() -> type[DailyStat]: + """返回 DailyStat 工厂类型。""" + + def _build(**overrides: Any) -> DailyStat: + defaults: dict[str, Any] = { + "stat_date": datetime(2024, 1, 1).date(), + "handler_name": "test_handler", + "success_count": 10, + "failure_count": 2, + "timeout_count": 1, + "dead_letter_count": 0, + } + defaults.update(overrides) + return DailyStat(**defaults) + + return _build + + +# ─── Stub 端口实现 ───────────────────────────────────────────────────────── + + +class FakeRepositories: + """内存版 Repositories 桩,所有方法均为 AsyncMock。 + + 通过 ``task`` / ``run_log`` / ``run_log_daily`` / ``idempotency`` 属性 + 访问各仓储桩,可按需配置 ``return_value`` / ``side_effect``。 + """ + + def __init__(self) -> None: + self.task = AsyncMock() + self.run_log = AsyncMock() + self.run_log_daily = AsyncMock() + self.idempotency = AsyncMock() + + +class FakeRuntimeServices: + """内存版 RuntimeServices 桩。 + + - ``handler_registry``:``MagicMock`` 或 None(模拟 api 进程) + - ``arq_pool``:``AsyncMock`` 或 None(模拟 api 进程) + - ``config``:``MagicMock``,按需配置 ``scheduler_*`` 字段 + """ + + def __init__(self) -> None: + self.handler_registry = MagicMock() + self.arq_pool = AsyncMock() + # 默认配置与 app.py 默认值对齐 + self.config = MagicMock() + self.config.scheduler_max_payload_size_kb = 64 + self.config.scheduler_min_cron_interval_minutes = 10 + self.config.scheduler_stagger_max_seconds = 300 + self.config.scheduler_backoff_schedule = [10, 30, 120, 600, 3600] + self.config.scheduler_max_consecutive_errors = 5 + self.config.scheduler_task_timeout_seconds = 600 + self.config.scheduler_run_log_retention_days = 90 + self.config.scheduler_idempotency_retention_hours = 24 + + +class FakeUnitOfWork: + """内存版 UnitOfWork 桩,记录 commit / rollback 调用。 + + ``async with`` 进入时返回 self,正常退出调用 ``commit``,异常退出调用 + ``rollback``,与 ``SqlAlchemyUnitOfWork`` 行为一致。 + """ + + def __init__(self) -> None: + self.committed = False + self.rolled_back = False + self.enter_count = 0 + self.exit_count = 0 + + async def __aenter__(self) -> FakeUnitOfWork: + self.enter_count += 1 + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: + self.exit_count += 1 + if exc_type is not None: + self.rolled_back = True + else: + self.committed = True + + async def commit(self) -> None: + self.committed = True + + async def rollback(self) -> None: + self.rolled_back = True + + +@pytest.fixture +def fake_repos() -> FakeRepositories: + """提供内存版 Repositories 桩。""" + return FakeRepositories() + + +@pytest.fixture +def fake_runtime() -> FakeRuntimeServices: + """提供内存版 RuntimeServices 桩。""" + return FakeRuntimeServices() + + +@pytest.fixture +def fake_uow() -> FakeUnitOfWork: + """提供内存版 UnitOfWork 桩。""" + return FakeUnitOfWork() + + +@pytest.fixture +def fixed_now(monkeypatch: pytest.MonkeyPatch) -> datetime: + """固定 UTC naive 时间,用于时间相关测试的稳定基准。 + + 同时 monkeypatch ``scheduler_service`` 模块内的 ``utc_now_naive``, + 使被测服务消费到同一固定时间。 + """ + fixed = datetime(2024, 6, 1, 0, 0, 0) + import yuxi.scheduler.use_cases.services.scheduler_service as svc_mod + + monkeypatch.setattr(svc_mod, "utc_now_naive", lambda: fixed) + return fixed + + +@pytest.fixture +def fixed_uuid(monkeypatch: pytest.MonkeyPatch) -> str: + """固定 ``uuid.uuid4().hex`` 返回值,保证 task_id / run_id 可重复。 + + 被 ``scheduler_service`` 模块内的 ``uuid.uuid4`` 调用消费。 + """ + fixed_hex = "fixed-uuid-hex-001" + + class _FakeUUID: + hex = fixed_hex + + monkeypatch.setattr( + "yuxi.scheduler.use_cases.services.scheduler_service.uuid.uuid4", + lambda: _FakeUUID(), + ) + return fixed_hex diff --git a/backend/test/unit/scheduler/core/test_contracts.py b/backend/test/unit/scheduler/core/test_contracts.py new file mode 100644 index 00000000..da963b75 --- /dev/null +++ b/backend/test/unit/scheduler/core/test_contracts.py @@ -0,0 +1,189 @@ +"""core 层跨层端口契约单元测试。 + +覆盖 ``yuxi.scheduler.core.contracts`` 模块: +- ``TaskContext``:handler 执行上下文 dataclass +- ``TaskResult``:handler 执行结果 dataclass +- ``TaskHandler``:业务域回调 Protocol(runtime_checkable) +- ``UnitOfWork``:工作单元 Protocol(runtime_checkable) +""" + +from __future__ import annotations + +from typing import Any + +import pytest + +from yuxi.scheduler.core.contracts import ( + TaskContext, + TaskHandler, + TaskResult, + UnitOfWork, +) + + +pytestmark = pytest.mark.unit + + +# ─── TaskContext ────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestTaskContext: + def test_required_fields_must_be_provided(self): + with pytest.raises(TypeError): + TaskContext() # type: ignore[call-arg] + + def test_fields_assigned(self): + # Arrange & Act + ctx = TaskContext( + task_id="task-001", + run_id="run-001", + handler_name="my_handler", + payload={"key": "value"}, + triggered_by="auto", + scheduled_at="2024-01-01T00:00:00", + ) + # Assert + assert ctx.task_id == "task-001" + assert ctx.run_id == "run-001" + assert ctx.handler_name == "my_handler" + assert ctx.payload == {"key": "value"} + assert ctx.triggered_by == "auto" + assert ctx.scheduled_at == "2024-01-01T00:00:00" + + def test_payload_accepts_empty_dict(self): + ctx = TaskContext( + task_id="t", + run_id="r", + handler_name="h", + payload={}, + triggered_by="auto", + scheduled_at=None, + ) + assert ctx.payload == {} + + +# ─── TaskResult ─────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestTaskResult: + def test_success_required(self): + with pytest.raises(TypeError): + TaskResult() # type: ignore[call-arg] + + def test_success_only_defaults(self): + # Arrange & Act + result = TaskResult(success=True) + # Assert + assert result.success is True + assert result.error is None + assert result.output == {} + + def test_failure_with_error(self): + # Arrange & Act + result = TaskResult(success=False, error="boom") + # Assert + assert result.success is False + assert result.error == "boom" + assert result.output == {} + + def test_output_default_is_isolated_per_instance(self): + # Arrange - 可变默认值必须 per-instance 隔离 + r1 = TaskResult(success=True) + r2 = TaskResult(success=True) + # Act + r1.output["key"] = "value" + # Assert + assert r2.output == {} + + def test_output_with_data(self): + result = TaskResult(success=True, output={"rows": 10}) + assert result.output == {"rows": 10} + + +# ─── TaskHandler Protocol ───────────────────────────────────────────────── + + +class _ValidHandler: + """符合 TaskHandler Protocol 的测试 handler。""" + + name = "valid_handler" + description = "A test handler" + + async def execute(self, ctx: TaskContext) -> TaskResult: + return TaskResult(success=True) + + +class _MissingNameHandler: + """缺少 name 属性,不符合 TaskHandler Protocol。""" + + description = "no name" + + async def execute(self, ctx: TaskContext) -> TaskResult: + return TaskResult(success=True) + + +@pytest.mark.unit +class TestTaskHandlerProtocol: + def test_valid_handler_satisfies_protocol(self): + # Arrange & Act & Assert - runtime_checkable 允许 isinstance 检查 + handler = _ValidHandler() + assert isinstance(handler, TaskHandler) + + def test_handler_without_name_does_not_satisfy_protocol(self): + # Arrange & Act & Assert - 缺少 name 属性不满足 Protocol + handler = _MissingNameHandler() + assert not isinstance(handler, TaskHandler) + + +# ─── UnitOfWork Protocol ────────────────────────────────────────────────── + + +class _ValidUnitOfWork: + """符合 UnitOfWork Protocol 的测试实现。""" + + async def __aenter__(self) -> _ValidUnitOfWork: + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + pass + + async def commit(self) -> None: + pass + + async def rollback(self) -> None: + pass + + +class _MissingCommitUnitOfWork: + """缺少 commit 方法,不符合 UnitOfWork Protocol。""" + + async def __aenter__(self) -> _MissingCommitUnitOfWork: + return self + + async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None: + pass + + async def rollback(self) -> None: + pass + + +@pytest.mark.unit +class TestUnitOfWorkProtocol: + def test_valid_uow_satisfies_protocol(self): + uow = _ValidUnitOfWork() + assert isinstance(uow, UnitOfWork) + + def test_uow_without_commit_does_not_satisfy_protocol(self): + uow = _MissingCommitUnitOfWork() + assert not isinstance(uow, UnitOfWork) + + @pytest.mark.asyncio + async def test_uow_async_context_manager_protocol(self): + # Arrange + uow = _ValidUnitOfWork() + # Act + async with uow as ctx: + assert ctx is uow + # Assert - 无异常即通过 diff --git a/backend/test/unit/scheduler/core/test_exceptions.py b/backend/test/unit/scheduler/core/test_exceptions.py new file mode 100644 index 00000000..e6b89c8a --- /dev/null +++ b/backend/test/unit/scheduler/core/test_exceptions.py @@ -0,0 +1,310 @@ +"""scheduler 异常层次单元测试。 + +覆盖 ``yuxi.scheduler.exceptions`` 模块的全部异常类,验证: +- 异常继承关系(``SchedulerError`` 根异常 + 分层子异常) +- ``status_code`` 类级属性默认值与覆盖 +- ``message`` / ``details`` 实例属性 +- ``__str__`` 返回 ``message`` +- ``SchedulerValidationError`` 同时继承 ``ValueError`` +""" + +from __future__ import annotations + +import pytest + +from yuxi.scheduler.exceptions import ( + DuplicateHandlerError, + HandlerNotFoundError, + InvalidCronExpressionError, + SchedulerConflictError, + SchedulerEntityNotFoundError, + SchedulerError, + SchedulerPayloadTooLargeError, + SchedulerPermissionDeniedError, + SchedulerValidationError, + TaskStatusTransitionError, +) + + +pytestmark = pytest.mark.unit + + +# ─── SchedulerError 根异常 ──────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerError: + def test_default_status_code_is_500(self): + # Arrange & Act + err = SchedulerError("boom") + # Assert + assert err.status_code == 500 + + def test_message_attribute(self): + # Arrange & Act + err = SchedulerError("boom") + # Assert + assert err.message == "boom" + + def test_details_defaults_to_empty_dict(self): + # Arrange & Act + err = SchedulerError("boom") + # Assert + assert err.details == {} + + def test_details_passed_through(self): + # Arrange + details = {"task_id": "abc", "reason": "test"} + # Act + err = SchedulerError("boom", details=details) + # Assert + assert err.details == details + + def test_str_returns_message(self): + # Arrange + err = SchedulerError("boom") + # Act & Assert + assert str(err) == "boom" + + def test_is_exception_subclass(self): + # Arrange & Act & Assert + assert issubclass(SchedulerError, Exception) + + +# ─── SchedulerValidationError ───────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerValidationError: + def test_status_code_is_400(self): + err = SchedulerValidationError("invalid") + assert err.status_code == 400 + + def test_inherits_value_error(self): + # Arrange & Act & Assert - 兼容 Pydantic field_validator 习惯 + assert issubclass(SchedulerValidationError, ValueError) + + def test_inherits_scheduler_error(self): + assert issubclass(SchedulerValidationError, SchedulerError) + + def test_can_be_raised_as_value_error(self): + # Arrange & Act & Assert - 在 dataclass 薄校验中可直接 raise + with pytest.raises(ValueError): + raise SchedulerValidationError("invalid") + + +# ─── InvalidCronExpressionError ─────────────────────────────────────────── + + +@pytest.mark.unit +class TestInvalidCronExpressionError: + def test_status_code_is_400(self): + err = InvalidCronExpressionError("bad cron") + assert err.status_code == 400 + + def test_inherits_scheduler_validation_error(self): + assert issubclass(InvalidCronExpressionError, SchedulerValidationError) + + def test_details_carry_expression(self): + err = InvalidCronExpressionError( + "bad cron", + details={"expression": "* * * * *", "reason": "too frequent"}, + ) + assert err.details["expression"] == "* * * * *" + + +# ─── SchedulerPayloadTooLargeError ──────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerPayloadTooLargeError: + def test_status_code_is_413(self): + err = SchedulerPayloadTooLargeError("too big") + assert err.status_code == 413 + + def test_inherits_scheduler_validation_error(self): + assert issubclass(SchedulerPayloadTooLargeError, SchedulerValidationError) + + def test_details_carry_size_and_limit(self): + err = SchedulerPayloadTooLargeError( + "too big", + details={"size": 102400, "limit": 65536, "unit": "bytes"}, + ) + assert err.details["size"] == 102400 + assert err.details["limit"] == 65536 + + +# ─── HandlerNotFoundError ───────────────────────────────────────────────── + + +@pytest.mark.unit +class TestHandlerNotFoundError: + def test_status_code_is_400(self): + # Arrange & Act - 语义上属于"调度配置校验失败",按 spec 归入 ValidationError + err = HandlerNotFoundError("not found") + # Assert + assert err.status_code == 400 + + def test_inherits_scheduler_validation_error(self): + assert issubclass(HandlerNotFoundError, SchedulerValidationError) + + def test_details_carry_handler_key(self): + err = HandlerNotFoundError( + "not found", + details={"handler_key": "my_handler"}, + ) + assert err.details["handler_key"] == "my_handler" + + +# ─── SchedulerEntityNotFoundError ───────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerEntityNotFoundError: + def test_status_code_is_404(self): + err = SchedulerEntityNotFoundError("not exist") + assert err.status_code == 404 + + def test_inherits_scheduler_error(self): + assert issubclass(SchedulerEntityNotFoundError, SchedulerError) + + def test_details_carry_entity_type(self): + err = SchedulerEntityNotFoundError( + "not exist", + details={"entity_type": "task", "identifier": "task-001"}, + ) + assert err.details["entity_type"] == "task" + + +# ─── SchedulerPermissionDeniedError ────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerPermissionDeniedError: + def test_status_code_is_403(self): + err = SchedulerPermissionDeniedError("forbidden") + assert err.status_code == 403 + + def test_inherits_scheduler_error(self): + assert issubclass(SchedulerPermissionDeniedError, SchedulerError) + + +# ─── SchedulerConflictError ─────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerConflictError: + def test_status_code_is_409(self): + err = SchedulerConflictError("conflict") + assert err.status_code == 409 + + def test_inherits_scheduler_error(self): + assert issubclass(SchedulerConflictError, SchedulerError) + + +# ─── DuplicateHandlerError ──────────────────────────────────────────────── + + +@pytest.mark.unit +class TestDuplicateHandlerError: + def test_status_code_is_409(self): + err = DuplicateHandlerError("dup") + assert err.status_code == 409 + + def test_inherits_scheduler_conflict_error(self): + assert issubclass(DuplicateHandlerError, SchedulerConflictError) + + def test_details_carry_handler_key(self): + err = DuplicateHandlerError( + "dup", + details={"handler_key": "my_handler", "existing_class": "MyHandler"}, + ) + assert err.details["handler_key"] == "my_handler" + + +# ─── TaskStatusTransitionError ──────────────────────────────────────────── + + +@pytest.mark.unit +class TestTaskStatusTransitionError: + def test_status_code_is_409(self): + err = TaskStatusTransitionError("bad transition") + assert err.status_code == 409 + + def test_inherits_scheduler_conflict_error(self): + assert issubclass(TaskStatusTransitionError, SchedulerConflictError) + + def test_details_carry_state_info(self): + err = TaskStatusTransitionError( + "bad transition", + details={ + "from_state": "succeeded", + "to_state": "running", + "allowed_transitions": [], + }, + ) + assert err.details["from_state"] == "succeeded" + assert err.details["to_state"] == "running" + + +# ─── 异常层次关系 ───────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestExceptionHierarchy: + def test_all_exceptions_inherit_scheduler_error(self): + # Arrange - 所有子异常都应继承根异常,最外层统一捕获 + subclasses = [ + SchedulerValidationError, + InvalidCronExpressionError, + SchedulerPayloadTooLargeError, + HandlerNotFoundError, + SchedulerEntityNotFoundError, + SchedulerPermissionDeniedError, + SchedulerConflictError, + DuplicateHandlerError, + TaskStatusTransitionError, + ] + # Act & Assert + for cls in subclasses: + assert issubclass(cls, SchedulerError), f"{cls.__name__} 未继承 SchedulerError" + + def test_validation_error_subclasses_inherit_value_error(self): + # Arrange - SchedulerValidationError 同时继承 ValueError,其子类也应继承 + subclasses = [ + InvalidCronExpressionError, + SchedulerPayloadTooLargeError, + HandlerNotFoundError, + ] + # Act & Assert + for cls in subclasses: + assert issubclass(cls, ValueError), f"{cls.__name__} 未继承 ValueError" + + def test_conflict_error_subclasses_inherit_scheduler_conflict_error(self): + # Arrange + subclasses = [DuplicateHandlerError, TaskStatusTransitionError] + # Act & Assert + for cls in subclasses: + assert issubclass(cls, SchedulerConflictError) + + def test_exception_preserves_cause_chain(self): + # Arrange - 异常链(__cause__)应被保留,便于调试 + original = ValueError("original cause") + # Act + try: + try: + raise original + except ValueError as e: + raise SchedulerValidationError("wrapped") from e + except SchedulerValidationError as wrapped: + # Assert + assert wrapped.__cause__ is original + + def test_exception_details_not_shared_across_instances(self): + # Arrange - details 字典不应在实例间共享(避免可变默认值陷阱) + err1 = SchedulerError("a", details={"key": "v1"}) + err2 = SchedulerError("b", details={"key": "v2"}) + # Act & Assert + assert err1.details is not err2.details + assert err1.details["key"] == "v1" + assert err2.details["key"] == "v2" diff --git a/backend/test/unit/scheduler/core/test_models.py b/backend/test/unit/scheduler/core/test_models.py new file mode 100644 index 00000000..057d0779 --- /dev/null +++ b/backend/test/unit/scheduler/core/test_models.py @@ -0,0 +1,306 @@ +"""core 层领域数据类单元测试。 + +覆盖 ``yuxi.scheduler.core.models`` 模块的全部 dataclass: +- ``ScheduledTask``:定时任务领域模型 +- ``ScheduledTaskRunLog``:执行明细日志领域模型 +- ``HandlerSummary``:handler 聚合摘要 +- ``DailyStat``:日聚合统计 + +验证字段默认值、可变默认值隔离、字段类型与 ORM 对齐。 +""" + +from __future__ import annotations + +from dataclasses import fields + +import pytest + +from yuxi.scheduler.core.models import ( + DailyStat, + HandlerSummary, + ScheduledTask, + ScheduledTaskRunLog, +) + + +pytestmark = pytest.mark.unit + + +# ─── ScheduledTask ──────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestScheduledTask: + def test_required_fields_must_be_provided(self): + # Arrange & Act & Assert - task_id / handler_name / owner_scope / owner_id / schedule_kind 必填 + with pytest.raises(TypeError): + ScheduledTask() # type: ignore[call-arg] + + def test_required_fields_assigned(self): + # Arrange & Act + task = ScheduledTask( + task_id="task-001", + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="cron", + ) + # Assert + assert task.task_id == "task-001" + assert task.handler_name == "my_handler" + assert task.owner_scope == "system" + assert task.owner_id == "test:1" + assert task.schedule_kind == "cron" + + def test_default_values(self): + # Arrange & Act + task = ScheduledTask( + task_id="t1", + handler_name="h", + owner_scope="system", + owner_id="o", + schedule_kind="cron", + ) + # Assert - 默认值与 ORM nullable/default 对齐 + assert task.cron_expression is None + assert task.run_at is None + assert task.tz == "Asia/Shanghai" + assert task.payload == {} + assert task.enabled is True + assert task.delete_after_run is False + assert task.block_strategy == "discard_later" + assert task.stagger_seconds is None + assert task.consecutive_errors == 0 + assert task.status == "active" + assert task.last_run_at is None + assert task.next_run_at is None + assert task.last_error is None + assert task.created_by == "system" + assert task.updated_by is None + assert task.id is None + assert task.created_at is None + assert task.updated_at is None + assert task.is_deleted == 0 + assert task.deleted_at is None + + def test_payload_default_is_isolated_per_instance(self): + # Arrange - 可变默认值必须 per-instance 隔离 + task1 = ScheduledTask( + task_id="t1", handler_name="h", owner_scope="s", owner_id="o", schedule_kind="cron" + ) + task2 = ScheduledTask( + task_id="t2", handler_name="h", owner_scope="s", owner_id="o", schedule_kind="cron" + ) + # Act + task1.payload["key"] = "value" + # Assert - 修改 task1.payload 不应影响 task2.payload + assert task2.payload == {} + + def test_field_overrides(self): + # Arrange & Act + task = ScheduledTask( + task_id="t1", + handler_name="h", + owner_scope="business", + owner_id="o", + schedule_kind="at", + cron_expression=None, + run_at="2024-01-01T00:00:00", + tz="UTC", + payload={"k": "v"}, + enabled=False, + delete_after_run=True, + block_strategy="discard_later", + stagger_seconds=120, + consecutive_errors=3, + status="dead_letter", + created_by="admin", + id=42, + ) + # Assert + assert task.schedule_kind == "at" + assert task.run_at == "2024-01-01T00:00:00" + assert task.tz == "UTC" + assert task.payload == {"k": "v"} + assert task.enabled is False + assert task.delete_after_run is True + assert task.stagger_seconds == 120 + assert task.consecutive_errors == 3 + assert task.status == "dead_letter" + assert task.created_by == "admin" + assert task.id == 42 + + def test_field_names_align_with_orm(self): + # Arrange & Act + field_names = {f.name for f in fields(ScheduledTask)} + # Assert - 关键字段必须存在(与 ORM ScheduledTask 对齐) + expected = { + "task_id", + "handler_name", + "owner_scope", + "owner_id", + "schedule_kind", + "cron_expression", + "run_at", + "tz", + "payload", + "enabled", + "delete_after_run", + "block_strategy", + "stagger_seconds", + "consecutive_errors", + "status", + "last_run_at", + "next_run_at", + "last_error", + "created_by", + "updated_by", + "id", + "created_at", + "updated_at", + "is_deleted", + "deleted_at", + } + assert expected.issubset(field_names) + + +# ─── ScheduledTaskRunLog ────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestScheduledTaskRunLog: + def test_required_fields_must_be_provided(self): + # Arrange & Act & Assert - task_id / run_id 必填 + with pytest.raises(TypeError): + ScheduledTaskRunLog() # type: ignore[call-arg] + + def test_required_fields_assigned(self): + # Arrange & Act + log = ScheduledTaskRunLog(task_id="t1", run_id="r1") + # Assert + assert log.task_id == "t1" + assert log.run_id == "r1" + + def test_default_values(self): + # Arrange & Act + log = ScheduledTaskRunLog(task_id="t1", run_id="r1") + # Assert + assert log.triggered_by == "auto" + assert log.status == "running" + assert log.error_message is None + assert log.output is None + assert log.started_at is None + assert log.finished_at is None + assert log.created_by == "system" + assert log.updated_by is None + assert log.id is None + assert log.created_at is None + assert log.updated_at is None + assert log.is_deleted == 0 + assert log.deleted_at is None + + def test_field_overrides(self): + # Arrange & Act + log = ScheduledTaskRunLog( + task_id="t1", + run_id="r1", + triggered_by="manual", + status="success", + error_message=None, + output={"result": "ok"}, + started_at="2024-01-01T00:00:00", + finished_at="2024-01-01T00:00:05", + ) + # Assert + assert log.triggered_by == "manual" + assert log.status == "success" + assert log.output == {"result": "ok"} + assert log.started_at == "2024-01-01T00:00:00" + assert log.finished_at == "2024-01-01T00:00:05" + + def test_field_names_align_with_orm(self): + # Arrange & Act + field_names = {f.name for f in fields(ScheduledTaskRunLog)} + # Assert - 关键字段必须存在(与 ORM ScheduledTaskRunLog 对齐) + expected = { + "task_id", + "run_id", + "triggered_by", + "status", + "error_message", + "output", + "started_at", + "finished_at", + "created_by", + "updated_by", + "id", + "created_at", + "updated_at", + "is_deleted", + "deleted_at", + } + assert expected.issubset(field_names) + + +# ─── HandlerSummary ─────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestHandlerSummary: + def test_required_fields_must_be_provided(self): + with pytest.raises(TypeError): + HandlerSummary() # type: ignore[call-arg] + + def test_required_fields_assigned(self): + summary = HandlerSummary(name="my_handler", task_count=5) + assert summary.name == "my_handler" + assert summary.task_count == 5 + + def test_last_active_at_defaults_to_none(self): + summary = HandlerSummary(name="h", task_count=0) + assert summary.last_active_at is None + + def test_field_names_align_with_orm(self): + field_names = {f.name for f in fields(HandlerSummary)} + expected = {"name", "task_count", "last_active_at"} + assert expected.issubset(field_names) + + +# ─── DailyStat ──────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestDailyStat: + def test_required_fields_must_be_provided(self): + with pytest.raises(TypeError): + DailyStat() # type: ignore[call-arg] + + def test_required_fields_assigned(self): + # Arrange & Act + stat = DailyStat( + stat_date="2024-01-01", + handler_name="h", + success_count=10, + failure_count=2, + timeout_count=1, + dead_letter_count=0, + ) + # Assert + assert stat.stat_date == "2024-01-01" + 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_field_names_align_with_orm(self): + field_names = {f.name for f in fields(DailyStat)} + expected = { + "stat_date", + "handler_name", + "success_count", + "failure_count", + "timeout_count", + "dead_letter_count", + } + assert expected.issubset(field_names) diff --git a/backend/test/unit/scheduler/core/test_validators.py b/backend/test/unit/scheduler/core/test_validators.py new file mode 100644 index 00000000..2aefbd1c --- /dev/null +++ b/backend/test/unit/scheduler/core/test_validators.py @@ -0,0 +1,188 @@ +"""core 层校验函数单元测试。 + +覆盖 ``yuxi.scheduler.core.validators`` 模块的全部公开校验函数: +- ``validate_cron_expression``:cron 表达式语法 / strict 模式 / 最小触发间隔 +- ``validate_payload_size``:payload 序列化后字节数上限 +- ``validate_owner_scope``:非空 + 长度上限 +""" + +from __future__ import annotations + +import pytest + +from yuxi.scheduler.core.validators import ( + validate_cron_expression, + validate_owner_scope, + validate_payload_size, +) +from yuxi.scheduler.exceptions import ( + InvalidCronExpressionError, + SchedulerPayloadTooLargeError, + SchedulerValidationError, +) + + +pytestmark = pytest.mark.unit + + +# ─── validate_cron_expression ───────────────────────────────────────────── + + +@pytest.mark.unit +class TestValidateCronExpression: + @pytest.mark.parametrize( + "expr", + [ + "*/15 * * * *", # 每 15 分钟 + "0 0 * * *", # 每天凌晨 + "0 * * * *", # 每小时 + "0 0 * * 1", # 每周一凌晨 + "0 0 1 * *", # 每月 1 号凌晨 + "*/30 * * * *", # 每 30 分钟 + ], + ids=["every-15min", "daily", "hourly", "weekly", "monthly", "every-30min"], + ) + def test_valid_cron_expressions_passes(self, expr: str): + # Act - 无异常抛出即通过 + validate_cron_expression(expr) + + @pytest.mark.parametrize( + ("expr", "match"), + [ + ("", "不能为空"), + (" ", "不能为空"), + ("* * *", "cron 表达式非法"), # 字段数不足 + ("99 * * * *", "cron 表达式非法"), # 分钟字段越界 + ("invalid", "cron 表达式非法"), # 非法语法 + ], + ids=["empty", "whitespace", "too-few-fields", "field-out-of-range", "invalid-syntax"], + ) + def test_invalid_cron_expressions_raise(self, expr: str, match: str): + with pytest.raises(InvalidCronExpressionError, match=match): + validate_cron_expression(expr) + + def test_strict_mode_rejects_6_field_cron(self): + # Arrange - strict 模式禁止含秒级字段 + expr = "0 */15 * * * *" + # Act & Assert + with pytest.raises(InvalidCronExpressionError, match="strict 模式禁止含秒级字段"): + validate_cron_expression(expr, strict=True) + + def test_strict_mode_rejects_every_minute(self): + # Arrange - strict 模式禁止每分钟执行 + expr = "* * * * *" + # Act & Assert + with pytest.raises(InvalidCronExpressionError, match="strict 模式禁止每分钟执行"): + validate_cron_expression(expr, strict=True) + + def test_non_strict_mode_allows_6_field_cron(self): + # Arrange - strict=False 时不检查字段数 + # 注:croniter 对 6 字段 cron 的间隔计算为 1 秒(秒字段递增), + # 故需 min_interval_minutes=0 跳过间隔校验,仅验证 strict=False 不拒绝 6 字段 + expr = "0 */15 * * * *" + # Act - 无异常抛出即通过 + validate_cron_expression(expr, strict=False, min_interval_minutes=0) + + @pytest.mark.parametrize( + ("expr", "min_interval", "should_raise"), + [ + ("*/5 * * * *", 10, True), # 5 分钟 < 10 分钟阈值 + ("*/5 * * * *", 3, False), # 5 分钟 >= 3 分钟阈值 + ("*/5 * * * *", 0, False), # 0 跳过间隔校验 + ("*/15 * * * *", 10, False), # 15 分钟 >= 10 分钟阈值 + ("*/15 * * * *", 20, True), # 15 分钟 < 20 分钟阈值 + ], + ids=["below-default", "below-custom-ok", "zero-skips", "at-default", "below-higher"], + ) + def test_min_interval_check( + self, expr: str, min_interval: int, should_raise: bool + ): + # Act & Assert + if should_raise: + with pytest.raises(InvalidCronExpressionError, match="小于最小要求"): + validate_cron_expression(expr, min_interval_minutes=min_interval) + else: + validate_cron_expression(expr, min_interval_minutes=min_interval) + + def test_invalid_cron_raises_invalid_cron_error_subclass(self): + # Arrange & Act & Assert - 异常类型为 SchedulerValidationError 子类 + with pytest.raises(SchedulerValidationError): + validate_cron_expression("invalid") + + +# ─── validate_payload_size ──────────────────────────────────────────────── + + +@pytest.mark.unit +class TestValidatePayloadSize: + def test_small_payload_passes(self): + # Arrange + payload = {"key": "value"} + # Act - 无异常抛出即通过 + validate_payload_size(payload, max_size_kb=64) + + def test_empty_payload_passes(self): + # Arrange + payload: dict = {} + # Act + validate_payload_size(payload, max_size_kb=64) + + def test_payload_at_limit_passes(self): + # Arrange - 构造恰好 1KB 的 payload(max_size_kb=1) + # {"k": ""} 序列化为 '{"k": ""}' 共 9 字节,故 value 长度 = 1024 - 9 + payload = {"k": "a" * (1024 - 9)} # 9 = len(json.dumps({"k": ""})) 的 JSON 开销 + # Act + validate_payload_size(payload, max_size_kb=1) + + def test_payload_exceeding_limit_raises(self): + # Arrange - 构造超过 1KB 的 payload + payload = {"k": "a" * 2048} + # Act & Assert + with pytest.raises(SchedulerPayloadTooLargeError, match="payload 大小超限"): + validate_payload_size(payload, max_size_kb=1) + + def test_unicode_payload_size_calculated_by_bytes(self): + # Arrange - 中文字符 UTF-8 占 3 字节,确保按字节数而非字符数计算 + payload = {"k": "中" * 500} # 500 * 3 = 1500 bytes > 1KB + # Act & Assert + with pytest.raises(SchedulerPayloadTooLargeError): + validate_payload_size(payload, max_size_kb=1) + + def test_exceeding_payload_raises_validation_error_subclass(self): + # Arrange & Act & Assert - 异常类型为 SchedulerValidationError 子类 + with pytest.raises(SchedulerValidationError): + validate_payload_size({"k": "a" * 2048}, max_size_kb=1) + + def test_max_size_zero_rejects_any_payload(self): + # Arrange - max_size_kb=0 时任何 payload 都超限(空 dict 序列化为 2 字节) + payload = {"k": "v"} + # Act & Assert + with pytest.raises(SchedulerPayloadTooLargeError): + validate_payload_size(payload, max_size_kb=0) + + +# ─── validate_owner_scope ───────────────────────────────────────────────── + + +@pytest.mark.unit +class TestValidateOwnerScope: + @pytest.mark.parametrize( + "scope", + ["system", "business", "custom_scope", "a" * 64], + ids=["system", "business", "custom", "at-max-length"], + ) + def test_valid_scope_passes(self, scope: str): + # Act - 无异常抛出即通过 + validate_owner_scope(scope) + + @pytest.mark.parametrize( + ("scope", "match"), + [ + ("", "owner_scope 非法"), + ("a" * 65, "owner_scope 非法"), # 长度超过 64 + ], + ids=["empty", "exceeds-max-length"], + ) + def test_invalid_scope_raises(self, scope: str, match: str): + with pytest.raises(SchedulerValidationError, match=match): + validate_owner_scope(scope) diff --git a/backend/test/unit/scheduler/framework/handlers/test_idempotency_cleanup_handler.py b/backend/test/unit/scheduler/framework/handlers/test_idempotency_cleanup_handler.py new file mode 100644 index 00000000..ef99bed6 --- /dev/null +++ b/backend/test/unit/scheduler/framework/handlers/test_idempotency_cleanup_handler.py @@ -0,0 +1,214 @@ +"""清理过期幂等记录 handler 单元测试。 + +覆盖 ``yuxi.scheduler.framework.handlers.idempotency_cleanup_handler`` 模块的 +``IdempotencyCleanupHandler``: +- 正常执行:调用仓储 cleanup_old 方法并返回成功结果 +- payload 覆盖默认保留小时数 +- 异常隔离:仓储异常转换为 TaskResult(success=False) +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.scheduler.core.contracts import TaskContext +from yuxi.scheduler.framework.handlers.idempotency_cleanup_handler import ( + IdempotencyCleanupHandler, + _DEFAULT_RETENTION_HOURS, +) + + +pytestmark = pytest.mark.unit + + +# ─── 测试辅助 ───────────────────────────────────────────────────────────── + + +def _make_ctx(**payload_overrides) -> TaskContext: + """构造 TaskContext,payload 可按需覆盖。""" + return TaskContext( + task_id="task-cleanup", + run_id="run-001", + handler_name="idempotency_cleanup", + payload=payload_overrides, + triggered_by="auto", + scheduled_at=datetime(2024, 1, 1, 0, 0, 0), + ) + + +@asynccontextmanager +async def _fake_session_factory(db_mock): + """构造假的 session_factory,yield db_mock。""" + yield db_mock + + +# ─── 类属性 ─────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestIdempotencyCleanupHandlerAttributes: + def test_name_is_idempotency_cleanup(self): + assert IdempotencyCleanupHandler.name == "idempotency_cleanup" + + def test_description_is_non_empty(self): + assert IdempotencyCleanupHandler.description + assert isinstance(IdempotencyCleanupHandler.description, str) + + +# ─── execute 正常路径 ───────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestIdempotencyCleanupHandlerExecute: + @pytest.mark.asyncio + async def test_successful_cleanup_returns_success(self): + # Arrange + db = MagicMock() + repos = MagicMock() + repos.idempotency = AsyncMock() + repos.idempotency.cleanup_old = AsyncMock(return_value=42) + + handler = IdempotencyCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_hours=24, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.idempotency_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx()) + + # Assert + assert result.success is True + assert result.output["deleted_count"] == 42 + assert result.output["retention_hours"] == 24 + + @pytest.mark.asyncio + async def test_payload_retention_hours_overrides_default(self): + # Arrange - payload 指定 retention_hours=12 + db = MagicMock() + repos = MagicMock() + repos.idempotency = AsyncMock() + repos.idempotency.cleanup_old = AsyncMock(return_value=5) + + handler = IdempotencyCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_hours=24, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.idempotency_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx(retention_hours=12)) + + # Assert - 使用 payload 中的 12 小时 + assert result.output["retention_hours"] == 12 + + @pytest.mark.asyncio + async def test_default_retention_hours_used_when_payload_missing(self): + # Arrange - payload 不含 retention_hours + db = MagicMock() + repos = MagicMock() + repos.idempotency = AsyncMock() + repos.idempotency.cleanup_old = AsyncMock(return_value=0) + + handler = IdempotencyCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_hours=_DEFAULT_RETENTION_HOURS, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.idempotency_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx()) + + # Assert + assert result.output["retention_hours"] == _DEFAULT_RETENTION_HOURS + + @pytest.mark.asyncio + async def test_cleanup_old_called_with_correct_cutoff(self): + # Arrange + db = MagicMock() + repos = MagicMock() + repos.idempotency = AsyncMock() + repos.idempotency.cleanup_old = AsyncMock(return_value=0) + + handler = IdempotencyCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_hours=24, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.idempotency_cleanup_handler.create_repositories", + return_value=repos, + ), patch( + "yuxi.scheduler.framework.handlers.idempotency_cleanup_handler.utc_now_naive", + return_value=datetime(2024, 6, 1, 12, 0, 0), + ): + await handler.execute(_make_ctx()) + + # Assert - cutoff = now - 24 hours + repos.idempotency.cleanup_old.assert_awaited_once() + called_cutoff = repos.idempotency.cleanup_old.call_args[0][0] + expected_cutoff = datetime(2024, 6, 1, 12, 0, 0) - timedelta(hours=24) + assert called_cutoff == expected_cutoff + + +# ─── execute 异常路径 ───────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestIdempotencyCleanupHandlerErrors: + @pytest.mark.asyncio + async def test_repository_exception_returns_failure(self): + # Arrange - 仓储抛异常 + db = MagicMock() + repos = MagicMock() + repos.idempotency = AsyncMock() + repos.idempotency.cleanup_old = AsyncMock(side_effect=RuntimeError("db error")) + + handler = IdempotencyCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.idempotency_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx()) + + # Assert - 异常被捕获并转换为 TaskResult(success=False) + assert result.success is False + assert "db error" in (result.error or "") + + @pytest.mark.asyncio + async def test_session_factory_exception_returns_failure(self): + # Arrange - session_factory 自身抛异常 + + @asynccontextmanager + async def _broken_session_factory(): + raise RuntimeError("session creation failed") + yield # pragma: no cover + + handler = IdempotencyCleanupHandler( + session_factory=_broken_session_factory, + ) + + # Act + result = await handler.execute(_make_ctx()) + + # Assert + assert result.success is False + assert "session creation failed" in (result.error or "") diff --git a/backend/test/unit/scheduler/framework/handlers/test_run_log_cleanup_handler.py b/backend/test/unit/scheduler/framework/handlers/test_run_log_cleanup_handler.py new file mode 100644 index 00000000..d8c67c4b --- /dev/null +++ b/backend/test/unit/scheduler/framework/handlers/test_run_log_cleanup_handler.py @@ -0,0 +1,225 @@ +"""清理过期执行日志 handler 单元测试。 + +覆盖 ``yuxi.scheduler.framework.handlers.run_log_cleanup_handler`` 模块的 +``RunLogCleanupHandler``: +- 正常执行:调用仓储 cleanup 方法并返回成功结果 +- payload 覆盖默认保留天数 +- 异常隔离:仓储异常转换为 TaskResult(success=False) +- 会话隔离:通过 session_factory 创建独立会话 +""" + +from __future__ import annotations + +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from yuxi.scheduler.core.contracts import TaskContext, TaskResult +from yuxi.scheduler.framework.handlers.run_log_cleanup_handler import ( + RunLogCleanupHandler, + _DEFAULT_RETENTION_DAYS, +) + + +pytestmark = pytest.mark.unit + + +# ─── 测试辅助 ───────────────────────────────────────────────────────────── + + +def _make_ctx(**payload_overrides) -> TaskContext: + """构造 TaskContext,payload 可按需覆盖。""" + return TaskContext( + task_id="task-cleanup", + run_id="run-001", + handler_name="run_log_cleanup", + payload=payload_overrides, + triggered_by="auto", + scheduled_at=datetime(2024, 1, 1, 0, 0, 0), + ) + + +@asynccontextmanager +async def _fake_session_factory(db_mock): + """构造假的 session_factory,yield db_mock。""" + yield db_mock + + +# ─── 类属性 ─────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestRunLogCleanupHandlerAttributes: + def test_name_is_run_log_cleanup(self): + assert RunLogCleanupHandler.name == "run_log_cleanup" + + def test_description_is_non_empty(self): + assert RunLogCleanupHandler.description + assert isinstance(RunLogCleanupHandler.description, str) + + +# ─── execute 正常路径 ───────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestRunLogCleanupHandlerExecute: + @pytest.mark.asyncio + async def test_successful_cleanup_returns_success(self): + # Arrange + db = MagicMock() + repos = MagicMock() + repos.run_log = AsyncMock() + repos.run_log.cleanup_old_logs = AsyncMock(return_value=10) + repos.run_log_daily = AsyncMock() + repos.run_log_daily.cleanup_old_daily_stats = AsyncMock(return_value=5) + + handler = RunLogCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_days=30, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.run_log_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx()) + + # Assert + assert result.success is True + assert result.output["deleted_logs"] == 10 + assert result.output["deleted_stats"] == 5 + assert result.output["retention_days"] == 30 + + @pytest.mark.asyncio + async def test_payload_retention_days_overrides_default(self): + # Arrange - payload 指定 retention_days=7 + db = MagicMock() + repos = MagicMock() + repos.run_log = AsyncMock() + repos.run_log.cleanup_old_logs = AsyncMock(return_value=3) + repos.run_log_daily = AsyncMock() + repos.run_log_daily.cleanup_old_daily_stats = AsyncMock(return_value=1) + + handler = RunLogCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_days=30, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.run_log_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx(retention_days=7)) + + # Assert - 使用 payload 中的 7 天,而非默认 30 天 + assert result.output["retention_days"] == 7 + + @pytest.mark.asyncio + async def test_default_retention_days_used_when_payload_missing(self): + # Arrange - payload 不含 retention_days + db = MagicMock() + repos = MagicMock() + repos.run_log = AsyncMock() + repos.run_log.cleanup_old_logs = AsyncMock(return_value=0) + repos.run_log_daily = AsyncMock() + repos.run_log_daily.cleanup_old_daily_stats = AsyncMock(return_value=0) + + handler = RunLogCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_days=_DEFAULT_RETENTION_DAYS, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.run_log_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx()) + + # Assert + assert result.output["retention_days"] == _DEFAULT_RETENTION_DAYS + + @pytest.mark.asyncio + async def test_cleanup_old_logs_called_with_correct_cutoff(self): + # Arrange + db = MagicMock() + repos = MagicMock() + repos.run_log = AsyncMock() + repos.run_log.cleanup_old_logs = AsyncMock(return_value=0) + repos.run_log_daily = AsyncMock() + repos.run_log_daily.cleanup_old_daily_stats = AsyncMock(return_value=0) + + handler = RunLogCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + default_retention_days=90, + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.run_log_cleanup_handler.create_repositories", + return_value=repos, + ), patch( + "yuxi.scheduler.framework.handlers.run_log_cleanup_handler.utc_now_naive", + return_value=datetime(2024, 6, 1, 0, 0, 0), + ): + await handler.execute(_make_ctx()) + + # Assert - cutoff = now - 90 days + repos.run_log.cleanup_old_logs.assert_awaited_once() + called_cutoff = repos.run_log.cleanup_old_logs.call_args[0][0] + expected_cutoff = datetime(2024, 6, 1, 0, 0, 0) - timedelta(days=90) + assert called_cutoff == expected_cutoff + + +# ─── execute 异常路径 ───────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestRunLogCleanupHandlerErrors: + @pytest.mark.asyncio + async def test_repository_exception_returns_failure(self): + # Arrange - 仓储抛异常 + db = MagicMock() + repos = MagicMock() + repos.run_log = AsyncMock() + repos.run_log.cleanup_old_logs = AsyncMock(side_effect=RuntimeError("db error")) + repos.run_log_daily = AsyncMock() + + handler = RunLogCleanupHandler( + session_factory=lambda: _fake_session_factory(db), + ) + + # Act + with patch( + "yuxi.scheduler.framework.handlers.run_log_cleanup_handler.create_repositories", + return_value=repos, + ): + result = await handler.execute(_make_ctx()) + + # Assert - 异常被捕获并转换为 TaskResult(success=False) + assert result.success is False + assert "db error" in (result.error or "") + + @pytest.mark.asyncio + async def test_session_factory_exception_returns_failure(self): + # Arrange - session_factory 自身抛异常 + + @asynccontextmanager + async def _broken_session_factory(): + raise RuntimeError("session creation failed") + yield # pragma: no cover + + handler = RunLogCleanupHandler( + session_factory=_broken_session_factory, + ) + + # Act + result = await handler.execute(_make_ctx()) + + # Assert + assert result.success is False + assert "session creation failed" in (result.error or "") diff --git a/backend/test/unit/scheduler/framework/runtime/test_handler_registry.py b/backend/test/unit/scheduler/framework/runtime/test_handler_registry.py new file mode 100644 index 00000000..2c9e4661 --- /dev/null +++ b/backend/test/unit/scheduler/framework/runtime/test_handler_registry.py @@ -0,0 +1,143 @@ +"""handler 注册表单元测试。 + +覆盖 ``yuxi.scheduler.framework.runtime.handler_registry`` 模块的 ``HandlerRegistry``: +- ``register``:注册 handler,重复注册抛 ``DuplicateHandlerError`` +- ``get``:按 name 查找 handler,未命中返回 None +- ``list_names``:返回排序后的 handler 名称列表 +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pytest + +from yuxi.scheduler.core.contracts import TaskContext, TaskResult +from yuxi.scheduler.exceptions import DuplicateHandlerError +from yuxi.scheduler.framework.runtime.handler_registry import HandlerRegistry + + +pytestmark = pytest.mark.unit + + +# ─── 测试用 handler ─────────────────────────────────────────────────────── + + +@dataclass +class _FakeHandler: + """符合 TaskHandler Protocol 的测试 handler。""" + + name: str + description: str = "test handler" + + async def execute(self, ctx: TaskContext) -> TaskResult: + return TaskResult(success=True) + + +# ─── register ───────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestHandlerRegistryRegister: + def test_register_single_handler(self): + # Arrange + registry = HandlerRegistry() + handler = _FakeHandler(name="my_handler") + # Act + registry.register(handler) + # Assert + assert registry.get("my_handler") is handler + + def test_register_multiple_handlers(self): + # Arrange + registry = HandlerRegistry() + h1 = _FakeHandler(name="h1") + h2 = _FakeHandler(name="h2") + # Act + registry.register(h1) + registry.register(h2) + # Assert + assert registry.get("h1") is h1 + assert registry.get("h2") is h2 + + def test_register_duplicate_raises(self): + # Arrange - 重复注册抛 DuplicateHandlerError + registry = HandlerRegistry() + registry.register(_FakeHandler(name="my_handler")) + # Act & Assert + with pytest.raises(DuplicateHandlerError, match="already registered"): + registry.register(_FakeHandler(name="my_handler")) + + def test_register_duplicate_error_carries_handler_key(self): + # Arrange + registry = HandlerRegistry() + registry.register(_FakeHandler(name="my_handler")) + # Act & Assert + with pytest.raises(DuplicateHandlerError) as exc_info: + registry.register(_FakeHandler(name="my_handler")) + assert exc_info.value.details["handler_key"] == "my_handler" + + def test_register_uses_handler_name_attribute(self): + # Arrange - handler 唯一标识取 handler.name + registry = HandlerRegistry() + handler = _FakeHandler(name="custom_name") + # Act + registry.register(handler) + # Assert - 不能用其他字符串作为 key + assert registry.get("custom_name") is handler + assert registry.get("_FakeHandler") is None + + +# ─── get ────────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestHandlerRegistryGet: + def test_get_existing_handler(self): + # Arrange + registry = HandlerRegistry() + handler = _FakeHandler(name="my_handler") + registry.register(handler) + # Act + result = registry.get("my_handler") + # Assert + assert result is handler + + def test_get_nonexistent_returns_none(self): + # Arrange + registry = HandlerRegistry() + # Act + result = registry.get("nonexistent") + # Assert - 未命中返回 None,由调用方决定是否抛 HandlerNotFoundError + assert result is None + + def test_get_on_empty_registry_returns_none(self): + registry = HandlerRegistry() + assert registry.get("any") is None + + +# ─── list_names ─────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestHandlerRegistryListNames: + def test_empty_registry_returns_empty_list(self): + registry = HandlerRegistry() + assert registry.list_names() == [] + + def test_single_handler(self): + registry = HandlerRegistry() + registry.register(_FakeHandler(name="h1")) + assert registry.list_names() == ["h1"] + + def test_multiple_handlers_sorted(self): + # Arrange + registry = HandlerRegistry() + # 故意乱序注册 + registry.register(_FakeHandler(name="charlie")) + registry.register(_FakeHandler(name="alpha")) + registry.register(_FakeHandler(name="bravo")) + # Act + names = registry.list_names() + # Assert - 排序后返回 + assert names == ["alpha", "bravo", "charlie"] diff --git a/backend/test/unit/scheduler/infrastructure/test_container.py b/backend/test/unit/scheduler/infrastructure/test_container.py new file mode 100644 index 00000000..23269003 --- /dev/null +++ b/backend/test/unit/scheduler/infrastructure/test_container.py @@ -0,0 +1,164 @@ +"""infrastructure/container.py 装配容器单元测试。 + +覆盖 ``yuxi.scheduler.infrastructure.container`` 模块的两个 factory 函数: +- ``create_scheduler_service``:api 进程请求级 factory(handler_registry=None) +- ``create_worker_scheduler_service``:worker 进程请求级 factory(完整 runtime) + +验证装配结果 ``SchedulerService`` 实例的依赖注入正确性。 +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + +from yuxi.scheduler.framework.runtime import HandlerRegistry, RuntimeServices +from yuxi.scheduler.infrastructure.container import ( + SchedulerService, + create_scheduler_service, + create_worker_scheduler_service, +) + + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def db_session() -> MagicMock: + """提供 MagicMock 模拟 AsyncSession。""" + return MagicMock() + + +# ─── create_scheduler_service ───────────────────────────────────────────── + + +@pytest.mark.unit +class TestCreateSchedulerService: + def test_returns_scheduler_service_instance(self, db_session: MagicMock): + # Arrange & Act + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_scheduler_service(db_session) + # Assert + assert isinstance(service, SchedulerService) + + def test_handler_registry_is_none(self, db_session: MagicMock): + # Arrange & Act - api 进程 handler_registry=None + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_scheduler_service(db_session) + # Assert + assert service._runtime.handler_registry is None + + def test_arq_pool_defaults_to_none(self, db_session: MagicMock): + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_scheduler_service(db_session) + assert service._runtime.arq_pool is None + + def test_arq_pool_passed_through(self, db_session: MagicMock): + # Arrange + pool = MagicMock() + # Act + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_scheduler_service(db_session, arq_pool=pool) + # Assert + assert service._runtime.arq_pool is pool + + def test_repos_injected(self, db_session: MagicMock): + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_scheduler_service(db_session) + # Assert - 4 个仓储实例被注入 + assert service._repos.task is not None + assert service._repos.run_log is not None + assert service._repos.run_log_daily is not None + assert service._repos.idempotency is not None + + def test_uow_injected(self, db_session: MagicMock): + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_scheduler_service(db_session) + assert service._uow is not None + + def test_config_injected(self, db_session: MagicMock): + with patch("yuxi.scheduler.infrastructure.container.app_config") as mock_config: + service = create_scheduler_service(db_session) + assert service._runtime.config is mock_config + + +# ─── create_worker_scheduler_service ────────────────────────────────────── + + +@pytest.mark.unit +class TestCreateWorkerSchedulerService: + def test_returns_scheduler_service_instance(self, db_session: MagicMock): + # Arrange + registry = HandlerRegistry() + pool = MagicMock() + # Act + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_worker_scheduler_service( + db_session, handler_registry=registry, arq_pool=pool + ) + # Assert + assert isinstance(service, SchedulerService) + + def test_handler_registry_injected(self, db_session: MagicMock): + registry = HandlerRegistry() + pool = MagicMock() + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_worker_scheduler_service( + db_session, handler_registry=registry, arq_pool=pool + ) + assert service._runtime.handler_registry is registry + + def test_arq_pool_injected(self, db_session: MagicMock): + registry = HandlerRegistry() + pool = MagicMock() + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_worker_scheduler_service( + db_session, handler_registry=registry, arq_pool=pool + ) + assert service._runtime.arq_pool is pool + + def test_repos_injected(self, db_session: MagicMock): + registry = HandlerRegistry() + pool = MagicMock() + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_worker_scheduler_service( + db_session, handler_registry=registry, arq_pool=pool + ) + assert service._repos.task is not None + assert service._repos.run_log is not None + assert service._repos.run_log_daily is not None + assert service._repos.idempotency is not None + + def test_uow_injected(self, db_session: MagicMock): + registry = HandlerRegistry() + pool = MagicMock() + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_worker_scheduler_service( + db_session, handler_registry=registry, arq_pool=pool + ) + assert service._uow is not None + + +# ─── RuntimeServices 装配 ───────────────────────────────────────────────── + + +@pytest.mark.unit +class TestRuntimeServicesAssembly: + def test_api_runtime_has_none_registry(self, db_session: MagicMock): + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_scheduler_service(db_session) + # Assert - RuntimeServices dataclass 字段正确 + assert isinstance(service._runtime, RuntimeServices) + assert service._runtime.handler_registry is None + + def test_worker_runtime_has_registry_and_pool(self, db_session: MagicMock): + registry = HandlerRegistry() + pool = MagicMock() + with patch("yuxi.scheduler.infrastructure.container.app_config"): + service = create_worker_scheduler_service( + db_session, handler_registry=registry, arq_pool=pool + ) + assert isinstance(service._runtime, RuntimeServices) + assert service._runtime.handler_registry is registry + assert service._runtime.arq_pool is pool diff --git a/backend/test/unit/scheduler/use_cases/test_mappers.py b/backend/test/unit/scheduler/use_cases/test_mappers.py new file mode 100644 index 00000000..5c27dede --- /dev/null +++ b/backend/test/unit/scheduler/use_cases/test_mappers.py @@ -0,0 +1,502 @@ +"""领域模型(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" diff --git a/backend/test/unit/scheduler/use_cases/test_scheduler_service.py b/backend/test/unit/scheduler/use_cases/test_scheduler_service.py new file mode 100644 index 00000000..c5957262 --- /dev/null +++ b/backend/test/unit/scheduler/use_cases/test_scheduler_service.py @@ -0,0 +1,1758 @@ +"""SchedulerService 用例编排单元测试。 + +覆盖 ``yuxi.scheduler.use_cases.services.scheduler_service`` 模块的 +``SchedulerService`` 全部公开方法: +- handler 注册(register_handler / get_handler) +- 任务 CRUD(create / get / list / update / delete) +- 状态机(pause / resume / trigger) +- 查询(list_run_logs / get_run_log / list_all_run_logs / list_handler_summary / + count_by_status / list_upcoming / list_daily_stats) +- 调度入口(tick / execute_task) +- 健康检查与运维恢复(get_health / reclaim_stale_runs) +- 任务回收站(list_deleted_tasks / restore_task / hard_delete_task) + +通过 ``FakeRepositories`` / ``FakeRuntimeServices`` / ``FakeUnitOfWork`` 隔离 +持久层与运行时依赖,``utc_now_naive`` / ``uuid.uuid4`` 通过 monkeypatch 固定。 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +from typing import TYPE_CHECKING, Any +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from pydantic import ValidationError + +from yuxi.scheduler.core.contracts import TaskContext, TaskResult +from yuxi.scheduler.core.models import ( + DailyStat, + HandlerSummary, + ScheduledTask, + ScheduledTaskRunLog, +) +from yuxi.scheduler.exceptions import ( + HandlerNotFoundError, + SchedulerEntityNotFoundError, + SchedulerError, + SchedulerValidationError, + TaskStatusTransitionError, +) +from yuxi.scheduler.use_cases.dto.scheduler import ( + CreateTaskInput, + DeleteTaskInput, + GetRunLogInput, + GetTaskInput, + HardDeleteTaskInput, + ListAllRunLogsInput, + ListDailyStatsInput, + ListDeletedTasksInput, + ListRunLogsInput, + ListTasksInput, + ListUpcomingInput, + PauseTaskInput, + ReclaimStaleRunsInput, + RestoreTaskInput, + ResumeTaskInput, + TriggerTaskInput, + UpdateTaskInput, +) +from yuxi.scheduler.use_cases.services.scheduler_service import SchedulerService + +if TYPE_CHECKING: + from test.unit.scheduler.conftest import ( + FakeRepositories, + FakeRuntimeServices, + FakeUnitOfWork, + ) + + +pytestmark = pytest.mark.unit + + +# ─── 测试辅助 ───────────────────────────────────────────────────────────── + + +def _make_task(**overrides: Any) -> ScheduledTask: + """构造 ScheduledTask 测试数据。""" + defaults: dict[str, Any] = { + "task_id": "task-001", + "handler_name": "test_handler", + "owner_scope": "system", + "owner_id": "test:1", + "schedule_kind": "cron", + "cron_expression": "*/15 * * * *", + "tz": "Asia/Shanghai", + "payload": {"key": "value"}, + "enabled": True, + "delete_after_run": False, + "block_strategy": "discard_later", + "stagger_seconds": 30, + "consecutive_errors": 0, + "status": "active", + "next_run_at": datetime(2024, 1, 1, 0, 15, 0), + "id": 1, + } + defaults.update(overrides) + return ScheduledTask(**defaults) + + +def _make_run_log(**overrides: Any) -> ScheduledTaskRunLog: + defaults: dict[str, Any] = { + "task_id": "task-001", + "run_id": "run-001", + "triggered_by": "auto", + "status": "success", + "started_at": datetime(2024, 1, 1, 0, 15, 0), + "finished_at": datetime(2024, 1, 1, 0, 15, 5), + "id": 1, + } + defaults.update(overrides) + return ScheduledTaskRunLog(**defaults) + + +@pytest.fixture +def service( + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fake_uow: FakeUnitOfWork, +) -> SchedulerService: + """提供装配完成的 SchedulerService 实例。""" + return SchedulerService(fake_repos, fake_runtime, fake_uow) + + +# 注:fixed_now / fixed_uuid 由 conftest.py 提供,此处不再重复定义。 + + +# ─── 内部辅助方法 ───────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServiceHelpers: + @pytest.mark.asyncio + async def test_require_handler_registry_returns_registry(self, service: SchedulerService): + # Arrange - fake_runtime 默认 handler_registry 为 MagicMock + # Act + result = service._require_handler_registry() + # Assert + assert result is service._runtime.handler_registry + + @pytest.mark.asyncio + async def test_require_handler_registry_raises_when_none(self, service: SchedulerService): + # Arrange - 模拟 api 进程(handler_registry=None) + service._runtime.handler_registry = None + # Act & Assert + with pytest.raises(SchedulerError, match="handler registry not available"): + service._require_handler_registry() + + @pytest.mark.asyncio + async def test_require_arq_pool_returns_pool(self, service: SchedulerService): + result = service._require_arq_pool() + assert result is service._runtime.arq_pool + + @pytest.mark.asyncio + async def test_require_arq_pool_raises_when_none(self, service: SchedulerService): + service._runtime.arq_pool = None + with pytest.raises(SchedulerError, match="arq pool not available"): + service._require_arq_pool() + + def test_calc_next_run_at_cron(self, service: SchedulerService): + # Arrange + task = _make_task(schedule_kind="cron", cron_expression="*/15 * * * *") + # Act + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=datetime(2024, 1, 1, 0, 15, 0), + ) as mock_calc: + result = service._calc_next_run_at(task) + # Assert + mock_calc.assert_called_once_with("*/15 * * * *", tz="Asia/Shanghai") + assert result == datetime(2024, 1, 1, 0, 15, 0) + + def test_calc_next_run_at_at_kind(self, service: SchedulerService): + # Arrange - at 类型直接返回 run_at + run_at = datetime(2024, 1, 1, 0, 0, 0) + task = _make_task(schedule_kind="at", cron_expression=None, run_at=run_at) + # Act + result = service._calc_next_run_at(task) + # Assert + assert result == run_at + + def test_calc_next_run_at_cron_missing_expression_raises(self, service: SchedulerService): + task = _make_task(schedule_kind="cron", cron_expression=None) + with pytest.raises(SchedulerValidationError, match="missing cron_expression"): + service._calc_next_run_at(task) + + def test_calc_next_run_at_unknown_kind_raises(self, service: SchedulerService): + task = _make_task(schedule_kind="unknown", cron_expression=None) + with pytest.raises(SchedulerValidationError, match="unknown schedule_kind"): + service._calc_next_run_at(task) + + def test_calc_stagger_cron_returns_value(self, service: SchedulerService): + # Arrange + task_id = "task-001" + # Act + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_stagger_seconds", + return_value=42, + ) as mock_calc: + result = service._calc_stagger(task_id, "cron") + # Assert + mock_calc.assert_called_once_with(task_id, max_seconds=300) + assert result == 42 + + def test_calc_stagger_non_cron_returns_none(self, service: SchedulerService): + result = service._calc_stagger("task-001", "at") + assert result is None + + def test_parse_iso_to_utc_naive_naive_input(self): + # Arrange - naive datetime 按 UTC 解释 + # Act + result = SchedulerService._parse_iso_to_utc_naive("2024-01-01T00:00:00", field_name="run_at") + # Assert + assert result == datetime(2024, 1, 1, 0, 0, 0) + assert result.tzinfo is None + + def test_parse_iso_to_utc_naive_aware_input(self): + # Arrange - aware datetime 转换为 UTC naive + # Act + result = SchedulerService._parse_iso_to_utc_naive( + "2024-01-01T08:00:00+08:00", field_name="run_at" + ) + # Assert - +08:00 → UTC 00:00 + assert result == datetime(2024, 1, 1, 0, 0, 0) + assert result.tzinfo is None + + def test_parse_iso_to_utc_naive_invalid_raises(self): + # Arrange & Act & Assert + with pytest.raises(SchedulerValidationError, match="格式非法"): + SchedulerService._parse_iso_to_utc_naive("not-a-date", field_name="run_at") + + def test_daily_stat_date_returns_shanghai_date(self, fixed_now: datetime): + # Arrange - fixed_now = 2024-06-01 00:00 UTC = Shanghai 08:00 + # Act + result = SchedulerService._daily_stat_date(fixed_now) + # Assert - Shanghai 自然日为 2024-06-01 + assert result == datetime(2024, 6, 1).date() + + def test_daily_stat_date_cross_day_boundary(self, fixed_now: datetime): + # Arrange - UTC 16:00 = Shanghai 00:00 次日 + utc_time = datetime(2024, 6, 1, 16, 0, 0) + # Act + result = SchedulerService._daily_stat_date(utc_time) + # Assert + assert result == datetime(2024, 6, 2).date() + + +# ─── handler 注册 ───────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServiceHandlerRegistration: + def test_register_handler_delegates_to_registry(self, service: SchedulerService): + # Arrange + handler = MagicMock() + handler.name = "my_handler" + registry = service._runtime.handler_registry + # Act + service.register_handler(handler) + # Assert + registry.register.assert_called_once_with(handler) + + def test_register_handler_raises_when_registry_none(self, service: SchedulerService): + service._runtime.handler_registry = None + with pytest.raises(SchedulerError, match="handler registry not available"): + service.register_handler(MagicMock()) + + def test_get_handler_returns_registered_handler(self, service: SchedulerService): + # Arrange + handler = MagicMock() + registry = service._runtime.handler_registry + registry.get = MagicMock(return_value=handler) + # Act + result = service.get_handler("my_handler") + # Assert + registry.get.assert_called_once_with("my_handler") + assert result is handler + + def test_get_handler_raises_when_not_found(self, service: SchedulerService): + # Arrange + registry = service._runtime.handler_registry + registry.get = MagicMock(return_value=None) + # Act & Assert + with pytest.raises(HandlerNotFoundError, match="not registered"): + service.get_handler("nonexistent") + + def test_get_handler_raises_when_registry_none(self, service: SchedulerService): + service._runtime.handler_registry = None + with pytest.raises(SchedulerError): + service.get_handler("any") + + +# ─── 任务 CRUD ──────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServiceCreateTask: + @pytest.mark.asyncio + async def test_create_cron_task_success( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_uow: FakeUnitOfWork, + fixed_uuid: str, + ): + # Arrange + input_dto = CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="cron", + cron_expression="*/15 * * * *", + ) + fake_repos.task.create = AsyncMock(return_value=_make_task(task_id=fixed_uuid)) + # Act + result = await service.create_task(input_dto) + # Assert + assert result.task_id == fixed_uuid + fake_repos.task.create.assert_awaited_once() + # commit=False 因为在 uow 块内 + call_args = fake_repos.task.create.call_args + assert call_args.kwargs["commit"] is False + assert call_args.args[0]["task_id"] == fixed_uuid + assert call_args.args[0]["schedule_kind"] == "cron" + assert call_args.args[0]["status"] == "active" + + @pytest.mark.asyncio + async def test_create_at_task_success( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + fixed_uuid: str, + ): + # Arrange - run_at 为未来时间 + future_run_at = (fixed_now + timedelta(hours=1)).isoformat() + input_dto = CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="at", + run_at=future_run_at, + delete_after_run=True, + ) + fake_repos.task.create = AsyncMock(return_value=_make_task(schedule_kind="at")) + # Act + result = await service.create_task(input_dto) + # Assert + assert result.schedule_kind == "at" + + @pytest.mark.asyncio + async def test_create_cron_task_missing_expression_raises( + self, + service: SchedulerService, + ): + # Arrange + input_dto = CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="cron", + cron_expression=None, + ) + # Act & Assert + with pytest.raises(SchedulerValidationError, match="cron_expression 必填"): + await service.create_task(input_dto) + + @pytest.mark.asyncio + async def test_create_at_task_missing_run_at_raises(self, service: SchedulerService): + input_dto = CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="at", + run_at=None, + ) + with pytest.raises(SchedulerValidationError, match="run_at 必填"): + await service.create_task(input_dto) + + @pytest.mark.asyncio + async def test_create_at_task_past_run_at_raises( + self, + service: SchedulerService, + fixed_now: datetime, + ): + # Arrange - run_at 为过去时间 + past_run_at = (fixed_now - timedelta(hours=1)).isoformat() + input_dto = CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="at", + run_at=past_run_at, + ) + # Act & Assert + with pytest.raises(SchedulerValidationError, match="run_at 必须为未来时间"): + await service.create_task(input_dto) + + @pytest.mark.asyncio + async def test_create_task_invalid_owner_scope_raises(self, service: SchedulerService): + # Arrange - owner_scope 为空,Pydantic DTO 层 min_length=1 前置校验拦截 + # 领域层 validate_owner_scope 不可达,期望 ValidationError + # Act & Assert + with pytest.raises(ValidationError): + CreateTaskInput( + handler_name="my_handler", + owner_scope="", + owner_id="test:1", + schedule_kind="cron", + cron_expression="*/15 * * * *", + ) + + @pytest.mark.asyncio + async def test_create_task_payload_too_large_raises(self, service: SchedulerService): + # Arrange - payload 超过 64KB + input_dto = CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="cron", + cron_expression="*/15 * * * *", + payload={"k": "a" * 100_000}, + ) + # Act & Assert + with pytest.raises(SchedulerValidationError): + await service.create_task(input_dto) + + @pytest.mark.asyncio + async def test_create_task_unknown_schedule_kind_raises( + self, service: SchedulerService + ): + # Arrange - schedule_kind 不合法,Pydantic DTO 层 pattern 前置校验拦截 + # 领域层 _calc_next_run_at 的 unknown schedule_kind 路径不可达,期望 ValidationError + # Act & Assert + with pytest.raises(ValidationError): + CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="unknown", + cron_expression="*/15 * * * *", + ) + + @pytest.mark.asyncio + async def test_create_cron_task_passes_tz_to_calc_next_run_at( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_uuid: str, + ): + # Arrange - 验证 tz 字段传递到 calc_next_run_at + input_dto = CreateTaskInput( + handler_name="my_handler", + owner_scope="system", + owner_id="test:1", + schedule_kind="cron", + cron_expression="*/15 * * * *", + tz="UTC", + ) + fake_repos.task.create = AsyncMock(return_value=_make_task(task_id=fixed_uuid)) + # Act + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=datetime(2024, 1, 1, 0, 15, 0), + ) as mock_calc: + await service.create_task(input_dto) + # Assert - tz 参数正确传递 + mock_calc.assert_called_once_with("*/15 * * * *", tz="UTC") + + +@pytest.mark.unit +class TestSchedulerServiceGetTask: + @pytest.mark.asyncio + async def test_get_task_success(self, service: SchedulerService, fake_repos: FakeRepositories): + # Arrange + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task()) + # Act + result = await service.get_task(GetTaskInput(task_id="task-001")) + # Assert + assert result.task_id == "task-001" + + @pytest.mark.asyncio + async def test_get_task_not_found_raises(self, service: SchedulerService, fake_repos: FakeRepositories): + fake_repos.task.get_by_task_id = AsyncMock(return_value=None) + with pytest.raises(SchedulerEntityNotFoundError, match="不存在"): + await service.get_task(GetTaskInput(task_id="nonexistent")) + + +@pytest.mark.unit +class TestSchedulerServiceListTasks: + @pytest.mark.asyncio + async def test_list_tasks_returns_items_and_total( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange + fake_repos.task.list = AsyncMock(return_value=([_make_task(), _make_task(task_id="t2")], 2)) + # Act + result = await service.list_tasks( + ListTasksInput(page=1, page_size=20, status="active") + ) + # Assert + assert len(result.items) == 2 + assert result.total == 2 + assert result.page == 1 + assert result.page_size == 20 + fake_repos.task.list.assert_awaited_once() + call_kwargs = fake_repos.task.list.call_args.kwargs + assert call_kwargs["status"] == "active" + assert call_kwargs["page"] == 1 + + @pytest.mark.asyncio + async def test_list_tasks_passes_all_filter_params( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange - 验证所有过滤参数都传递到仓储 + fake_repos.task.list = AsyncMock(return_value=([], 0)) + # Act + await service.list_tasks( + ListTasksInput( + owner_scope="business", + owner_id="test:1", + handler_name="my_handler", + enabled=True, + status="active", + page=2, + page_size=50, + ) + ) + # Assert + call_kwargs = fake_repos.task.list.call_args.kwargs + assert call_kwargs["owner_scope"] == "business" + assert call_kwargs["owner_id"] == "test:1" + assert call_kwargs["handler_name"] == "my_handler" + assert call_kwargs["enabled"] is True + assert call_kwargs["status"] == "active" + assert call_kwargs["page"] == 2 + assert call_kwargs["page_size"] == 50 + + +@pytest.mark.unit +class TestSchedulerServiceUpdateTask: + @pytest.mark.asyncio + async def test_update_task_no_changes_returns_original( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange - 不传任何更新字段 + task = _make_task() + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + input_dto = UpdateTaskInput(task_id="task-001") + # Act + result = await service.update_task(input_dto) + # Assert - 直接返回原任务,不调用 update + assert result.task_id == "task-001" + fake_repos.task.update.assert_not_awaited() + + @pytest.mark.asyncio + async def test_update_task_not_found_raises( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + fake_repos.task.get_by_task_id = AsyncMock(return_value=None) + with pytest.raises(SchedulerEntityNotFoundError): + await service.update_task(UpdateTaskInput(task_id="nonexistent", enabled=False)) + + @pytest.mark.asyncio + async def test_update_cron_expression_recalculates_next_run_at( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange + task = _make_task(schedule_kind="cron", cron_expression="*/15 * * * *") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.task.update = AsyncMock(return_value=_make_task(cron_expression="0 * * * *")) + new_next = datetime(2024, 6, 1, 1, 0, 0) + # Act + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=new_next, + ) as mock_calc, patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_stagger_seconds", + return_value=42, + ): + result = await service.update_task( + UpdateTaskInput(task_id="task-001", cron_expression="0 * * * *") + ) + # Assert + mock_calc.assert_called_once() + call_args = fake_repos.task.update.call_args + assert call_args.args[0] == "task-001" + assert call_args.args[1]["cron_expression"] == "0 * * * *" + assert call_args.args[1]["next_run_at"] == new_next + assert call_args.args[1]["stagger_seconds"] == 42 + + @pytest.mark.asyncio + async def test_update_cron_task_run_at_raises( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange - cron 任务不支持更新 run_at + task = _make_task(schedule_kind="cron") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + # Act & Assert + with pytest.raises(SchedulerValidationError, match="cron 任务不支持更新 run_at"): + await service.update_task( + UpdateTaskInput(task_id="task-001", run_at="2024-12-31T00:00:00") + ) + + @pytest.mark.asyncio + async def test_update_at_task_cron_expression_raises( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange - at 任务不支持更新 cron_expression + task = _make_task(schedule_kind="at", cron_expression=None) + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + # Act & Assert + with pytest.raises(SchedulerValidationError, match="at 任务不支持更新 cron_expression"): + await service.update_task( + UpdateTaskInput(task_id="task-001", cron_expression="*/15 * * * *") + ) + + @pytest.mark.asyncio + async def test_update_payload_too_large_raises( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + task = _make_task() + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + with pytest.raises(SchedulerValidationError): + await service.update_task( + UpdateTaskInput(task_id="task-001", payload={"k": "a" * 100_000}) + ) + + @pytest.mark.asyncio + async def test_update_at_task_run_at_recalculates_next_run_at( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange - at 任务更新 run_at,应重算 next_run_at + task = _make_task(schedule_kind="at", cron_expression=None) + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.task.update = AsyncMock(return_value=task) + future_run_at = (fixed_now + timedelta(hours=2)).isoformat() + # Act + result = await service.update_task( + UpdateTaskInput(task_id="task-001", run_at=future_run_at) + ) + # Assert - next_run_at 被重算并写入 update_data + call_args = fake_repos.task.update.call_args + assert call_args.args[1]["next_run_at"] == datetime.fromisoformat(future_run_at) + + @pytest.mark.asyncio + async def test_update_at_task_past_run_at_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange - at 任务更新 run_at 为过去时间 + task = _make_task(schedule_kind="at", cron_expression=None) + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + past_run_at = (fixed_now - timedelta(hours=1)).isoformat() + # Act & Assert + with pytest.raises(SchedulerValidationError, match="run_at 必须为未来时间"): + await service.update_task( + UpdateTaskInput(task_id="task-001", run_at=past_run_at) + ) + + @pytest.mark.asyncio + async def test_update_payload_none_does_not_trigger_validation( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange - payload=None 不应触发 payload 大小校验 + task = _make_task() + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.task.update = AsyncMock(return_value=task) + # Act - 不应抛异常 + await service.update_task( + UpdateTaskInput(task_id="task-001", payload=None) + ) + # Assert - update 被调用(payload=None 在 model_dump 中可能被排除) + fake_repos.task.update.assert_awaited_once() + + @pytest.mark.asyncio + async def test_update_enabled_field_passes_through( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange - 更新 enabled 字段 + task = _make_task(enabled=True) + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.task.update = AsyncMock(return_value=_make_task(enabled=False)) + # Act + result = await service.update_task( + UpdateTaskInput(task_id="task-001", enabled=False) + ) + # Assert + assert result.enabled is False + call_args = fake_repos.task.update.call_args + assert call_args.args[1]["enabled"] is False + + +@pytest.mark.unit +class TestSchedulerServiceDeleteTask: + @pytest.mark.asyncio + async def test_delete_task_success(self, service: SchedulerService, fake_repos: FakeRepositories): + fake_repos.task.delete = AsyncMock(return_value=True) + # Act - 无异常即通过 + await service.delete_task(DeleteTaskInput(task_id="task-001")) + # Assert - delete_task 未使用 UnitOfWork,依赖仓储默认自动提交 + fake_repos.task.delete.assert_awaited_once_with("task-001") + + @pytest.mark.asyncio + async def test_delete_task_not_found_raises( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + fake_repos.task.delete = AsyncMock(return_value=False) + with pytest.raises(SchedulerEntityNotFoundError): + await service.delete_task(DeleteTaskInput(task_id="nonexistent")) + + +# ─── 状态机 ─────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServicePauseTask: + @pytest.mark.asyncio + async def test_pause_success( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange + paused_task = _make_task(status="paused") + fake_repos.task.pause = AsyncMock(return_value=paused_task) + # Act + result = await service.pause_task(PauseTaskInput(task_id="task-001")) + # Assert + assert result.status == "paused" + fake_repos.task.pause.assert_awaited_once_with("task-001", commit=False) + + @pytest.mark.asyncio + async def test_pause_not_found_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange - pause 返回 None 且任务不存在 + fake_repos.task.pause = AsyncMock(return_value=None) + fake_repos.task.get_by_task_id = AsyncMock(return_value=None) + # Act & Assert + with pytest.raises(SchedulerEntityNotFoundError): + await service.pause_task(PauseTaskInput(task_id="nonexistent")) + + @pytest.mark.asyncio + async def test_pause_invalid_state_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange - pause 返回 None 但任务存在(状态不允许) + fake_repos.task.pause = AsyncMock(return_value=None) + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task(status="dead_letter")) + # Act & Assert + with pytest.raises(TaskStatusTransitionError, match="不允许暂停"): + await service.pause_task(PauseTaskInput(task_id="task-001")) + + +@pytest.mark.unit +class TestSchedulerServiceResumeTask: + @pytest.mark.asyncio + async def test_resume_paused_task( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task(status="paused")) + fake_repos.task.resume = AsyncMock(return_value=_make_task(status="active")) + # Act + result = await service.resume_task(ResumeTaskInput(task_id="task-001")) + # Assert + assert result.status == "active" + fake_repos.task.resume.assert_awaited_once() + + @pytest.mark.asyncio + async def test_resume_dead_letter_task( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task(status="dead_letter")) + fake_repos.task.reset_from_dead_letter = AsyncMock(return_value=_make_task(status="active")) + # Act + result = await service.resume_task(ResumeTaskInput(task_id="task-001")) + # Assert + fake_repos.task.reset_from_dead_letter.assert_awaited_once() + assert result.status == "active" + + @pytest.mark.asyncio + async def test_resume_active_task_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange - active 状态不允许恢复 + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task(status="active")) + # Act & Assert + with pytest.raises(TaskStatusTransitionError, match="不允许恢复"): + await service.resume_task(ResumeTaskInput(task_id="task-001")) + + @pytest.mark.asyncio + async def test_resume_not_found_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + fake_repos.task.get_by_task_id = AsyncMock(return_value=None) + with pytest.raises(SchedulerEntityNotFoundError): + await service.resume_task(ResumeTaskInput(task_id="nonexistent")) + + +@pytest.mark.unit +class TestSchedulerServiceTriggerTask: + @pytest.mark.asyncio + async def test_trigger_task_enqueues_job( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_uuid: str, + ): + # Arrange + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task()) + pool = fake_runtime.arq_pool + # Act + result = await service.trigger_task(TriggerTaskInput(task_id="task-001")) + # Assert + assert result.task_id == "task-001" + pool.enqueue_job.assert_awaited_once() + call_args = pool.enqueue_job.call_args + assert call_args.args[0] == "execute_scheduled_task" + assert call_args.args[1] == "task-001" + assert call_args.args[2] == fixed_uuid # run_id + assert call_args.args[3] == "manual" + + @pytest.mark.asyncio + async def test_trigger_task_not_found_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + fake_repos.task.get_by_task_id = AsyncMock(return_value=None) + with pytest.raises(SchedulerEntityNotFoundError): + await service.trigger_task(TriggerTaskInput(task_id="nonexistent")) + + @pytest.mark.asyncio + async def test_trigger_task_no_arq_pool_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + ): + # Arrange - api 进程 arq_pool=None + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task()) + fake_runtime.arq_pool = None + # Act & Assert + with pytest.raises(SchedulerError, match="arq pool not available"): + await service.trigger_task(TriggerTaskInput(task_id="task-001")) + + +# ─── 查询 ───────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServiceListRunLogs: + @pytest.mark.asyncio + async def test_list_run_logs_by_task( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange + fake_repos.run_log.list_by_task = AsyncMock( + return_value=([_make_run_log()], 1) + ) + # Act + result = await service.list_run_logs( + ListRunLogsInput(task_id="task-001", page=1, page_size=20) + ) + # Assert + assert len(result.items) == 1 + assert result.total == 1 + + @pytest.mark.asyncio + async def test_list_run_logs_with_date_filters( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange + fake_repos.run_log.list_by_task = AsyncMock(return_value=([], 0)) + # Act + await service.list_run_logs( + ListRunLogsInput( + task_id="task-001", + start_date="2024-01-01T00:00:00", + end_date="2024-01-31T00:00:00", + ) + ) + # Assert - 日期被解析为 datetime 传入仓储 + call_kwargs = fake_repos.run_log.list_by_task.call_args.kwargs + assert call_kwargs["started_after"] == datetime(2024, 1, 1, 0, 0, 0) + assert call_kwargs["started_before"] == datetime(2024, 1, 31, 0, 0, 0) + + +@pytest.mark.unit +class TestSchedulerServiceGetRunLog: + @pytest.mark.asyncio + async def test_get_run_log_success(self, service: SchedulerService, fake_repos: FakeRepositories): + fake_repos.run_log.get_by_run_id = AsyncMock(return_value=_make_run_log()) + result = await service.get_run_log(GetRunLogInput(run_id="run-001")) + assert result.run_id == "run-001" + + @pytest.mark.asyncio + async def test_get_run_log_not_found_raises( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + fake_repos.run_log.get_by_run_id = AsyncMock(return_value=None) + with pytest.raises(SchedulerEntityNotFoundError): + await service.get_run_log(GetRunLogInput(run_id="nonexistent")) + + +@pytest.mark.unit +class TestSchedulerServiceListAllRunLogs: + @pytest.mark.asyncio + async def test_list_all_with_task_id(self, service: SchedulerService, fake_repos: FakeRepositories): + fake_repos.run_log.list_by_task = AsyncMock(return_value=([_make_run_log()], 1)) + result = await service.list_all_run_logs( + ListAllRunLogsInput(task_id="task-001") + ) + assert len(result.items) == 1 + fake_repos.run_log.list_by_task.assert_awaited_once() + + @pytest.mark.asyncio + async def test_list_all_with_status_only(self, service: SchedulerService, fake_repos: FakeRepositories): + fake_repos.run_log.list_by_status = AsyncMock(return_value=([_make_run_log()], 1)) + result = await service.list_all_run_logs( + ListAllRunLogsInput(status="running") + ) + assert len(result.items) == 1 + fake_repos.run_log.list_by_status.assert_awaited_once() + + @pytest.mark.asyncio + async def test_list_all_without_task_id_and_status_raises( + self, service: SchedulerService + ): + with pytest.raises(SchedulerValidationError, match="不可同时为空"): + await service.list_all_run_logs(ListAllRunLogsInput()) + + +@pytest.mark.unit +class TestSchedulerServiceObservability: + @pytest.mark.asyncio + async def test_list_handler_summary(self, service: SchedulerService, fake_repos: FakeRepositories): + # Arrange + fake_repos.task.list_handler_summary = AsyncMock( + return_value=[HandlerSummary(name="h1", task_count=5)] + ) + # Act + result = await service.list_handler_summary() + # Assert + assert len(result.items) == 1 + assert result.items[0].handler_name == "h1" + + @pytest.mark.asyncio + async def test_count_by_status(self, service: SchedulerService, fake_repos: FakeRepositories): + fake_repos.task.count_by_status = AsyncMock( + return_value={"active": 10, "paused": 5, "dead_letter": 2} + ) + result = await service.count_by_status() + assert result.active == 10 + assert result.paused == 5 + assert result.dead_letter == 2 + + @pytest.mark.asyncio + async def test_list_upcoming(self, service: SchedulerService, fake_repos: FakeRepositories, fixed_now: datetime): + fake_repos.task.list_upcoming = AsyncMock(return_value=[_make_task()]) + result = await service.list_upcoming(ListUpcomingInput(limit=50)) + assert len(result.items) == 1 + # 验证 before = now + 24h + call_kwargs = fake_repos.task.list_upcoming.call_args.kwargs + assert call_kwargs["after"] == fixed_now + assert call_kwargs["before"] == fixed_now + timedelta(hours=24) + assert call_kwargs["limit"] == 50 + + @pytest.mark.asyncio + async def test_list_upcoming_passes_handler_name_filter( + self, service: SchedulerService, fake_repos: FakeRepositories, fixed_now: datetime + ): + # Arrange - 验证 handler_name 过滤参数传递 + fake_repos.task.list_upcoming = AsyncMock(return_value=[]) + # Act + await service.list_upcoming(ListUpcomingInput(limit=10, handler_name="my_handler")) + # Assert + call_kwargs = fake_repos.task.list_upcoming.call_args.kwargs + assert call_kwargs["handler_name"] == "my_handler" + assert call_kwargs["limit"] == 10 + + +@pytest.mark.unit +class TestSchedulerServiceListDailyStats: + @pytest.mark.asyncio + async def test_list_daily_stats_success(self, service: SchedulerService, fake_repos: FakeRepositories): + # Arrange + fake_repos.run_log_daily.list_daily_stat = AsyncMock( + return_value=[ + 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 + result = await service.list_daily_stats( + ListDailyStatsInput(start_date="2024-01-01", end_date="2024-01-31") + ) + # Assert + assert len(result.items) == 1 + assert result.items[0].total_count == 13 + + @pytest.mark.asyncio + async def test_list_daily_stats_invalid_date_format_raises(self, service: SchedulerService): + with pytest.raises(SchedulerValidationError, match="日期格式非法"): + await service.list_daily_stats( + ListDailyStatsInput(start_date="not-a-date", end_date="2024-01-31") + ) + + @pytest.mark.asyncio + async def test_list_daily_stats_start_after_end_raises(self, service: SchedulerService): + with pytest.raises(SchedulerValidationError, match="不能晚于"): + await service.list_daily_stats( + ListDailyStatsInput(start_date="2024-12-31", end_date="2024-01-01") + ) + + @pytest.mark.asyncio + async def test_list_daily_stats_range_exceeds_90_days_raises(self, service: SchedulerService): + with pytest.raises(SchedulerValidationError, match="超过 90 天"): + await service.list_daily_stats( + ListDailyStatsInput(start_date="2024-01-01", end_date="2024-06-01") + ) + + +# ─── 调度入口 ───────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServiceTick: + @pytest.mark.asyncio + async def test_tick_no_due_tasks_returns_early( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange + fake_repos.task.list_due = AsyncMock(return_value=[]) + # Act + await service.tick() + # Assert - 没有到期任务时直接返回,不查询 arq_pool + fake_repos.task.list_due.assert_awaited_once() + service._runtime.arq_pool.enqueue_job.assert_not_awaited() + + @pytest.mark.asyncio + async def test_tick_enqueues_due_task( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + fixed_uuid: str, + ): + # Arrange + task = _make_task( + next_run_at=fixed_now - timedelta(minutes=5), + stagger_seconds=None, + ) + fake_repos.task.list_due = AsyncMock(return_value=[task]) + fake_repos.run_log.get_running_by_task = AsyncMock(return_value=None) + fake_repos.task.acquire_for_run = AsyncMock(return_value=True) + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=fixed_now + timedelta(minutes=15), + ): + # Act + await service.tick() + # Assert + fake_runtime.arq_pool.enqueue_job.assert_awaited_once() + call_args = fake_runtime.arq_pool.enqueue_job.call_args + assert call_args.args[0] == "execute_scheduled_task" + + @pytest.mark.asyncio + async def test_tick_skips_when_running_and_discard_strategy( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + fixed_uuid: str, + ): + # Arrange - 存在 running 记录且 block_strategy=discard_later + task = _make_task( + next_run_at=fixed_now - timedelta(minutes=5), + block_strategy="discard_later", + ) + fake_repos.task.list_due = AsyncMock(return_value=[task]) + fake_repos.run_log.get_running_by_task = AsyncMock(return_value=_make_run_log(status="running")) + fake_repos.task.acquire_for_run = AsyncMock(return_value=True) + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=fixed_now + timedelta(minutes=15), + ): + # Act + await service.tick() + # Assert - 写入 skipped 日志,不入队执行 + fake_repos.run_log.create.assert_awaited_once() + create_data = fake_repos.run_log.create.call_args.args[0] + assert create_data["status"] == "skipped" + fake_runtime.arq_pool.enqueue_job.assert_not_awaited() + + @pytest.mark.asyncio + async def test_tick_skips_when_stagger_not_reached( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - stagger 未到,next_run_at + stagger > now + task = _make_task( + next_run_at=fixed_now - timedelta(minutes=1), + stagger_seconds=300, # 5 分钟抖动 + schedule_kind="cron", + ) + fake_repos.task.list_due = AsyncMock(return_value=[task]) + # Act + await service.tick() + # Assert - 不查询 running,不入队 + fake_repos.run_log.get_running_by_task.assert_not_awaited() + fake_runtime.arq_pool.enqueue_job.assert_not_awaited() + + @pytest.mark.asyncio + async def test_tick_acquire_failure_skips_enqueue( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - acquire_for_run 返回 False(被其他实例抢占) + task = _make_task(next_run_at=fixed_now - timedelta(minutes=5), stagger_seconds=None) + fake_repos.task.list_due = AsyncMock(return_value=[task]) + fake_repos.run_log.get_running_by_task = AsyncMock(return_value=None) + fake_repos.task.acquire_for_run = AsyncMock(return_value=False) + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=fixed_now + timedelta(minutes=15), + ): + # Act + await service.tick() + # Assert + fake_runtime.arq_pool.enqueue_job.assert_not_awaited() + + @pytest.mark.asyncio + async def test_tick_non_discard_strategy_proceeds_with_running( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + fixed_uuid: str, + ): + # Arrange - block_strategy != discard_later 时,即使存在 running 记录也继续执行 + task = _make_task( + next_run_at=fixed_now - timedelta(minutes=5), + stagger_seconds=None, + block_strategy="allow_concurrent", + ) + fake_repos.task.list_due = AsyncMock(return_value=[task]) + fake_repos.run_log.get_running_by_task = AsyncMock( + return_value=_make_run_log(status="running") + ) + fake_repos.task.acquire_for_run = AsyncMock(return_value=True) + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=fixed_now + timedelta(minutes=15), + ): + # Act + await service.tick() + # Assert - 不写 skipped 日志,直接入队执行 + fake_repos.run_log.create.assert_not_awaited() + fake_runtime.arq_pool.enqueue_job.assert_awaited_once() + + @pytest.mark.asyncio + async def test_tick_task_exception_does_not_break_loop( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - 第一个任务抛异常,第二个任务正常 + task1 = _make_task(task_id="t1", next_run_at=fixed_now - timedelta(minutes=5), stagger_seconds=None) + task2 = _make_task(task_id="t2", next_run_at=fixed_now - timedelta(minutes=5), stagger_seconds=None) + fake_repos.task.list_due = AsyncMock(return_value=[task1, task2]) + + # task1 查询 running 时抛异常 + call_count = 0 + + async def _get_running(task_id: str): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("db error on task1") + return None + + fake_repos.run_log.get_running_by_task = AsyncMock(side_effect=_get_running) + fake_repos.task.acquire_for_run = AsyncMock(return_value=True) + with patch( + "yuxi.scheduler.use_cases.services.scheduler_service.calc_next_run_at", + return_value=fixed_now + timedelta(minutes=15), + ): + # Act + await service.tick() + # Assert - 第二个任务仍被入队 + fake_runtime.arq_pool.enqueue_job.assert_awaited_once() + + +@pytest.mark.unit +class TestSchedulerServiceExecuteTask: + @pytest.mark.asyncio + async def test_execute_task_not_found_returns_silently( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange + fake_repos.task.get_by_task_id = AsyncMock(return_value=None) + # Act - 无异常即通过 + await service.execute_task("nonexistent", "run-001", "auto") + # Assert - 不创建 run_log + fake_repos.run_log.create.assert_not_awaited() + + @pytest.mark.asyncio + async def test_execute_dead_letter_auto_trigger_skips( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange - 死信任务自动触发跳过 + fake_repos.task.get_by_task_id = AsyncMock( + return_value=_make_task(status="dead_letter") + ) + # Act + await service.execute_task("task-001", "run-001", "auto") + # Assert - 不创建 run_log + fake_repos.run_log.create.assert_not_awaited() + + @pytest.mark.asyncio + async def test_execute_dead_letter_manual_trigger_proceeds( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - 死信任务手动触发允许执行 + task = _make_task(status="dead_letter", handler_name="my_handler") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + handler = AsyncMock() + handler.execute = AsyncMock(return_value=TaskResult(success=True)) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + # Act + await service.execute_task("task-001", "run-001", "manual") + # Assert - 创建了 run_log 并执行了 handler + fake_repos.run_log.create.assert_awaited_once() + handler.execute.assert_awaited_once() + + @pytest.mark.asyncio + async def test_execute_success_path( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange + task = _make_task(handler_name="my_handler") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_repos.task.record_run_result = AsyncMock(return_value=task) + handler = AsyncMock() + handler.execute = AsyncMock(return_value=TaskResult(success=True, output={"rows": 1})) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + # Act + await service.execute_task("task-001", "run-001", "auto") + # Assert + fake_repos.task.record_run_result.assert_awaited_once() + call_kwargs = fake_repos.task.record_run_result.call_args.kwargs + assert call_kwargs["success"] is True + fake_repos.run_log.update.assert_awaited_once() + fake_repos.run_log_daily.upsert_daily_stat.assert_awaited_once() + + @pytest.mark.asyncio + async def test_execute_task_passes_payload_override_to_handler( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - 验证 payload_override 覆盖 task.payload + task = _make_task(handler_name="my_handler", payload={"original": True}) + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_repos.task.record_run_result = AsyncMock(return_value=task) + handler = AsyncMock() + handler.execute = AsyncMock(return_value=TaskResult(success=True)) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + override_payload = {"overridden": True} + # Act + await service.execute_task( + "task-001", "run-001", "manual", + payload_override=override_payload, + ) + # Assert - handler 收到的 ctx.payload 为 override 值 + ctx = handler.execute.call_args.args[0] + assert ctx.payload == override_payload + + @pytest.mark.asyncio + async def test_execute_task_passes_scheduled_at_to_handler_context( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - 验证 scheduled_at 传递到 TaskContext + task = _make_task(handler_name="my_handler") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_repos.task.record_run_result = AsyncMock(return_value=task) + handler = AsyncMock() + handler.execute = AsyncMock(return_value=TaskResult(success=True)) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + scheduled_at = datetime(2024, 1, 1, 0, 15, 0) + # Act + await service.execute_task( + "task-001", "run-001", "auto", + scheduled_at=scheduled_at, + ) + # Assert + ctx = handler.execute.call_args.args[0] + assert ctx.scheduled_at == scheduled_at + + @pytest.mark.asyncio + async def test_execute_task_uses_task_payload_when_no_override( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - 无 payload_override 时使用 task.payload + task = _make_task(handler_name="my_handler", payload={"default": True}) + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_repos.task.record_run_result = AsyncMock(return_value=task) + handler = AsyncMock() + handler.execute = AsyncMock(return_value=TaskResult(success=True)) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + # Act + await service.execute_task("task-001", "run-001", "auto") + # Assert + ctx = handler.execute.call_args.args[0] + assert ctx.payload == {"default": True} + + @pytest.mark.asyncio + async def test_execute_handler_not_registered_marks_failure( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange + task = _make_task(handler_name="missing_handler") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_repos.task.record_run_result = AsyncMock(return_value=task) + fake_runtime.handler_registry.get = MagicMock(return_value=None) + # Act + await service.execute_task("task-001", "run-001", "auto") + # Assert - 调用 failure 路径 + fake_repos.task.record_run_result.assert_awaited_once() + call_kwargs = fake_repos.task.record_run_result.call_args.kwargs + assert call_kwargs["success"] is False + assert "not registered" in call_kwargs["error"] + + @pytest.mark.asyncio + async def test_execute_handler_exception_marks_failure( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - handler.execute 抛异常 + task = _make_task(handler_name="my_handler") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_repos.task.record_run_result = AsyncMock(return_value=task) + handler = AsyncMock() + handler.execute = AsyncMock(side_effect=RuntimeError("handler boom")) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + # Act + await service.execute_task("task-001", "run-001", "auto") + # Assert + fake_repos.task.record_run_result.assert_awaited_once() + call_kwargs = fake_repos.task.record_run_result.call_args.kwargs + assert call_kwargs["success"] is False + assert "handler boom" in call_kwargs["error"] + + @pytest.mark.asyncio + async def test_execute_at_task_delete_after_run( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - 一次性任务执行成功后删除 + task = _make_task(schedule_kind="at", delete_after_run=True, handler_name="my_handler") + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_repos.task.record_run_result = AsyncMock(return_value=task) + handler = AsyncMock() + handler.execute = AsyncMock(return_value=TaskResult(success=True)) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + # Act + await service.execute_task("task-001", "run-001", "auto") + # Assert - 调用了 delete + fake_repos.task.delete.assert_awaited_once_with("task-001", commit=False) + + @pytest.mark.asyncio + async def test_execute_failure_marks_dead_letter_when_threshold_reached( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - consecutive_errors + 1 达到阈值 5 + task = _make_task(handler_name="my_handler", consecutive_errors=4) + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + # record_run_result 返回 consecutive_errors=5(达到阈值) + updated_task = _make_task(consecutive_errors=5) + fake_repos.task.record_run_result = AsyncMock(return_value=updated_task) + fake_repos.task.mark_dead_letter = AsyncMock(return_value=_make_task(status="dead_letter")) + handler = AsyncMock() + handler.execute = AsyncMock(return_value=TaskResult(success=False, error="boom")) + fake_runtime.handler_registry.get = MagicMock(return_value=handler) + # Act + await service.execute_task("task-001", "run-001", "auto") + # Assert - 标记死信 + fake_repos.task.mark_dead_letter.assert_awaited_once_with("task-001", commit=False) + + @pytest.mark.asyncio + async def test_execute_no_registry_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fake_runtime: FakeRuntimeServices, + fixed_now: datetime, + ): + # Arrange - api 进程 handler_registry=None + task = _make_task() + fake_repos.task.get_by_task_id = AsyncMock(return_value=task) + fake_repos.run_log.create = AsyncMock(return_value=_make_run_log()) + fake_runtime.handler_registry = None + # Act & Assert - 抛 SchedulerError(被 tick 的异常隔离捕获,但直接调用会抛出) + with pytest.raises(SchedulerError, match="handler registry not available"): + await service.execute_task("task-001", "run-001", "auto") + + +# ─── 健康检查与运维恢复 ─────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServiceGetHealth: + @pytest.mark.asyncio + async def test_get_health_healthy_with_recent_success( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange - 最近 5 分钟内有成功记录 + fake_repos.run_log.count_running = AsyncMock(return_value=0) + fake_repos.task.count_by_status = AsyncMock( + return_value={"active": 10, "paused": 2, "dead_letter": 1} + ) + recent_log = _make_run_log(started_at=fixed_now - timedelta(minutes=2)) + fake_repos.run_log.list_by_status = AsyncMock(return_value=([recent_log], 1)) + # Act + result = await service.get_health() + # Assert + assert result.status == "healthy" + assert result.active_task_count == 10 + assert result.dead_letter_count == 1 + assert result.running_count == 0 + + @pytest.mark.asyncio + async def test_get_health_healthy_with_running_count( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange - 无最近成功记录但有运行中任务 + fake_repos.run_log.count_running = AsyncMock(return_value=3) + fake_repos.task.count_by_status = AsyncMock(return_value={"active": 5}) + fake_repos.run_log.list_by_status = AsyncMock(return_value=([], 0)) + # Act + result = await service.get_health() + # Assert + assert result.status == "healthy" + assert result.running_count == 3 + + @pytest.mark.asyncio + async def test_get_health_unhealthy( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange - 无最近成功记录且无运行中任务 + fake_repos.run_log.count_running = AsyncMock(return_value=0) + fake_repos.task.count_by_status = AsyncMock(return_value={}) + fake_repos.run_log.list_by_status = AsyncMock(return_value=([], 0)) + # Act + result = await service.get_health() + # Assert + assert result.status == "unhealthy" + assert result.last_tick_at is None + + +@pytest.mark.unit +class TestSchedulerServiceReclaimStaleRuns: + @pytest.mark.asyncio + async def test_reclaim_with_default_timeout( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange - 使用 config.scheduler_task_timeout_seconds=600 + fake_repos.run_log.reclaim_stale_runs = AsyncMock(return_value=5) + # Act + result = await service.reclaim_stale_runs(ReclaimStaleRunsInput()) + # Assert + assert result.reclaimed_count == 5 + call_kwargs = fake_repos.run_log.reclaim_stale_runs.call_args.kwargs + expected_stale_before = fixed_now - timedelta(seconds=600) + assert call_kwargs["stale_before"] == expected_stale_before + + @pytest.mark.asyncio + async def test_reclaim_with_custom_timeout( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + fixed_now: datetime, + ): + # Arrange - 自定义 timeout_seconds=120 + fake_repos.run_log.reclaim_stale_runs = AsyncMock(return_value=2) + # Act + result = await service.reclaim_stale_runs(ReclaimStaleRunsInput(timeout_seconds=120)) + # Assert + assert result.reclaimed_count == 2 + call_kwargs = fake_repos.run_log.reclaim_stale_runs.call_args.kwargs + expected_stale_before = fixed_now - timedelta(seconds=120) + assert call_kwargs["stale_before"] == expected_stale_before + + @pytest.mark.asyncio + async def test_reclaim_zero_count( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + fake_repos.run_log.reclaim_stale_runs = AsyncMock(return_value=0) + result = await service.reclaim_stale_runs(ReclaimStaleRunsInput()) + assert result.reclaimed_count == 0 + + +# ─── 任务回收站 ─────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestSchedulerServiceListDeletedTasks: + @pytest.mark.asyncio + async def test_list_deleted_tasks_returns_items( + self, service: SchedulerService, fake_repos: FakeRepositories + ): + # Arrange + fake_repos.task.list_deleted = AsyncMock( + return_value=[_make_task(is_deleted=1)] + ) + # Act + result = await service.list_deleted_tasks( + ListDeletedTasksInput(page=1, page_size=20) + ) + # Assert + assert len(result.items) == 1 + assert result.total == 1 # total = len(items) + # 验证 offset 计算 + call_kwargs = fake_repos.task.list_deleted.call_args.kwargs + assert call_kwargs["limit"] == 20 + assert call_kwargs["offset"] == 0 + + @pytest.mark.asyncio + async def test_list_deleted_tasks_with_date_filters( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + fake_repos.task.list_deleted = AsyncMock(return_value=[]) + await service.list_deleted_tasks( + ListDeletedTasksInput( + start_date="2024-01-01T00:00:00", + end_date="2024-01-31T00:00:00", + ) + ) + call_kwargs = fake_repos.task.list_deleted.call_args.kwargs + assert call_kwargs["start_time"] == datetime(2024, 1, 1, 0, 0, 0) + assert call_kwargs["end_time"] == datetime(2024, 1, 31, 0, 0, 0) + + @pytest.mark.asyncio + async def test_list_deleted_tasks_pagination( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + fake_repos.task.list_deleted = AsyncMock(return_value=[]) + await service.list_deleted_tasks( + ListDeletedTasksInput(page=3, page_size=10) + ) + call_kwargs = fake_repos.task.list_deleted.call_args.kwargs + assert call_kwargs["limit"] == 10 + assert call_kwargs["offset"] == 20 # (3-1) * 10 + + +@pytest.mark.unit +class TestSchedulerServiceRestoreTask: + @pytest.mark.asyncio + async def test_restore_active_task_becomes_paused( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange - 恢复前 status=active,应自动置为 paused + deleted_task = _make_task(is_deleted=1, status="active", id=42) + fake_repos.task.get_deleted_by_task_id = AsyncMock(return_value=deleted_task) + fake_repos.task.restore_by_id = AsyncMock(return_value=1) + fake_repos.task.pause = AsyncMock(return_value=_make_task(status="paused")) + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task(status="paused")) + # Act + result = await service.restore_task(RestoreTaskInput(task_id="task-001")) + # Assert + fake_repos.task.restore_by_id.assert_awaited_once_with(42, updated_by=None, commit=False) + fake_repos.task.pause.assert_awaited_once_with("task-001", commit=False) + assert result.status == "paused" + + @pytest.mark.asyncio + async def test_restore_paused_task_not_re_paused( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange - 恢复前 status=paused,不再调用 pause + deleted_task = _make_task(is_deleted=1, status="paused", id=42) + fake_repos.task.get_deleted_by_task_id = AsyncMock(return_value=deleted_task) + fake_repos.task.restore_by_id = AsyncMock(return_value=1) + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task(status="paused")) + # Act + await service.restore_task(RestoreTaskInput(task_id="task-001")) + # Assert - 不调用 pause + fake_repos.task.pause.assert_not_awaited() + + @pytest.mark.asyncio + async def test_restore_not_in_trash_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + fake_repos.task.get_deleted_by_task_id = AsyncMock(return_value=None) + with pytest.raises(SchedulerEntityNotFoundError, match="不在回收站"): + await service.restore_task(RestoreTaskInput(task_id="nonexistent")) + + @pytest.mark.asyncio + async def test_restore_passes_updated_by_to_restore_by_id( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange - 验证 updated_by 传递到 restore_by_id + deleted_task = _make_task(is_deleted=1, status="paused", id=42) + fake_repos.task.get_deleted_by_task_id = AsyncMock(return_value=deleted_task) + fake_repos.task.restore_by_id = AsyncMock(return_value=1) + fake_repos.task.get_by_task_id = AsyncMock(return_value=_make_task(status="paused")) + # Act + await service.restore_task( + RestoreTaskInput(task_id="task-001", updated_by="admin") + ) + # Assert + fake_repos.task.restore_by_id.assert_awaited_once_with( + 42, updated_by="admin", commit=False + ) + + +@pytest.mark.unit +class TestSchedulerServiceHardDeleteTask: + @pytest.mark.asyncio + async def test_hard_delete_success( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + # Arrange + deleted_task = _make_task(is_deleted=1, id=42) + fake_repos.task.get_deleted_by_task_id = AsyncMock(return_value=deleted_task) + fake_repos.task.hard_delete_by_id = AsyncMock(return_value=1) + # Act + await service.hard_delete_task(HardDeleteTaskInput(task_id="task-001")) + # Assert - hard_delete_task 未使用 UnitOfWork,依赖仓储默认自动提交 + fake_repos.task.hard_delete_by_id.assert_awaited_once_with(42) + + @pytest.mark.asyncio + async def test_hard_delete_not_in_trash_raises( + self, + service: SchedulerService, + fake_repos: FakeRepositories, + ): + fake_repos.task.get_deleted_by_task_id = AsyncMock(return_value=None) + with pytest.raises(SchedulerEntityNotFoundError, match="不在回收站"): + await service.hard_delete_task(HardDeleteTaskInput(task_id="nonexistent")) diff --git a/backend/test/unit/scheduler/use_cases/utils/test_backoff.py b/backend/test/unit/scheduler/use_cases/utils/test_backoff.py new file mode 100644 index 00000000..ff52e3b9 --- /dev/null +++ b/backend/test/unit/scheduler/use_cases/utils/test_backoff.py @@ -0,0 +1,134 @@ +"""指数退避计算工具单元测试。 + +覆盖 ``yuxi.scheduler.use_cases.utils.backoff`` 模块: +- ``calc_backoff_seconds``:根据连续错误数从退避序列取退避秒数 +- ``calc_backoff_next_run_at``:计算退避后的下次运行时间 +""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +import pytest + +from yuxi.scheduler.use_cases.utils.backoff import ( + calc_backoff_next_run_at, + calc_backoff_seconds, +) + + +pytestmark = pytest.mark.unit + + +# ─── calc_backoff_seconds ───────────────────────────────────────────────── + + +@pytest.mark.unit +class TestCalcBackoffSeconds: + @pytest.mark.parametrize( + ("errors", "expected"), + [ + (0, 0), # 无错误无退避 + (-1, 0), # 负数视为无错误 + (-100, 0), # 大负数同样视为无错误 + ], + ids=["zero", "negative-one", "large-negative"], + ) + def test_non_positive_errors_return_zero(self, errors: int, expected: int): + schedule = [10, 30, 120, 600, 3600] + assert calc_backoff_seconds(errors, schedule) == expected + + @pytest.mark.parametrize( + ("errors", "expected"), + [ + (1, 10), # 第 1 次错误取 schedule[0] + (2, 30), # 第 2 次错误取 schedule[1] + (3, 120), # 第 3 次错误取 schedule[2] + (4, 600), # 第 4 次错误取 schedule[3] + (5, 3600), # 第 5 次错误取 schedule[4] + ], + ids=["first", "second", "third", "fourth", "fifth"], + ) + def test_error_count_within_schedule_returns_indexed_value( + self, errors: int, expected: int + ): + schedule = [10, 30, 120, 600, 3600] + assert calc_backoff_seconds(errors, schedule) == expected + + @pytest.mark.parametrize( + ("errors", "expected"), + [ + (3, 120), # 边界:consecutive_errors == len(schedule) + (10, 120), # 超过序列长度取最后一个值 + (100, 120), # 远超序列长度仍取最后一个值 + ], + ids=["at-length", "exceeds", "far-exceeds"], + ) + def test_error_count_exceeding_schedule_caps_at_last( + self, errors: int, expected: int + ): + schedule = [10, 30, 120] + assert calc_backoff_seconds(errors, schedule) == expected + + def test_empty_schedule_returns_zero(self): + # Arrange - 空退避序列 + schedule: list[int] = [] + # Act + result = calc_backoff_seconds(5, schedule) + # Assert + assert result == 0 + + def test_single_element_schedule(self): + # Arrange - 单元素序列 + schedule = [60] + # Act & Assert + assert calc_backoff_seconds(1, schedule) == 60 + assert calc_backoff_seconds(5, schedule) == 60 + + +# ─── calc_backoff_next_run_at ───────────────────────────────────────────── + + +@pytest.mark.unit +class TestCalcBackoffNextRunAt: + @pytest.mark.parametrize( + ("errors", "expected_offset"), + [ + (0, 0), # 无错误时退避秒数为 0,返回 base_time + (1, 10), # 第 1 次错误加 10 秒 + (3, 120), # 第 3 次错误加 120 秒 + (100, 120), # 超过序列长度封顶为 120 秒 + ], + ids=["zero-errors", "first-error", "third-error", "exceeds-cap"], + ) + def test_backoff_next_run_at_offsets( + self, errors: int, expected_offset: int + ): + # Arrange + base = datetime(2024, 1, 1, 0, 0, 0) + schedule = [10, 30, 120] + # Act + result = calc_backoff_next_run_at(errors, schedule, base_time=base) + # Assert + assert result == base + timedelta(seconds=expected_offset) + + def test_default_base_time_is_recent(self, monkeypatch: pytest.MonkeyPatch): + # Arrange - mock utc_now_naive 返回固定时间 + fixed_now = datetime(2024, 6, 1, 12, 0, 0) + import yuxi.scheduler.use_cases.utils.backoff as backoff_mod + + monkeypatch.setattr(backoff_mod, "utc_now_naive", lambda: fixed_now) + schedule = [60] + # Act + result = calc_backoff_next_run_at(1, schedule) + # Assert + assert result == fixed_now + timedelta(seconds=60) + + def test_empty_schedule_returns_base_time(self): + # Arrange + base = datetime(2024, 1, 1, 0, 0, 0) + schedule: list[int] = [] + # Act + result = calc_backoff_next_run_at(5, schedule, base_time=base) + # Assert + assert result == base diff --git a/backend/test/unit/scheduler/use_cases/utils/test_cron_calc.py b/backend/test/unit/scheduler/use_cases/utils/test_cron_calc.py new file mode 100644 index 00000000..cb6a533e --- /dev/null +++ b/backend/test/unit/scheduler/use_cases/utils/test_cron_calc.py @@ -0,0 +1,157 @@ +"""cron 下次触发时间计算工具单元测试。 + +覆盖 ``yuxi.scheduler.use_cases.utils.cron_calc`` 模块的 ``calc_next_run_at`` 函数: +- 无时区参数:base_time 作为 naive datetime 直接交给 croniter +- 有时区参数:按该时区解释 cron 表达式,结果返回 UTC naive datetime +- 默认 base_time:``utc_now_naive()`` +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +import pytest + +from yuxi.scheduler.use_cases.utils.cron_calc import calc_next_run_at + + +pytestmark = pytest.mark.unit + + +# ─── calc_next_run_at(无时区) ──────────────────────────────────────────── + + +@pytest.mark.unit +class TestCalcNextRunAtNoTimezone: + def test_daily_cron_returns_next_day(self): + # Arrange - 每天凌晨执行 + base = datetime(2024, 1, 1, 12, 0, 0) + expr = "0 0 * * *" + # Act + result = calc_next_run_at(expr, base_time=base) + # Assert - 下一次执行为 2024-01-02 00:00 + assert result == datetime(2024, 1, 2, 0, 0, 0) + + def test_hourly_cron_returns_next_hour(self): + # Arrange + base = datetime(2024, 1, 1, 12, 30, 0) + expr = "0 * * * *" + # Act + result = calc_next_run_at(expr, base_time=base) + # Assert + assert result == datetime(2024, 1, 1, 13, 0, 0) + + def test_every_15_minutes(self): + # Arrange + base = datetime(2024, 1, 1, 12, 5, 0) + expr = "*/15 * * * *" + # Act + result = calc_next_run_at(expr, base_time=base) + # Assert + assert result == datetime(2024, 1, 1, 12, 15, 0) + + def test_specific_time_today_if_future(self): + # Arrange - base 时间在指定时刻之前,下一次执行为当天 + base = datetime(2024, 1, 1, 8, 0, 0) + expr = "30 10 * * *" + # Act + result = calc_next_run_at(expr, base_time=base) + # Assert + assert result == datetime(2024, 1, 1, 10, 30, 0) + + def test_specific_time_tomorrow_if_past(self): + # Arrange - base 时间在指定时刻之后,下一次执行为次日 + base = datetime(2024, 1, 1, 12, 0, 0) + expr = "30 10 * * *" + # Act + result = calc_next_run_at(expr, base_time=base) + # Assert + assert result == datetime(2024, 1, 2, 10, 30, 0) + + def test_returns_naive_datetime(self): + # Arrange + base = datetime(2024, 1, 1, 12, 0, 0) + # Act + result = calc_next_run_at("0 * * * *", base_time=base) + # Assert - 返回 naive datetime(与 DB 字段存储约定一致) + assert result.tzinfo is None + + def test_default_base_time_is_recent(self, monkeypatch: pytest.MonkeyPatch): + # Arrange - mock utc_now_naive 返回固定时间 + fixed_now = datetime(2024, 6, 1, 12, 0, 0) + import yuxi.scheduler.use_cases.utils.cron_calc as cron_mod + + monkeypatch.setattr(cron_mod, "utc_now_naive", lambda: fixed_now) + # Act + result = calc_next_run_at("0 * * * *") + # Assert + assert result == datetime(2024, 6, 1, 13, 0, 0) + + +# ─── calc_next_run_at(有时区) ──────────────────────────────────────────── + + +@pytest.mark.unit +class TestCalcNextRunAtWithTimezone: + def test_tz_returns_utc_naive(self): + # Arrange - Asia/Shanghai 时区 + base = datetime(2024, 1, 1, 12, 0, 0) # UTC 12:00 = Shanghai 20:00 + expr = "0 0 * * *" # 每天 00:00 Shanghai 时间 + # Act + result = calc_next_run_at(expr, base_time=base, tz="Asia/Shanghai") + # Assert - Shanghai 00:00 = UTC 16:00 前一天,但 base 是 1/1 12:00 UTC, + # 下一次 Shanghai 00:00 是 1/2 00:00 Shanghai = 1/1 16:00 UTC + assert result == datetime(2024, 1, 1, 16, 0, 0) + assert result.tzinfo is None + + def test_tz_hourly_cron(self): + # Arrange - base UTC 12:30 = Shanghai 20:30 + base = datetime(2024, 1, 1, 12, 30, 0) + expr = "0 * * * *" # 每小时整点 Shanghai 时间 + # Act + result = calc_next_run_at(expr, base_time=base, tz="Asia/Shanghai") + # Assert - 下一次 Shanghai 整点是 21:00 = UTC 13:00 + assert result == datetime(2024, 1, 1, 13, 0, 0) + + def test_tz_utc_zero_offset(self): + # Arrange - UTC 时区,与无时区行为一致 + base = datetime(2024, 1, 1, 12, 0, 0) + expr = "0 * * * *" + # Act + result_no_tz = calc_next_run_at(expr, base_time=base) + result_utc = calc_next_run_at(expr, base_time=base, tz="UTC") + # Assert + assert result_no_tz == result_utc + + def test_tz_default_base_time(self, monkeypatch: pytest.MonkeyPatch): + # Arrange + fixed_now = datetime(2024, 6, 1, 12, 0, 0) + import yuxi.scheduler.use_cases.utils.cron_calc as cron_mod + + monkeypatch.setattr(cron_mod, "utc_now_naive", lambda: fixed_now) + # Act + result = calc_next_run_at("0 * * * *", tz="Asia/Shanghai") + # Assert - 返回 naive datetime + assert result.tzinfo is None + # Shanghai 20:00 = UTC 12:00,下一次 Shanghai 整点 21:00 = UTC 13:00 + assert result == datetime(2024, 6, 1, 13, 0, 0) + + +# ─── 异常场景 ───────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestCalcNextRunAtErrors: + def test_invalid_cron_raises_value_error(self): + # Arrange + base = datetime(2024, 1, 1, 12, 0, 0) + # Act & Assert - croniter 抛出 ValueError + with pytest.raises(ValueError): + calc_next_run_at("invalid", base_time=base) + + def test_invalid_tz_raises_key_error_or_value_error(self): + # Arrange - 非法时区 + base = datetime(2024, 1, 1, 12, 0, 0) + # Act & Assert - ZoneInfo 抛出 KeyError 或 ValueError + with pytest.raises((KeyError, ValueError)): + calc_next_run_at("0 * * * *", base_time=base, tz="Invalid/Timezone") diff --git a/backend/test/unit/scheduler/use_cases/utils/test_stagger.py b/backend/test/unit/scheduler/use_cases/utils/test_stagger.py new file mode 100644 index 00000000..6619a5f7 --- /dev/null +++ b/backend/test/unit/scheduler/use_cases/utils/test_stagger.py @@ -0,0 +1,111 @@ +"""整点抖动计算工具单元测试。 + +覆盖 ``yuxi.scheduler.use_cases.utils.stagger`` 模块的 ``calc_stagger_seconds`` 函数: +- 基于 task_id 哈希计算固定抖动值 +- 同一 task_id 多次计算得到相同结果(固定性) +- 抖动值范围 ``[0, max_seconds]`` +""" + +from __future__ import annotations + +import hashlib + +import pytest + +from yuxi.scheduler.use_cases.utils.stagger import calc_stagger_seconds + + +pytestmark = pytest.mark.unit + + +# ─── 固定性 ─────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestStaggerDeterminism: + def test_same_task_id_returns_same_value(self): + # Arrange + task_id = "task-001" + # Act + result1 = calc_stagger_seconds(task_id, max_seconds=300) + result2 = calc_stagger_seconds(task_id, max_seconds=300) + # Assert - 同一 task_id 多次计算得到相同结果 + assert result1 == result2 + + def test_different_task_ids_may_return_different_values(self): + # Arrange - 两个不同 task_id,至少有一个不同(哈希分布) + # 使用足够大的 max_seconds 确保差异可见 + task_id1 = "task-001" + task_id2 = "task-002" + # Act + result1 = calc_stagger_seconds(task_id1, max_seconds=300) + result2 = calc_stagger_seconds(task_id2, max_seconds=300) + # Assert - 大概率不同(哈希分布均匀) + assert result1 != result2 + + +# ─── 范围 ───────────────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestStaggerRange: + @pytest.mark.parametrize( + "max_seconds", + [1, 10, 100, 300, 3600], + ids=["max-1", "max-10", "max-100", "max-300", "max-3600"], + ) + def test_result_within_range_for_various_max_seconds(self, max_seconds: int): + # Act & Assert - 多个 task_id 在不同 max_seconds 下都应在范围内 + for i in range(50): + result = calc_stagger_seconds(f"task-{i:03d}", max_seconds=max_seconds) + assert 0 <= result <= max_seconds + + def test_result_within_range_for_many_task_ids(self): + # Arrange - 多个 task_id 都应在范围内 + max_seconds = 100 + # Act & Assert + for i in range(100): + result = calc_stagger_seconds(f"task-{i:03d}", max_seconds=max_seconds) + assert 0 <= result <= max_seconds + + @pytest.mark.parametrize( + ("max_seconds", "valid_values"), + [ + (0, {0}), # max_seconds=0 时模 1,结果必为 0 + (1, {0, 1}), # max_seconds=1 时模 2,结果为 0 或 1 + ], + ids=["max-zero", "max-one"], + ) + def test_boundary_max_seconds(self, max_seconds: int, valid_values: set[int]): + # Act & Assert + for i in range(50): + result = calc_stagger_seconds(f"task-{i}", max_seconds=max_seconds) + assert result in valid_values + + +# ─── 哈希算法一致性 ─────────────────────────────────────────────────────── + + +@pytest.mark.unit +class TestStaggerHashAlgorithm: + def test_uses_md5_hash_of_task_id(self): + # Arrange - 手动计算 MD5 哈希取模,验证实现与文档一致 + task_id = "task-001" + max_seconds = 300 + expected_digest = hashlib.md5(task_id.encode("utf-8")).hexdigest() + expected = int(expected_digest, 16) % (max_seconds + 1) + # Act + result = calc_stagger_seconds(task_id, max_seconds=max_seconds) + # Assert + assert result == expected + + def test_unicode_task_id_handled(self): + # Arrange - task_id 含非 ASCII 字符 + task_id = "任务-001" + max_seconds = 300 + expected_digest = hashlib.md5(task_id.encode("utf-8")).hexdigest() + expected = int(expected_digest, 16) % (max_seconds + 1) + # Act + result = calc_stagger_seconds(task_id, max_seconds=max_seconds) + # Assert + assert result == expected