Files
roboco/tests/unit/services/test_conventions_resolve.py
T

157 lines
5.6 KiB
Python
Raw Normal View History

"""ConventionsService root/HEAD resolution + backfill persistence (no DB)."""
from __future__ import annotations
import asyncio
import subprocess
from types import SimpleNamespace
2026-06-29 05:38:21 +02:00
from typing import TYPE_CHECKING, Any, cast
from unittest.mock import patch
import pytest
from roboco.services.conventions import ConventionsService
if TYPE_CHECKING:
from pathlib import Path
from roboco.db.tables import ProjectTable
def _git_repo(root: Path) -> str:
(root / "roboco" / "services").mkdir(parents=True)
(root / "roboco" / "services" / "x.py").write_text("def f():\n return 1\n")
for cmd in (
["git", "init", "-q"],
["git", "add", "-A"],
["git", "-c", "user.email=t@t", "-c", "user.name=t", "commit", "-qm", "i"],
):
subprocess.run(cmd, cwd=root, check=True, capture_output=True)
return subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=root,
capture_output=True,
text=True,
check=False,
).stdout.strip()
def _svc() -> ConventionsService:
2026-06-29 05:38:21 +02:00
return ConventionsService(session=cast("Any", None))
def test_resolve_reads_clone_head_returns_raw_sha_no_mutation(tmp_path: Path) -> None:
sha = _git_repo(tmp_path)
project = SimpleNamespace(workspace_path=None, head_commit=None, slug="p")
root, sha_raw = _svc()._resolve(cast("ProjectTable", project), tmp_path)
assert root == tmp_path
assert sha_raw == sha
# _resolve no longer mutates the ORM; callers mutate on the event loop.
assert project.workspace_path is None
assert project.head_commit is None
def test_resolve_non_git_path_returns_raw_sha_none(tmp_path: Path) -> None:
project = SimpleNamespace(
workspace_path=str(tmp_path), head_commit="deadbeef", slug="p"
)
_root, sha = _svc()._resolve(cast("ProjectTable", project), None)
# Raw rev-parse returned None; the caller's `if sha is not None` guard
# preserves the persisted head_commit (no mutation inside _resolve).
assert sha is None
assert project.head_commit == "deadbeef"
def test_resolve_no_workspace_returns_none_root() -> None:
project = SimpleNamespace(workspace_path=None, head_commit=None, slug="p")
root, sha = _svc()._resolve(cast("ProjectTable", project), None)
assert root is None
assert sha is None
def test_head_sha_at_non_git_returns_none(tmp_path: Path) -> None:
assert ConventionsService._head_sha_at(tmp_path) is None
@pytest.mark.asyncio
async def test_resolve_workspace_force_refetches() -> None:
"""resolve_workspace must pass force=True so conventions reads always see
the current default-branch HEAD (no 30s stale-map window after a merge)."""
captured: dict[str, object] = {}
class _FakeWS:
async def ensure_read_clone(self, slug: str, *, force: bool = False) -> Path:
captured["slug"] = slug
captured["force"] = force
return cast("Path", "/fake-clone")
project = SimpleNamespace(slug="p", workspace_path=None, head_commit=None)
with patch(
"roboco.services.workspace.get_workspace_service",
return_value=_FakeWS(),
):
root = await _svc().resolve_workspace(cast("ProjectTable", project))
assert root is not None and str(root) == "/fake-clone"
assert captured["slug"] == "p"
assert captured["force"] is True
@pytest.mark.asyncio
async def test_resolve_does_not_mutate_orm(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
# _resolve must return (root, sha_raw) and NOT touch project.workspace_path
# or project.head_commit. The caller mutates on the event loop, not the
# worker thread.
svc = _svc()
project = SimpleNamespace(
workspace_path=None, head_commit="preexisting-sha", slug="p"
)
monkeypatch.setattr(svc, "_head_sha_at", lambda _root: "raw-abc123")
monkeypatch.setattr(svc, "_workspace_root", lambda _p: tmp_path)
root, sha = await asyncio.to_thread(
svc._resolve, cast("ProjectTable", project), None
)
assert root == tmp_path
assert sha == "raw-abc123"
# the worker-thread call did NOT mutate the ORM:
assert project.workspace_path is None
assert project.head_commit == "preexisting-sha"
# the caller then mutates on the event loop (the production contract):
if root is not None:
project.workspace_path = str(root)
if sha is not None:
project.head_commit = sha
assert project.workspace_path == str(tmp_path)
assert project.head_commit == "raw-abc123"
@pytest.mark.asyncio
async def test_resolve_raw_sha_none_for_non_git_path(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
# _head_sha_at returns None (non-git path) -> _resolve returns (root, None),
# and the caller's guard leaves project.head_commit untouched.
svc = _svc()
project = SimpleNamespace(
workspace_path=None, head_commit="persisted-keep-me", slug="p"
)
monkeypatch.setattr(svc, "_head_sha_at", lambda _root: None)
monkeypatch.setattr(svc, "_workspace_root", lambda _p: tmp_path)
root, sha = await asyncio.to_thread(
svc._resolve, cast("ProjectTable", project), None
)
assert sha is None
# caller guard: sha is None -> do NOT touch head_commit (the inner
# `if sha is not None` branch is deliberately omitted here — sha is None,
# so the guard's body is dead code; the persisted head_commit must survive).
if root is not None:
project.workspace_path = str(root)
assert project.head_commit == "persisted-keep-me" # guard preserved
# and head (cache key) falls back:
head = sha or svc._head_sha(cast("ProjectTable", project))
assert head == "persisted-keep-me"