- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
682 lines
26 KiB
Python
682 lines
26 KiB
Python
# #region Test.GitService.Status [C:3] [TYPE Module] [SEMANTICS test,git,status,diff,history]
|
|
# @BRIEF Tests for GitServiceStatusMixin — parse porcelain, get_status, get_diff, get_commit_history.
|
|
# @RELATION BINDS_TO -> [Services.Status.GitServiceStatusMixin]
|
|
# @TEST_EDGE: empty_porcelain -> no output returns empty lists
|
|
# @TEST_EDGE: untracked_files -> ?? prefix parsed as untracked
|
|
# @TEST_EDGE: staged_and_modified -> XY prefix correctly split
|
|
# @TEST_EDGE: renamed_files -> -> arrow parsed as rename target
|
|
# @TEST_EDGE: git_status_failure -> exception caught, warning logged
|
|
# @TEST_EDGE: no_commits -> has_commits=false, branch name still returned
|
|
# @TEST_EDGE: divergent_branch -> ahead>0 AND behind>0 → DIVERGED
|
|
# @TEST_EDGE: ahead_remote -> ahead>0, behind=0 → AHEAD_REMOTE
|
|
# @TEST_EDGE: behind_remote -> ahead=0, behind>0 → BEHIND_REMOTE
|
|
# @TEST_EDGE: dirty_repo -> changes present → CHANGES
|
|
# @TEST_EDGE: clean_synced -> no changes, no ahead/behind → SYNCED
|
|
# @TEST_EDGE: diff_with_file -> file_path passed to git diff
|
|
# @TEST_EDGE: diff_staged -> --staged flag passed
|
|
# @TEST_EDGE: commit_history_empty -> no heads, no remotes returns []
|
|
# @TEST_EDGE: commit_history_exception -> exception logged, returns []
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
from src.services.git._status import GitServiceStatusMixin
|
|
|
|
|
|
# ── Helper: create a testable instance of the mixin ──
|
|
|
|
class TestableGitStatus(GitServiceStatusMixin):
|
|
"""Concrete test class providing the minimum _locked and get_repo stubs.
|
|
|
|
_locked is a no-op context manager. get_repo returns a pre-set mock.
|
|
"""
|
|
def __init__(self, mock_repo=None):
|
|
self._mock_repo = mock_repo or MagicMock()
|
|
self._lock_called = False
|
|
|
|
async def get_repo(self, dashboard_id):
|
|
return self._mock_repo
|
|
|
|
def _locked(self, dashboard_id):
|
|
import contextlib
|
|
@contextlib.contextmanager
|
|
def _lock():
|
|
self._lock_called = True
|
|
yield
|
|
return _lock()
|
|
|
|
|
|
# ── _parse_status_porcelain ──
|
|
|
|
class TestParseStatusPorcelain:
|
|
"""_parse_status_porcelain — git status --porcelain parser."""
|
|
|
|
# #region Test.GitService.TestPorcelainEmptyOutput [C:2] [TYPE Function]
|
|
def test_porcelain_empty_output(self):
|
|
"""Empty output → all lists empty."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = ""
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
assert staged == []
|
|
assert modified == []
|
|
assert untracked == []
|
|
# #endregion Test.GitService.TestPorcelainEmptyOutput
|
|
|
|
# #region Test.GitService.TestPorcelainUntrackedFiles [C:2] [TYPE Function]
|
|
def test_porcelain_untracked_files(self):
|
|
"""?? prefixed lines appear in untracked list."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = "?? new_file.txt\n?? untracked_dir/\n"
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
assert untracked == ["new_file.txt", "untracked_dir/"]
|
|
assert staged == []
|
|
assert modified == []
|
|
# #endregion Test.GitService.TestPorcelainUntrackedFiles
|
|
|
|
# #region Test.GitService.TestPorcelainStaged [C:2] [TYPE Function]
|
|
def test_porcelain_staged(self):
|
|
"""XY where X!=space → staged."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = "M staged.txt\nA added.py\n"
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
assert staged == ["staged.txt", "added.py"]
|
|
assert modified == []
|
|
# #endregion Test.GitService.TestPorcelainStaged
|
|
|
|
# #region Test.GitService.TestPorcelainModified [C:2] [TYPE Function]
|
|
def test_porcelain_modified(self):
|
|
"""XY where Y!=space → modified."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = " M modified.rs\n"
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
assert modified == ["modified.rs"]
|
|
assert staged == []
|
|
# #endregion Test.GitService.TestPorcelainModified
|
|
|
|
# #region Test.GitService.TestPorcelainStagedAndModified [C:2] [TYPE Function]
|
|
def test_porcelain_staged_and_modified(self):
|
|
"""MM → both staged and modified."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = "MM both.txt\n"
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
assert "both.txt" in staged
|
|
assert "both.txt" in modified
|
|
# #endregion Test.GitService.TestPorcelainStagedAndModified
|
|
|
|
# #region Test.GitService.TestPorcelainRenamed [C:2] [TYPE Function]
|
|
def test_porcelain_renamed(self):
|
|
"""R old -> new → staged shows new path."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = "R old_name.py -> new_name.py\n"
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
assert "new_name.py" in staged
|
|
assert "old_name.py" not in str(staged)
|
|
# #endregion Test.GitService.TestPorcelainRenamed
|
|
|
|
# #region Test.GitService.TestPorcelainExcludeIntentToAdd [C:2] [TYPE Function]
|
|
def test_porcelain_exclude_intent_to_add(self):
|
|
"""!! lines (intent-to-add/assume-unchanged) are skipped."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = "!! ignored_pattern\n"
|
|
_, _, untracked = svc._parse_status_porcelain(repo)
|
|
assert untracked == []
|
|
# #endregion Test.GitService.TestPorcelainExcludeIntentToAdd
|
|
|
|
# #region Test.GitService.TestPorcelainShortLine [C:2] [TYPE Function]
|
|
def test_porcelain_short_line(self):
|
|
"""Line with <3 chars is skipped (e.g., empty/broken output)."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.return_value = "A\nBB\n M file.txt\n"
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
# "A\n" and "BB\n" are skipped (< 3 chars). Only " M file.txt" is parsed.
|
|
assert "file.txt" in modified
|
|
assert len(staged) == 0
|
|
# #endregion Test.GitService.TestPorcelainShortLine
|
|
|
|
# #region Test.GitService.TestPorcelainGitFailure [C:2] [TYPE Function]
|
|
def test_porcelain_git_failure(self):
|
|
"""Exception from git.status returns empty lists, no crash."""
|
|
svc = TestableGitStatus()
|
|
repo = MagicMock()
|
|
repo.git.status.side_effect = Exception("git not available")
|
|
staged, modified, untracked = svc._parse_status_porcelain(repo)
|
|
assert staged == []
|
|
assert modified == []
|
|
assert untracked == []
|
|
# #endregion Test.GitService.TestPorcelainGitFailure
|
|
|
|
|
|
# ── get_status ──
|
|
|
|
class TestGetStatus:
|
|
"""get_status — full repository status computation."""
|
|
|
|
# #region Test.GitService.TestGetStatusNoCommits [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_no_commits(self):
|
|
"""Repository with no commits: has_commits=false, branch returned."""
|
|
from unittest.mock import PropertyMock
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
# Accessing repo.head.commit as a PROPERTY raises ValueError (like real GitPython)
|
|
type(head).commit = PropertyMock(side_effect=ValueError("no commits"))
|
|
repo.head = head
|
|
repo.active_branch.name = "main"
|
|
repo.active_branch.tracking_branch.return_value = None
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert svc._lock_called is True
|
|
assert result["current_branch"] == "main"
|
|
assert result["has_upstream"] is False
|
|
assert result["last_commit_hash"] is None
|
|
assert result["last_commit_author"] is None
|
|
# #endregion Test.GitService.TestGetStatusNoCommits
|
|
|
|
# #region Test.GitService.TestGetStatusDiverged [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_diverged(self):
|
|
"""ahead>0 and behind>0 → DIVERGED."""
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.commit = MagicMock()
|
|
repo.head = head
|
|
repo.active_branch.name = "feature"
|
|
tracking = MagicMock()
|
|
tracking.name = "origin/feature"
|
|
repo.active_branch.tracking_branch.return_value = tracking
|
|
# iter_commits for ahead: first call returns [1,2], second returns [3,4,5]
|
|
repo.iter_commits.side_effect = [
|
|
[MagicMock(), MagicMock()], # ahead: 2 commits
|
|
[MagicMock(), MagicMock(), MagicMock()], # behind: 3 commits
|
|
]
|
|
repo.git.status.return_value = ""
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert result["sync_state"] == "DIVERGED"
|
|
assert result["ahead_count"] == 2
|
|
assert result["behind_count"] == 3
|
|
assert result["is_diverged"] is True
|
|
# #endregion Test.GitService.TestGetStatusDiverged
|
|
|
|
# #region Test.GitService.TestGetStatusAhead [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_ahead(self):
|
|
"""ahead>0, behind=0 → AHEAD_REMOTE."""
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.commit = MagicMock()
|
|
repo.head = head
|
|
repo.active_branch.name = "feature"
|
|
tracking = MagicMock()
|
|
repo.active_branch.tracking_branch.return_value = tracking
|
|
repo.iter_commits.side_effect = [
|
|
[MagicMock()], # ahead: 1 commit
|
|
[], # behind: 0
|
|
]
|
|
repo.git.status.return_value = ""
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert result["sync_state"] == "AHEAD_REMOTE"
|
|
assert result["ahead_count"] == 1
|
|
assert result["behind_count"] == 0
|
|
# #endregion Test.GitService.TestGetStatusAhead
|
|
|
|
# #region Test.GitService.TestGetStatusBehind [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_behind(self):
|
|
"""ahead=0, behind>0 → BEHIND_REMOTE."""
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.commit = MagicMock(
|
|
hexsha="abcdef1234567890",
|
|
message="Update BI dashboard\n\nDetails",
|
|
committed_date=1710000000,
|
|
)
|
|
head.commit.author.name = "BI Analyst"
|
|
repo.head = head
|
|
repo.active_branch.name = "main"
|
|
tracking = MagicMock()
|
|
repo.active_branch.tracking_branch.return_value = tracking
|
|
repo.iter_commits.side_effect = [
|
|
[], # ahead: 0
|
|
[MagicMock()], # behind: 1
|
|
]
|
|
repo.git.status.return_value = ""
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert result["sync_state"] == "BEHIND_REMOTE"
|
|
assert result["ahead_count"] == 0
|
|
assert result["behind_count"] == 1
|
|
# #endregion Test.GitService.TestGetStatusBehind
|
|
|
|
# #region Test.GitService.TestGetStatusDirty [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_dirty(self):
|
|
"""Unstaged changes present → CHANGES."""
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.commit = MagicMock()
|
|
repo.head = head
|
|
repo.active_branch.name = "main"
|
|
repo.active_branch.tracking_branch.return_value = None
|
|
repo.git.status.return_value = " M modified.txt\n?? new.txt\n"
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert result["sync_state"] == "CHANGES"
|
|
assert result["is_dirty"] is True
|
|
assert "modified.txt" in result["modified_files"]
|
|
assert "new.txt" in result["untracked_files"]
|
|
# #endregion Test.GitService.TestGetStatusDirty
|
|
|
|
# #region Test.GitService.TestGetStatusSynced [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_synced(self):
|
|
"""Clean, no divergence → SYNCED."""
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.commit = MagicMock(
|
|
hexsha="abcdef1234567890",
|
|
message="Update BI dashboard\n\nDetails",
|
|
committed_date=1710000000,
|
|
)
|
|
head.commit.author.name = "BI Analyst"
|
|
repo.head = head
|
|
repo.active_branch.name = "main"
|
|
repo.active_branch.tracking_branch.return_value = None
|
|
repo.git.status.return_value = ""
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert result["sync_state"] == "SYNCED"
|
|
assert result["is_dirty"] is False
|
|
assert result["last_commit_hash"] == "abcdef1234567890"
|
|
assert result["last_commit_message"] == "Update BI dashboard\n\nDetails"
|
|
assert result["last_commit_author"] == "BI Analyst"
|
|
assert result["last_commit_date"].startswith("2024-03-09T")
|
|
# #endregion Test.GitService.TestGetStatusSynced
|
|
|
|
# #region Test.GitService.TestGetStatusTrackingException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_tracking_exception(self):
|
|
"""Exception in tracking_branch() → has_upstream=false."""
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.commit = MagicMock()
|
|
repo.head = head
|
|
repo.active_branch.name = "main"
|
|
repo.active_branch.tracking_branch.side_effect = Exception("no remote")
|
|
repo.git.status.return_value = ""
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert result["has_upstream"] is False
|
|
assert result["upstream_branch"] is None
|
|
# #endregion Test.GitService.TestGetStatusTrackingException
|
|
|
|
# #region Test.GitService.TestGetStatusIterCommitsException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_iter_commits_exception(self):
|
|
"""Exception in iter_commits → ahead=0, behind=0."""
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.commit = MagicMock()
|
|
repo.head = head
|
|
repo.active_branch.name = "main"
|
|
tracking = MagicMock()
|
|
repo.active_branch.tracking_branch.return_value = tracking
|
|
repo.iter_commits.side_effect = Exception("git error")
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
|
|
assert result["ahead_count"] == 0
|
|
assert result["behind_count"] == 0
|
|
# #endregion Test.GitService.TestGetStatusIterCommitsException
|
|
|
|
|
|
# ── rollback_commit ──
|
|
|
|
class TestRollbackCommit:
|
|
"""rollback_commit — creates a revert commit without rewriting history."""
|
|
|
|
# #region Test.GitService.TestRollbackCommitRevertsTarget [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_rollback_commit_reverts_target(self):
|
|
repo = MagicMock()
|
|
repo.head.commit.hexsha = "rollback123"
|
|
svc = TestableGitStatus(repo)
|
|
|
|
result = await svc.rollback_commit(42, "abcdef1", "bad PROD deploy")
|
|
|
|
repo.commit.assert_called_once_with("abcdef1")
|
|
repo.git.revert.assert_called_once_with("--no-edit", "abcdef1")
|
|
assert result["status"] == "success"
|
|
assert result["reverted_commit"] == "abcdef1"
|
|
assert result["rollback_commit"] == "rollback123"
|
|
# #endregion Test.GitService.TestRollbackCommitRevertsTarget
|
|
|
|
|
|
# ── get_diff ──
|
|
|
|
class TestGetDiff:
|
|
"""get_diff — diff generation."""
|
|
|
|
# #region Test.GitService.TestGetDiffNoArgs [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_diff_no_args(self):
|
|
"""Default diff (no file, not staged)."""
|
|
repo = MagicMock()
|
|
repo.git.diff.return_value = "diff --git a/file.txt b/file.txt"
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_diff(42)
|
|
assert result == "diff --git a/file.txt b/file.txt"
|
|
repo.git.diff.assert_called_once_with()
|
|
# #endregion Test.GitService.TestGetDiffNoArgs
|
|
|
|
# #region Test.GitService.TestGetDiffStaged [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_diff_staged(self):
|
|
"""--staged flag included."""
|
|
repo = MagicMock()
|
|
repo.git.diff.return_value = "staged diff"
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_diff(42, staged=True)
|
|
assert result == "staged diff"
|
|
repo.git.diff.assert_called_once_with("--staged")
|
|
# #endregion Test.GitService.TestGetDiffStaged
|
|
|
|
# #region Test.GitService.TestGetDiffWithFile [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_diff_with_file(self):
|
|
"""File path passed with -- separator."""
|
|
repo = MagicMock()
|
|
repo.git.diff.return_value = "file diff"
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_diff(42, file_path="src/main.py")
|
|
assert result == "file diff"
|
|
repo.git.diff.assert_called_once_with("--", "src/main.py")
|
|
# #endregion Test.GitService.TestGetDiffWithFile
|
|
|
|
# #region Test.GitService.TestGetDiffStagedWithFile [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_diff_staged_with_file(self):
|
|
"""Both staged and file_path passed."""
|
|
repo = MagicMock()
|
|
repo.git.diff.return_value = "staged file diff"
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_diff(42, file_path="test.py", staged=True)
|
|
assert result == "staged file diff"
|
|
repo.git.diff.assert_called_once_with("--staged", "--", "test.py")
|
|
# #endregion Test.GitService.TestGetDiffStagedWithFile
|
|
|
|
|
|
# ── get_commit_history ──
|
|
|
|
class TestGetCommitHistory:
|
|
"""get_commit_history — commit log retrieval."""
|
|
|
|
# #region Test.GitService.TestCommitHistoryNoHeadsNoRemotes [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_commit_history_no_heads_no_remotes(self):
|
|
"""No heads and no remotes → returns []."""
|
|
repo = MagicMock()
|
|
repo.heads = []
|
|
repo.remotes = []
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_commit_history(42)
|
|
assert result == []
|
|
# #endregion Test.GitService.TestCommitHistoryNoHeadsNoRemotes
|
|
|
|
# #region Test.GitService.TestCommitHistoryReturnsCommits [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_commit_history_returns_commits(self):
|
|
"""Returns parsed commit dicts from iter_commits."""
|
|
import datetime
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.remotes = [MagicMock()]
|
|
|
|
commit1 = MagicMock()
|
|
commit1.hexsha = "abc123"
|
|
commit1.author.name = "Alice"
|
|
commit1.author.email = "alice@example.com"
|
|
commit1.committed_date = 1700000000
|
|
commit1.message = "First commit\n"
|
|
commit1.stats.files = {"file1.py": {}, "file2.py": {}}
|
|
|
|
repo.iter_commits.return_value = [commit1]
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_commit_history(42, limit=10)
|
|
|
|
assert len(result) == 1
|
|
assert result[0]["hash"] == "abc123"
|
|
assert result[0]["author"] == "Alice"
|
|
assert result[0]["email"] == "alice@example.com"
|
|
assert result[0]["message"] == "First commit"
|
|
assert "file1.py" in result[0]["files_changed"]
|
|
repo.iter_commits.assert_called_once_with(max_count=10)
|
|
# #endregion Test.GitService.TestCommitHistoryReturnsCommits
|
|
|
|
# #region Test.GitService.TestCommitHistoryException [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_commit_history_exception(self):
|
|
"""Exception during iteration returns []."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.iter_commits.side_effect = Exception("git error")
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_commit_history(42)
|
|
|
|
assert result == []
|
|
# #endregion Test.GitService.TestCommitHistoryException
|
|
|
|
# #region Test.GitService.TestCommitHistoryLimitDefault [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_commit_history_limit_default(self):
|
|
"""Default limit is 50."""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock()]
|
|
repo.remotes = [MagicMock()]
|
|
repo.iter_commits.return_value = []
|
|
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_commit_history(42)
|
|
|
|
repo.iter_commits.assert_called_once_with(max_count=50)
|
|
assert result == []
|
|
# #endregion Test.GitService.TestCommitHistoryLimitDefault
|
|
|
|
# #region Test.GitService.TestGetBranchCommitsMissingProdBranch [C:2] [TYPE Function]
|
|
@pytest.mark.asyncio
|
|
async def test_get_branch_commits_missing_prod_branch(self):
|
|
"""Missing 'prod' branch (common on legacy dashboard 1 repos) → returns [] without git fatal error log.
|
|
Closes the exact prod log symptom: 'fatal: bad revision 'prod'' for branch prod on dashboard 1.
|
|
"""
|
|
repo = MagicMock()
|
|
repo.heads = [MagicMock(name="dev")] # no 'prod'
|
|
repo.remotes = []
|
|
svc = TestableGitStatus(repo)
|
|
|
|
result = await svc.get_branch_commits(1, "prod")
|
|
|
|
assert result == []
|
|
# Should not have called iter_commits (early return)
|
|
repo.iter_commits.assert_not_called()
|
|
# #endregion Test.GitService.TestGetBranchCommitsMissingProdBranch
|
|
# #endregion Test.GitService.Status
|
|
|
|
|
|
# #region Test.Git.Status.AdditionalBranches [C:3] [TYPE Module]
|
|
# @defgroup get_status metadata, get_branch_commits, get_commit_diff branches.
|
|
|
|
class TestGetStatusMetadata:
|
|
"""get_status — commit metadata happy path and failure branch."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_commit_metadata_happy(self):
|
|
repo = MagicMock()
|
|
commit = MagicMock()
|
|
commit.hexsha = "abc123"
|
|
commit.message = " fix: thing "
|
|
commit.author.name = "dev"
|
|
commit.committed_date = 1700000000
|
|
repo.head.commit = commit
|
|
repo.active_branch.name = "main"
|
|
repo.active_branch.tracking_branch.return_value = None
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
assert result["last_commit_hash"] == "abc123"
|
|
assert result["last_commit_message"] == "fix: thing"
|
|
assert result["last_commit_author"] == "dev"
|
|
assert result["last_commit_date"] is not None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_status_commit_metadata_exception(self):
|
|
repo = MagicMock()
|
|
commit = MagicMock()
|
|
commit.committed_date = "not-a-timestamp" # fromtimestamp raises TypeError
|
|
repo.head.commit = commit
|
|
repo.active_branch.name = "main"
|
|
repo.active_branch.tracking_branch.return_value = None
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_status(42)
|
|
assert result["last_commit_hash"] is not None or result["last_commit_hash"] is None
|
|
assert result["last_commit_date"] is None
|
|
# #endregion Test.Git.Status.AdditionalBranches.Metadata
|
|
|
|
|
|
class TestGetBranchCommits:
|
|
"""get_branch_commits — missing branch and failure branches."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_branch_not_in_local_heads_returns_empty(self):
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.name = "main"
|
|
repo.heads = [head]
|
|
repo.remotes = [MagicMock()]
|
|
repo.iter_commits = MagicMock()
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_branch_commits(42, "develop")
|
|
assert result == []
|
|
repo.iter_commits.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_heads_no_remotes_returns_empty(self):
|
|
repo = MagicMock()
|
|
repo.heads = []
|
|
repo.remotes = []
|
|
svc = TestableGitStatus(repo)
|
|
assert await svc.get_branch_commits(42, "main") == []
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_iter_commits_exception_returns_empty(self):
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.name = "main"
|
|
repo.heads = [head]
|
|
repo.remotes = [MagicMock()]
|
|
repo.iter_commits.side_effect = Exception("bad revision")
|
|
svc = TestableGitStatus(repo)
|
|
assert await svc.get_branch_commits(42, "main") == []
|
|
# #endregion Test.Git.Status.AdditionalBranches.BranchCommits
|
|
|
|
|
|
class TestGetCommitDiffExtra:
|
|
"""get_commit_diff — to_ref variant and failure."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_with_to_ref(self):
|
|
repo = MagicMock()
|
|
repo.git.diff.return_value = "diff v1 v2"
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_commit_diff(42, "v1", "v2")
|
|
assert result == "diff v1 v2"
|
|
repo.git.diff.assert_called_once_with("v1", "v2")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_without_to_ref(self):
|
|
repo = MagicMock()
|
|
repo.git.diff.return_value = "diff v1"
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.get_commit_diff(42, "v1")
|
|
assert result == "diff v1"
|
|
repo.git.diff.assert_called_once_with("v1")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_failure_reraises(self):
|
|
repo = MagicMock()
|
|
repo.git.diff.side_effect = Exception("boom")
|
|
svc = TestableGitStatus(repo)
|
|
with pytest.raises(Exception, match="boom"):
|
|
await svc.get_commit_diff(42, "v1")
|
|
# #endregion Test.Git.Status.AdditionalBranches.CommitDiff
|
|
|
|
|
|
class TestGetBranchCommitsHappy:
|
|
"""get_branch_commits — success path."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_happy_path_builds_commit_list(self):
|
|
repo = MagicMock()
|
|
head = MagicMock()
|
|
head.name = "main"
|
|
repo.heads = [head]
|
|
repo.remotes = [MagicMock()]
|
|
commit = MagicMock()
|
|
commit.hexsha = "abc123"
|
|
commit.author.name = "dev"
|
|
commit.author.email = "d@x.com"
|
|
commit.committed_date = 1700000000
|
|
commit.message = " feat: x "
|
|
commit.stats.files.keys.return_value = ["a.py", "b.py"]
|
|
repo.iter_commits.return_value = [commit]
|
|
svc = TestableGitStatus(repo)
|
|
commits = await svc.get_branch_commits(42, "main", limit=5)
|
|
assert len(commits) == 1
|
|
assert commits[0]["hash"] == "abc123"
|
|
assert commits[0]["message"] == "feat: x"
|
|
assert commits[0]["files_changed"] == ["a.py", "b.py"]
|
|
assert commits[0]["branch"] == "main"
|
|
repo.iter_commits.assert_called_once_with("main", max_count=5)
|
|
# #endregion Test.Git.Status.AdditionalBranches.Happy
|
|
|
|
|
|
class TestRollbackCommitNoReason:
|
|
"""rollback_commit without a reason — skips the logging block."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_without_reason(self):
|
|
repo = MagicMock()
|
|
repo.head.commit.hexsha = "rollback456"
|
|
svc = TestableGitStatus(repo)
|
|
result = await svc.rollback_commit(42, "abcdef2")
|
|
assert result["status"] == "success"
|
|
repo.commit.assert_called_once_with("abcdef2")
|