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

631 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

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

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