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