Files
ss-tools/backend/tests/services/git/test_git_undo_commit.py
busya b9c0fa4c28 feat(git): BI-first UI/UX rework of /git — wizard modal, smart grid, undo, pre-flight
Grid (Phase 1):
- Smart row actions by sync status (Connect Git / Save version (N) / Manage / Diagnose)
- "What changed" column with compact stacked category badges
- Status filter chips, sticky bulk panel with live progress, inline row errors + retry
- Dedupe repo-status batch requests (grid feeds the Repositories tab)
- DashboardDataGrid: new actionsCell snippet

GitManager modal (Phases 2-4):
- Simple/Full mode toggle (localStorage), Simple = 3-step wizard
  (Changes → Verify → Publish) via new GitWizardStepper
- Undo center + undo toast: soft-undo unpublished commit
  (backend POST /repositories/{ref}/undo-commit, reset --soft HEAD~1,
  409 guards for pushed/detached/empty HEAD)
- Commit draft autosave per dashboard slug
- Keyboard: Ctrl+Enter commit, 1/2/3 step/tab navigation
- Contextual "You are here: step N" help on the /git page

Excellence (Phase 5):
- First-run GuidedTour (4 spotlight steps, restartable from help panel)
- Pre-flight checklist before create/publish release (ConfirmDialog children
  + confirmDisabled; red checks block the action)
- Rollback confirmation with revert-preview diff; guided conflict progress bar
- Preview link to PREPROD before publishing; human-readable version labels
- prefers-reduced-motion guard; page <title>; aria-live wizard announcements
- docs/design/git-ux-glossary.md — canonical action verbs, ru/en normalized

UX fixes (user feedback):
- Compact change chips (vertical stack, 10px) — no table horizontal scroll
- "Insert into version description" button next to AI key-changes summary
- Guided recovery for "binding belongs to another Git server" (CTA to settings)
- Instant rollback button reveal (CSS visibility via :global, no opacity repaint)

QA fixes:
- Glossary compliance: 0 "commit/коммит" in user-facing strings
- Contract coverage for rollback functions in CommitHistory
- Rollback label aligned to glossary ("Откат к версии" / "Revert to version")

Tests: backend 446 git passed + 5 new undo-commit edge cases;
frontend 3204 passed (8 pre-existing failures unrelated: pipeline locale,
ConfirmationCard, PasswordPrompt, assistant_chat, test_tasks);
new GitReleasePanel pre-flight tests 3/3 green; vite build green.
2026-07-22 18:06:26 +03:00

127 lines
5.2 KiB
Python

# #region Test.Git.UndoLastCommit [C:3] [TYPE Module] [SEMANTICS test,git,undo,soft-reset,commit]
# @BRIEF Tests for GitServiceBranchMixin.undo_last_commit — BI-safe soft-undo of unpublished commits.
# @RELATION BINDS_TO -> [Services.Branch.UndoLastCommit]
# @TEST_EDGE: no_commits -> HTTPException 409 "No commits to undo"
# @TEST_EDGE: already_pushed -> HTTPException 409 (ahead_count == 0)
# @TEST_EDGE: unpushed_with_upstream -> reset --soft HEAD~1 executed, returns undone hash/message
# @TEST_EDGE: no_upstream -> undo allowed (nothing was ever pushed)
# @TEST_EDGE: detached_head -> HTTPException 409 (active_branch raises TypeError)
import contextlib
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / "src"))
import pytest
from unittest.mock import MagicMock
from fastapi import HTTPException
from src.services.git._branch import GitServiceBranchMixin
class TestableGitUndo(GitServiceBranchMixin):
"""Concrete test class providing _locked and get_repo stubs."""
def __init__(self, mock_repo=None):
self._mock_repo = mock_repo or MagicMock()
async def get_repo(self, dashboard_id):
return self._mock_repo
def _locked(self, dashboard_id):
@contextlib.contextmanager
def _lock():
yield
return _lock()
def _make_repo(*, has_head=True, tracking=None, ahead=1, branch_name="dev"):
repo = MagicMock()
if has_head:
head_commit = MagicMock()
head_commit.hexsha = "abc123def456"
head_commit.message = "feat: test version\n"
repo.head.commit = head_commit
else:
type(repo.head).commit = property(lambda self: (_ for _ in ()).throw(ValueError("no commits")))
active_branch = MagicMock()
active_branch.name = branch_name
active_branch.tracking_branch.return_value = tracking # None or MagicMock(name='origin/dev')
if tracking == "error":
# Detached HEAD: accessing repo.active_branch itself raises TypeError
type(repo).active_branch = property(lambda self: (_ for _ in ()).throw(TypeError("detached HEAD")))
else:
repo.active_branch = active_branch
repo.iter_commits.return_value = iter([MagicMock() for _ in range(ahead)])
return repo
class TestUndoLastCommit:
# #region Test.Git.UndoNoCommits [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_undo_no_commits_raises_409(self):
"""Empty repo → 409 'No commits to undo'."""
repo = _make_repo(has_head=False)
svc = TestableGitUndo(repo)
with pytest.raises(HTTPException) as exc_info:
await svc.undo_last_commit(1)
assert exc_info.value.status_code == 409
assert "No commits" in exc_info.value.detail
repo.git.reset.assert_not_called()
# #endregion Test.Git.UndoNoCommits
# #region Test.Git.UndoAlreadyPushed [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_undo_already_pushed_raises_409(self):
"""Last commit already on remote (ahead == 0) → 409, no reset."""
tracking = MagicMock()
tracking.name = "origin/dev"
repo = _make_repo(tracking=tracking, ahead=0)
svc = TestableGitUndo(repo)
with pytest.raises(HTTPException) as exc_info:
await svc.undo_last_commit(1)
assert exc_info.value.status_code == 409
assert "already published" in exc_info.value.detail
repo.git.reset.assert_not_called()
# #endregion Test.Git.UndoAlreadyPushed
# #region Test.Git.UndoUnpushedSuccess [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_undo_unpushed_commit_succeeds(self):
"""Unpushed commit (ahead == 1) → soft reset executed, undone metadata returned."""
tracking = MagicMock()
tracking.name = "origin/dev"
repo = _make_repo(tracking=tracking, ahead=1)
svc = TestableGitUndo(repo)
result = await svc.undo_last_commit(1)
repo.git.reset.assert_called_once_with("--soft", "HEAD~1")
assert result["status"] == "success"
assert result["undone_hash"] == "abc123def456"
assert result["undone_message"] == "feat: test version"
# #endregion Test.Git.UndoUnpushedSuccess
# #region Test.Git.UndoNoUpstream [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_undo_no_upstream_allowed(self):
"""No upstream tracking branch → nothing pushed, undo allowed."""
repo = _make_repo(tracking=None)
svc = TestableGitUndo(repo)
result = await svc.undo_last_commit(1)
repo.git.reset.assert_called_once_with("--soft", "HEAD~1")
assert result["status"] == "success"
# #endregion Test.Git.UndoNoUpstream
# #region Test.Git.UndoDetachedHead [C:2] [TYPE Function]
@pytest.mark.asyncio
async def test_undo_detached_head_raises_409(self):
"""Detached HEAD (active_branch raises TypeError) → 409."""
repo = _make_repo(has_head=True, tracking="error")
svc = TestableGitUndo(repo)
with pytest.raises(HTTPException) as exc_info:
await svc.undo_last_commit(1)
assert exc_info.value.status_code == 409
repo.git.reset.assert_not_called()
# #endregion Test.Git.UndoDetachedHead