Files
ss-tools/backend/tests/api/test_git_repo_operations_routes.py

523 lines
21 KiB
Python

# #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