mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
260 lines
8.3 KiB
Python
260 lines
8.3 KiB
Python
"""DocsSyncEngine — originate one docs-update task per release, bounded + deduped.
|
|||
|
|
|
||
|
|
Mirrors the dep-update engine unit-test style: mocked TaskService/ProjectService
|
||
|
|
so the engine's logic can be exercised without a real Postgres + pgvector setup.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from types import SimpleNamespace
|
||
|
|
from typing import Any
|
||
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||
|
|
from uuid import uuid4
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
from roboco.config import settings
|
||
|
|
from roboco.services.docs_sync_engine import DocsSyncEngine
|
||
|
|
|
||
|
|
|
||
|
|
def _project(project_id: Any, slug: str, git_url: str) -> SimpleNamespace:
|
||
|
|
return SimpleNamespace(id=project_id, slug=slug, git_url=git_url)
|
||
|
|
|
||
|
|
|
||
|
|
def _task(task_id: Any, project_id: Any, version: str | None = None) -> SimpleNamespace:
|
||
|
|
markers: dict[str, Any] = {}
|
||
|
|
if version is not None:
|
||
|
|
markers["docs_sync_release_version"] = version
|
||
|
|
return SimpleNamespace(
|
||
|
|
id=task_id,
|
||
|
|
project_id=project_id,
|
||
|
|
orchestration_markers=markers,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def _make_engine(project_svc: Any, task_svc: Any) -> tuple[DocsSyncEngine, list[Any]]:
|
||
|
|
session = MagicMock()
|
||
|
|
session.flush = AsyncMock(return_value=None)
|
||
|
|
engine = DocsSyncEngine(session)
|
||
|
|
patchers = [
|
||
|
|
patch(
|
||
|
|
"roboco.services.docs_sync_engine.get_project_service",
|
||
|
|
return_value=project_svc,
|
||
|
|
),
|
||
|
|
patch(
|
||
|
|
"roboco.services.docs_sync_engine.get_task_service",
|
||
|
|
return_value=task_svc,
|
||
|
|
),
|
||
|
|
]
|
||
|
|
for p in patchers:
|
||
|
|
p.start()
|
||
|
|
return engine, patchers
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def _enabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||
|
|
monkeypatch.setattr(settings, "docs_sync_enabled", True)
|
||
|
|
monkeypatch.setattr(settings, "docs_sync_max_open_tasks", 3)
|
||
|
|
monkeypatch.setattr(settings, "docs_sync_max_per_cycle", 1)
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_enabled_opens_one_docs_update_task(_enabled: None) -> None:
|
||
|
|
project_id = uuid4()
|
||
|
|
project = _project(
|
||
|
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||
|
|
)
|
||
|
|
created = _task(uuid4(), project_id, "0.23.0")
|
||
|
|
|
||
|
|
project_svc = MagicMock()
|
||
|
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[])
|
||
|
|
task_svc.create = AsyncMock(return_value=created)
|
||
|
|
|
||
|
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||
|
|
try:
|
||
|
|
result = await engine.originate_docs_update(
|
||
|
|
version="0.23.0",
|
||
|
|
changelog="## [0.23.0]\n\n### Added\n- docs-sync engine\n",
|
||
|
|
)
|
||
|
|
finally:
|
||
|
|
for p in patchers:
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
assert result is not None
|
||
|
|
assert result.id == created.id
|
||
|
|
assert result.orchestration_markers is not None
|
||
|
|
assert result.orchestration_markers.get("docs_sync_release_version") == "0.23.0"
|
||
|
|
task_svc.create.assert_awaited_once()
|
||
|
|
req = task_svc.create.await_args.args[0]
|
||
|
|
assert req.project_id == project_id
|
||
|
|
assert req.source == "docs_sync"
|
||
|
|
assert "docs-sync engine" in req.description
|
||
|
|
assert "Divergence checklist" in req.description
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_same_version_is_deduped(_enabled: None) -> None:
|
||
|
|
project_id = uuid4()
|
||
|
|
project = _project(
|
||
|
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||
|
|
)
|
||
|
|
open_task = _task(uuid4(), project_id, "0.23.0")
|
||
|
|
|
||
|
|
project_svc = MagicMock()
|
||
|
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[open_task])
|
||
|
|
task_svc.create = AsyncMock()
|
||
|
|
|
||
|
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||
|
|
try:
|
||
|
|
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||
|
|
finally:
|
||
|
|
for p in patchers:
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
assert result is None
|
||
|
|
task_svc.create.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_different_versions_open_distinct_tasks(_enabled: None) -> None:
|
||
|
|
project_id = uuid4()
|
||
|
|
project = _project(
|
||
|
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||
|
|
)
|
||
|
|
open_task = _task(uuid4(), project_id, "0.23.0")
|
||
|
|
new_task = _task(uuid4(), project_id, "0.24.0")
|
||
|
|
|
||
|
|
project_svc = MagicMock()
|
||
|
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_docs_sync_tasks = AsyncMock(
|
||
|
|
side_effect=lambda version=None: (
|
||
|
|
[open_task]
|
||
|
|
if version == "0.23.0"
|
||
|
|
else ([] if version == "0.24.0" else [open_task])
|
||
|
|
)
|
||
|
|
)
|
||
|
|
task_svc.create = AsyncMock(return_value=new_task)
|
||
|
|
|
||
|
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||
|
|
try:
|
||
|
|
result = await engine.originate_docs_update(version="0.24.0", changelog="y")
|
||
|
|
finally:
|
||
|
|
for p in patchers:
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
assert result is not None
|
||
|
|
assert result.id == new_task.id
|
||
|
|
task_svc.create.assert_awaited_once()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_disabled_is_noop(
|
||
|
|
monkeypatch: pytest.MonkeyPatch,
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setattr(settings, "docs_sync_enabled", False)
|
||
|
|
project_svc = MagicMock()
|
||
|
|
project_svc.get_by_slug = AsyncMock()
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.create = AsyncMock()
|
||
|
|
|
||
|
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||
|
|
try:
|
||
|
|
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||
|
|
finally:
|
||
|
|
for p in patchers:
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
assert result is None
|
||
|
|
assert project_svc.get_by_slug.await_count == 0
|
||
|
|
assert task_svc.create.await_count == 0
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_missing_project_warns_and_returns_none(
|
||
|
|
_enabled: None, caplog: pytest.LogCaptureFixture
|
||
|
|
) -> None:
|
||
|
|
project_svc = MagicMock()
|
||
|
|
project_svc.get_by_slug = AsyncMock(return_value=None)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.create = AsyncMock()
|
||
|
|
|
||
|
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||
|
|
try:
|
||
|
|
with caplog.at_level("WARNING", logger="roboco.services.docs_sync_engine"):
|
||
|
|
result = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||
|
|
finally:
|
||
|
|
for p in patchers:
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
assert result is None
|
||
|
|
assert "roboco-website" in caplog.text
|
||
|
|
assert "not registered" in caplog.text
|
||
|
|
assert task_svc.create.await_count == 0
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_open_task_cap_is_enforced(
|
||
|
|
_enabled: None, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
monkeypatch.setattr(settings, "docs_sync_max_open_tasks", 1)
|
||
|
|
project_id = uuid4()
|
||
|
|
project = _project(
|
||
|
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||
|
|
)
|
||
|
|
open_task = _task(uuid4(), project_id, "0.23.0")
|
||
|
|
|
||
|
|
project_svc = MagicMock()
|
||
|
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_docs_sync_tasks = AsyncMock(return_value=[open_task])
|
||
|
|
task_svc.create = AsyncMock()
|
||
|
|
|
||
|
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||
|
|
try:
|
||
|
|
result = await engine.originate_docs_update(version="0.24.0", changelog="y")
|
||
|
|
finally:
|
||
|
|
for p in patchers:
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
assert result is None
|
||
|
|
task_svc.create.assert_not_awaited()
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_per_cycle_cap_is_enforced(
|
||
|
|
_enabled: None, monkeypatch: pytest.MonkeyPatch
|
||
|
|
) -> None:
|
||
|
|
"""Once the per-cycle cap is reached, further calls on the same engine no-op."""
|
||
|
|
monkeypatch.setattr(settings, "docs_sync_max_per_cycle", 1)
|
||
|
|
project_id = uuid4()
|
||
|
|
project = _project(
|
||
|
|
project_id, "roboco-website", "https://github.com/x/roboco-website.git"
|
||
|
|
)
|
||
|
|
first_task = _task(uuid4(), project_id, "0.23.0")
|
||
|
|
second_task = _task(uuid4(), project_id, "0.24.0")
|
||
|
|
|
||
|
|
project_svc = MagicMock()
|
||
|
|
project_svc.get_by_slug = AsyncMock(return_value=project)
|
||
|
|
task_svc = MagicMock()
|
||
|
|
task_svc.list_open_docs_sync_tasks = AsyncMock(
|
||
|
|
side_effect=lambda version=None: [first_task] if version is None else []
|
||
|
|
)
|
||
|
|
task_svc.create = AsyncMock(side_effect=[first_task, second_task])
|
||
|
|
|
||
|
|
engine, patchers = _make_engine(project_svc, task_svc)
|
||
|
|
try:
|
||
|
|
first = await engine.originate_docs_update(version="0.23.0", changelog="x")
|
||
|
|
second = await engine.originate_docs_update(version="0.24.0", changelog="y")
|
||
|
|
finally:
|
||
|
|
for p in patchers:
|
||
|
|
p.stop()
|
||
|
|
|
||
|
|
assert first is not None
|
||
|
|
assert first.id == first_task.id
|
||
|
|
assert second is None
|
||
|
|
task_svc.create.assert_awaited_once()
|