ForcePilot/backend/test/unit/scheduler/adapters/persistence/test_repos.py

631 lines
24 KiB
Python
Raw Normal View History

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