# #region Test.Api.GitRepoOperationsRoutes [C:3] [TYPE Module] [SEMANTICS test,git,operations,commit,push,pull,status,diff,history] # @BRIEF Tests for Git repository operations API routes. # @RELATION BINDS_TO -> [GitRepoOperationsRoutes] import os os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:") os.environ.setdefault("SECRET_KEY", "test-secret-key-for-tests") os.environ.setdefault("DEV_MODE", "true") import sys from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient _src = str(Path(__file__).resolve().parent.parent.parent / "src") if _src not in sys.path: sys.path.insert(0, _src) def _make_client(overrides: dict | None = None) -> TestClient: from src.api.routes.git._router import router as parent_router from src.core.database import get_db from src.dependencies import get_current_user from src.schemas.auth import User, RoleSchema app = FastAPI() app.include_router(parent_router) mock_user = User( id="admin-1", username="admin", email="admin@x.com", auth_source="LOCAL", created_at=__import__("datetime").datetime.now(), roles=[RoleSchema(id="r1", name="Admin", description="", permissions=[])], ) app.dependency_overrides[get_db] = lambda: MagicMock() app.dependency_overrides[get_current_user] = lambda: mock_user if overrides: for dep, fn in overrides.items(): app.dependency_overrides[dep] = fn return TestClient(app) # ── commit_changes ── class TestCommitChanges: """POST /repositories/{dashboard_ref}/commit""" COMMIT_PAYLOAD = {"message": "feat: add dashboard", "files": ["dash.json"]} def test_success(self): mock_gs = MagicMock() with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.api.routes.git._repo_operations_routes._apply_git_identity_from_profile", AsyncMock()), ): client = _make_client() resp = client.post("/repositories/42/commit", json=self.COMMIT_PAYLOAD) assert resp.status_code == 200 assert resp.json()["status"] == "success" def test_unexpected_error(self): mock_gs = MagicMock() mock_gs.commit_changes = AsyncMock(return_value=RuntimeError("boom")) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.api.routes.git._repo_operations_routes._apply_git_identity_from_profile", AsyncMock()), ): client = _make_client() resp = client.post("/repositories/42/commit", json=self.COMMIT_PAYLOAD) assert resp.status_code == 500 # ── push_changes ── class TestPushChanges: """POST /repositories/{dashboard_ref}/push""" def test_success(self): mock_gs = MagicMock() with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.post("/repositories/42/push") assert resp.status_code == 200 assert resp.json()["status"] == "success" def test_unexpected_error(self): mock_gs = MagicMock() mock_gs.push_changes = AsyncMock(return_value=RuntimeError("boom")) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.post("/repositories/42/push") assert resp.status_code == 500 # ── pull_changes ── class TestPullChanges: """POST /repositories/{dashboard_ref}/pull""" def test_success(self): mock_gs = MagicMock() mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None from src.core.database import get_db with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.api.routes.git._repo_operations_routes._apply_git_identity_from_profile", AsyncMock()), ): client = _make_client({get_db: lambda: mock_db}) resp = client.post("/repositories/42/pull") assert resp.status_code == 200 assert resp.json()["status"] == "success" def test_success_with_repo_binding(self): mock_gs = MagicMock() mock_repo = MagicMock() mock_repo.config_id = "cfg-1" mock_repo.local_path = "/tmp/repo" mock_repo.remote_url = "https://url" mock_config = MagicMock() mock_config.url = "https://gitea.example.com" mock_config.provider = "GITEA" mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.side_effect = [mock_repo, mock_config] from src.core.database import get_db with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.api.routes.git._repo_operations_routes._apply_git_identity_from_profile", AsyncMock()), ): client = _make_client({get_db: lambda: mock_db}) resp = client.post("/repositories/42/pull") assert resp.status_code == 200 def test_unexpected_error(self): mock_gs = MagicMock() mock_gs.pull_changes = AsyncMock(return_value=RuntimeError("boom")) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.api.routes.git._repo_operations_routes._apply_git_identity_from_profile", AsyncMock()), ): client = _make_client() resp = client.post("/repositories/42/pull") assert resp.status_code == 500 # ── get_repository_status ── class TestGetRepositoryStatus: """GET /repositories/{dashboard_ref}/status""" def test_success(self): with ( patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value={"is_dirty": False, "sync_state": "CLEAN"})), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/status") assert resp.status_code == 200 data = resp.json() assert data["is_dirty"] is False assert data["sync_state"] == "CLEAN" def test_unexpected_error(self): with ( patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(side_effect=RuntimeError("boom"))), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/status") assert resp.status_code == 500 # ── get_repository_status_batch ── class TestGetRepositoryStatusBatch: """POST /repositories/status/batch""" def test_success(self): mock_status = {"is_dirty": False, "sync_state": "CLEAN", "has_repo": True} with ( patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value=mock_status)), ): client = _make_client() resp = client.post("/repositories/status/batch", json={"dashboard_ids": [1, 2, 3]}) assert resp.status_code == 200 data = resp.json() assert "statuses" in data assert "1" in data["statuses"] assert len(data["statuses"]) == 3 def test_with_duplicate_ids(self): mock_status = {"is_dirty": False, "sync_state": "CLEAN", "has_repo": True} with ( patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value=mock_status)), ): client = _make_client() resp = client.post("/repositories/status/batch", json={"dashboard_ids": [1, 1, 2, 2]}) assert resp.status_code == 200 assert len(resp.json()["statuses"]) == 2 def test_with_http_error_on_one(self): def side_effect(did): if did == 1: raise HTTPException(404) return {"is_dirty": False, "has_repo": True} with ( patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(side_effect=side_effect)), ): client = _make_client() resp = client.post("/repositories/status/batch", json={"dashboard_ids": [1, 2]}) assert resp.status_code == 200 statuses = resp.json()["statuses"] assert statuses["1"]["sync_state"] == "ERROR" assert statuses["2"]["has_repo"] is True def test_truncates_large_batch(self): mock_status = {"is_dirty": False} many_ids = list(range(100)) with ( patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(return_value=mock_status)), ): client = _make_client() resp = client.post("/repositories/status/batch", json={"dashboard_ids": many_ids}) assert resp.status_code == 200 assert len(resp.json()["statuses"]) == 50 # ── get_repository_diff ── class TestGetRepositoryDiff: """GET /repositories/{dashboard_ref}/diff""" def test_success(self): mock_gs = MagicMock() mock_gs.get_diff = AsyncMock(return_value={"diff": "--- a/dash.json\n+++ b/dash.json"}) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/diff") assert resp.status_code == 200 assert "diff" in resp.json() def test_with_file_filter(self): mock_gs = MagicMock() mock_gs.get_diff = AsyncMock(return_value={"diff": "file diff"}) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/diff?file_path=dash.json&staged=true") assert resp.status_code == 200 def test_unexpected_error(self): mock_gs = MagicMock() mock_gs.get_diff = AsyncMock(return_value=RuntimeError("boom")) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/diff") assert resp.status_code == 500 # ── get_history ── class TestGetHistory: """GET /repositories/{dashboard_ref}/history""" def test_success(self): from datetime import datetime mock_gs = MagicMock() mock_commit = MagicMock() mock_commit.hash = "abc123" mock_commit.author = "test" mock_commit.email = "test@x.com" mock_commit.timestamp = datetime.now() mock_commit.message = "feat: add" mock_commit.files_changed = ["dash.json"] mock_gs.get_commit_history = AsyncMock(return_value=[mock_commit]) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/history") assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["hash"] == "abc123" def test_with_limit(self): mock_gs = MagicMock() mock_gs.get_commit_history = AsyncMock(return_value=[]) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/history?limit=5") assert resp.status_code == 200 def test_unexpected_error(self): mock_gs = MagicMock() mock_gs.get_commit_history = AsyncMock(return_value=RuntimeError("boom")) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.get("/repositories/42/history") assert resp.status_code == 500 # ── generate_commit_message ── class TestGenerateCommitMessage: """POST /repositories/{dashboard_ref}/generate-message""" def test_no_changes(self): mock_gs = MagicMock() mock_gs.get_diff = AsyncMock(return_value=[None, None]) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.post("/repositories/42/generate-message") assert resp.status_code == 200 assert resp.json()["message"] == "No changes detected" def test_unexpected_error(self): mock_gs = MagicMock() mock_gs.get_diff = AsyncMock(return_value=RuntimeError("boom")) with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), ): client = _make_client() resp = client.post("/repositories/42/generate-message") assert resp.status_code == 500 def test_no_active_provider(self): mock_gs = MagicMock() mock_gs.get_diff = AsyncMock(return_value=[{"diff": "changes"}, None]) mock_llm_svc = MagicMock() mock_llm_svc.get_all_providers.return_value = [] with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_svc), ): client = _make_client() resp = client.post("/repositories/42/generate-message") assert resp.status_code == 400 assert "No active LLM provider" in resp.text def test_provider_found_via_active_flag(self): """generate-commit-message falls back to active provider when bound provider not found.""" mock_gs = MagicMock() mock_gs.get_diff = AsyncMock(return_value=[{"diff": "changes"}, None]) mock_gs.get_commit_history = AsyncMock(return_value=[]) mock_provider = MagicMock() mock_provider.id = "active-provider" mock_provider.provider_type = "openai" mock_provider.is_active = True mock_provider.base_url = None mock_provider.default_model = "gpt-4" mock_llm_svc = MagicMock() mock_llm_svc.get_all_providers.return_value = [mock_provider] mock_llm_svc.get_decrypted_api_key.return_value = "sk-test" with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.services.llm_provider.LLMProviderService", return_value=mock_llm_svc), patch("src.plugins.git.llm_extension.GitLLMExtension") as mock_ext_cls, ): mock_ext = MagicMock() mock_ext.suggest_commit_message = AsyncMock(return_value="feat: ai message") mock_ext_cls.return_value = mock_ext client = _make_client() resp = client.post("/repositories/42/generate-message") assert resp.status_code == 200 assert resp.json()["message"] == "feat: ai message" # ── HTTPException propagation ── class TestHttpExceptionPropagation: """When _resolve_dashboard_id_from_ref raises HTTPException, route must re-raise unchanged.""" HTTPERR = HTTPException(status_code=404, detail="Not found") def test_commit_changes_http_error(self): with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): client = _make_client() resp = client.post("/repositories/42/commit", json={"message": "x", "files": []}) assert resp.status_code == 404 def test_push_changes_http_error(self): with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): client = _make_client() resp = client.post("/repositories/42/push") assert resp.status_code == 404 def test_pull_changes_http_error(self): with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): client = _make_client() resp = client.post("/repositories/42/pull") assert resp.status_code == 404 def test_get_repository_status_http_error(self): with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): client = _make_client() resp = client.get("/repositories/42/status") assert resp.status_code == 404 def test_get_repository_diff_http_error(self): with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): client = _make_client() resp = client.get("/repositories/42/diff") assert resp.status_code == 404 def test_get_history_http_error(self): with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): client = _make_client() resp = client.get("/repositories/42/history") assert resp.status_code == 404 def test_generate_commit_message_http_error(self): with patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(side_effect=self.HTTPERR)): client = _make_client() resp = client.post("/repositories/42/generate-message") assert resp.status_code == 404 # ── get_repository_status_batch generic Exception ── class TestBatchStatusGenericError: """When _resolve_repository_status raises a non-HTTP error, batch should map it to ERROR state.""" def test_generic_exception_mapped_to_error(self): def side_effect(did): if did == 2: raise RuntimeError("unexpected") return {"is_dirty": False, "has_repo": True} with patch("src.api.routes.git._repo_operations_routes._resolve_repository_status", AsyncMock(side_effect=side_effect)): client = _make_client() resp = client.post("/repositories/status/batch", json={"dashboard_ids": [1, 2, 3]}) assert resp.status_code == 200 statuses = resp.json()["statuses"] assert statuses["2"]["sync_state"] == "ERROR" # ── pull_changes diagnostics ── class TestPullChangesDiagnostics: """pull_changes route diagnostics block with DB error.""" def test_diagnostics_error_logged(self): mock_gs = MagicMock() mock_db = MagicMock() # First call raises exception to trigger diagnostics mock_db.query.return_value.filter.return_value.first.side_effect = RuntimeError("db err") from src.core.database import get_db with ( patch("src.api.routes.git._repo_operations_routes.get_git_service", return_value=mock_gs), patch("src.api.routes.git._resolve_dashboard_id_from_ref", AsyncMock(return_value=42)), patch("src.api.routes.git._repo_operations_routes._apply_git_identity_from_profile", AsyncMock()), ): client = _make_client({get_db: lambda: mock_db}) resp = client.post("/repositories/42/pull") assert resp.status_code == 200 assert resp.json()["status"] == "success" # #endregion Test.Api.GitRepoOperationsRoutes