631 lines
24 KiB
Python
631 lines
24 KiB
Python
|
|
"""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
|