Files
roboco/tests/unit/services/test_git_diff_and_files.py
T
89254f796c [62845be1] Fix claim_review/evidence 120s timeout: dedupe git calls, parallelize DB reads, bound conventions-validator timeout, add bounded-timeout guard (#756)
* [62845be1] test(gateway): prove claim_review/evidence timeout fix — dedup, timeout guards

Add the test coverage the acceptance criteria require but the existing
implementation lacked: a git.py-level test proving diff_and_files
resolves the shared workspace/token/head/base state exactly once, and
bounded-timeout tests exercising the actual gateway_timeout trip for
evidence(), claim_review, and the /api/git/diff route. Also fixes
pre-existing ruff-format drift and a mypy no-any-return in the same
fix cluster (content_actions.py, git.py, qa.py, routes/git.py) so
make gate passes clean.

* [62845be1] fix(gateway): add missing evidence_assembly_timeout_seconds/conventions_validator_timeout_seconds settings and Envelope.gateway_timeout classmethod

The claim_review/evidence()/roboco_git_diff bounded-timeout fix referenced
settings.evidence_assembly_timeout_seconds, settings.conventions_validator_timeout_seconds,
and Envelope.gateway_timeout() but none of the three were ever defined,
so every code path that hit the timeout guard raised AttributeError instead
of returning the structured envelope. Added both Settings fields (90s/45s
defaults, well under flow_verb_timeout_seconds) and the Envelope.gateway_timeout
classmethod matching the wire shape api/middleware.py's outer 504 already uses.

* [62845be1] docs(services): document claim_review/evidence timeout fix — dedup, parallelization, bounded timeouts

---------

Co-authored-by: Backend Developer 1 <be-dev-1@roboco.tech>
Co-authored-by: Backend Documenter <be-doc@roboco.tech>
2026-07-31 12:55:52 +00:00

102 lines
4.1 KiB
Python

"""Task #62845be1: ``diff_and_files`` resolves shared state ONCE.
``diff()`` and ``list_changed_files()`` each independently re-resolve the
workspace, auth token, head ref, and diff base before running their own
``git diff`` subprocess — duplicated work when a caller (evidence assembly)
needs both. ``diff_and_files`` resolves that shared state a single time,
then runs the two ``git diff`` subprocesses concurrently.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from roboco.services.git import GitService
_BR = "feature/backend/root1234--cellpm56--dev78901"
def _git_service() -> Any:
# A real constructor (not __new__) so ``self.log`` is bound —
# ``diff_and_files`` logs its own resolve/diff timing.
return GitService(MagicMock())
@pytest.mark.asyncio
async def test_diff_and_files_resolves_shared_state_once() -> None:
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/qa-ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=f"origin/{_BR}")
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
captured: list[list[str]] = []
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
captured.append(args)
if args[:2] == ["diff", "--name-only"]:
return type(
"R", (), {"returncode": 0, "stdout": "README.md\nsrc/app.py\n"}
)()
return type("R", (), {"returncode": 0, "stdout": "diff body"})()
with patch.object(svc, "_run_git", new=fake_run):
diff, files = await svc.diff_and_files(branch_name=_BR)
assert diff == "diff body"
assert files == ["README.md", "src/app.py"]
# The shared resolution work runs exactly ONCE, not once per sub-call.
svc._workspace_for_branch.assert_awaited_once()
svc._token_for_branch.assert_awaited_once()
svc._resolve_head_ref.assert_awaited_once()
svc._resolve_diff_base.assert_awaited_once()
# Both the `diff` and `diff --name-only` subprocesses still ran, off the
# same resolved base...head pair.
assert any(c == ["diff", f"origin/master...origin/{_BR}"] for c in captured)
assert any(
c == ["diff", "--name-only", f"origin/master...origin/{_BR}"] for c in captured
)
@pytest.mark.asyncio
async def test_diff_and_files_honors_explicit_base_and_preferred_parent() -> None:
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/dev-ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=_BR)
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
async def fake_run(_ws: Any, _args: list[str], **_kw: Any) -> Any:
return type("R", (), {"returncode": 0, "stdout": ""})()
with patch.object(svc, "_run_git", new=fake_run):
await svc.diff_and_files(
branch_name=_BR, base="HEAD~1", preferred_parent="feature/backend/other"
)
# An explicit base skips _resolve_diff_base entirely.
svc._resolve_diff_base.assert_not_awaited()
@pytest.mark.asyncio
async def test_diff_and_files_matches_diff_and_list_changed_files_output() -> None:
"""Combined accessor returns the same data the two separate calls would."""
svc = _git_service()
svc._workspace_for_branch = AsyncMock(return_value=Path("/tmp/ws"))
svc._token_for_branch = AsyncMock(return_value="tok")
svc._resolve_head_ref = AsyncMock(return_value=f"origin/{_BR}")
svc._resolve_diff_base = AsyncMock(return_value="origin/master")
async def fake_run(_ws: Any, args: list[str], **_kw: Any) -> Any:
if args[:2] == ["diff", "--name-only"]:
return type("R", (), {"returncode": 0, "stdout": "a.py\nb.py\n"})()
return type("R", (), {"returncode": 0, "stdout": "full diff body"})()
with patch.object(svc, "_run_git", new=fake_run):
diff, files = await svc.diff_and_files(branch_name=_BR)
assert diff == "full diff body"
assert files == ["a.py", "b.py"]