"""DepUpdateEngine — dedupe by (repo, command) + single open-task fetch. Two unit tests (no DB): a monorepo with two distinct ``dep_update_command`` values is not blocked by ``git_url`` alone (one open task per command, not one per repo), and ``run_cycle`` fetches the open-task set exactly once regardless of project count (the per-project scoped query is gone). """ 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.dep_update_engine import DepUpdateEngine def _project(slug: str, git_url: str, command: str) -> SimpleNamespace: return SimpleNamespace( id=uuid4(), slug=slug, git_url=git_url, dep_update_command=command ) def _open_task(project: Any) -> SimpleNamespace: # run_cycle reads t.project.git_url / t.project.dep_update_command. return SimpleNamespace(id=uuid4(), project_id=project.id, project=project) class _FakeWorkspace: def __init__(self, updates: bool = True) -> None: self._updates = updates async def dry_upgrade_changes_lockfile(self, _project: Any) -> bool: return self._updates def _make_engine( task_svc: Any, workspace: _FakeWorkspace | None = None ) -> tuple[DepUpdateEngine, Any]: session = MagicMock() engine = DepUpdateEngine(session, workspace=workspace or _FakeWorkspace()) patcher = patch( "roboco.services.dep_update_engine.get_task_service", return_value=task_svc ) patcher.start() return engine, patcher @pytest.fixture def _enabled(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(settings, "dep_update_enabled", True) monkeypatch.setattr(settings, "dep_update_max_per_cycle", 10) monkeypatch.setattr(settings, "dep_update_max_open_tasks", 10) @pytest.mark.asyncio async def test_dedupe_spared_by_command_not_git_url(_enabled: None) -> None: """A monorepo with two distinct dep_update_command values is not blocked by git_url alone — one open task per command, not one per repo.""" git_url = "https://github.com/x/mono.git" p1 = _project("mono-uv", git_url, "uv lock -U") p2 = _project("mono-pnpm", git_url, "pnpm update -L") open_task = _open_task(p1) task_svc = MagicMock() task_svc.list_open_dep_update_tasks = AsyncMock(return_value=[open_task]) task_svc.create = AsyncMock( side_effect=lambda req: SimpleNamespace(id=uuid4(), project_id=req.project_id) ) engine, patcher = _make_engine(task_svc) try: created = await engine.run_cycle([p1, p2]) finally: patcher.stop() # p1 deduped (open task already covers (mono, "uv lock -U")); # p2 still eligible (different command) -> opened. assert len(created) == 1 assert created[0].project_id == p2.id @pytest.mark.asyncio async def test_run_cycle_fetches_open_tasks_once(_enabled: None) -> None: """run_cycle fetches the open-task set exactly once regardless of project count — the per-project scoped query is gone.""" project_count = 5 projects = [ _project(f"p{i}", f"https://github.com/x/r{i}.git", f"cmd{i}") for i in range(project_count) ] task_svc = MagicMock() task_svc.list_open_dep_update_tasks = AsyncMock(return_value=[]) task_svc.create = AsyncMock( side_effect=lambda req: SimpleNamespace(id=uuid4(), project_id=req.project_id) ) engine, patcher = _make_engine(task_svc) try: created = await engine.run_cycle(projects) finally: patcher.stop() assert len(created) == project_count # One fetch for both the cap and the per-(repo, command) dedupe — # not one per project. assert task_svc.list_open_dep_update_tasks.call_count == 1