Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,985 @@
|
||||
"""Unit-Tests für die MigrationEngine.
|
||||
|
||||
Testet Konflikterkennung, Registry-Verwaltung und Migration via
|
||||
gemockte Git-Operationen.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from monorepo.migration import (
|
||||
MigrationConflict,
|
||||
MigrationEngine,
|
||||
MigrationError,
|
||||
MigrationRegistryEntry,
|
||||
MigrationResult,
|
||||
ValidationResult,
|
||||
)
|
||||
from monorepo.models import MigrationPlan
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def monorepo_root(tmp_path: Path) -> Path:
|
||||
"""Erstellt ein temporäres Monorepo-Root ohne git init."""
|
||||
root = tmp_path / "monorepo"
|
||||
root.mkdir()
|
||||
(root / "privat").mkdir()
|
||||
(root / "dhive").mkdir()
|
||||
(root / "bahn").mkdir()
|
||||
(root / "shared" / "config").mkdir(parents=True)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(monorepo_root: Path) -> MigrationEngine:
|
||||
"""Erstellt eine MigrationEngine-Instanz."""
|
||||
return MigrationEngine(monorepo_root)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_plan() -> MigrationPlan:
|
||||
"""Erstellt einen Beispiel-Migrationsplan."""
|
||||
return MigrationPlan(
|
||||
source_repo="https://github.com/example/my-project.git",
|
||||
target_context="dhive",
|
||||
target_name="my-project",
|
||||
mode="subtree",
|
||||
dependencies=[],
|
||||
order=1,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konflikterkennung: Namenskonvention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNamingConventionConflict:
|
||||
"""Tests für Namenskonventions-Konflikterkennung."""
|
||||
|
||||
def test_valid_kebab_case_no_conflict(self, engine: MigrationEngine) -> None:
|
||||
"""Gültiger kebab-case Name erzeugt keinen Namenskonflikt."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="valid-name",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
|
||||
assert len(naming) == 0
|
||||
|
||||
def test_uppercase_name_detected(self, engine: MigrationEngine) -> None:
|
||||
"""Großbuchstaben im Namen werden als Verletzung erkannt."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="MyProject",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
|
||||
assert len(naming) == 1
|
||||
assert "kebab-case" in naming[0].description
|
||||
|
||||
def test_underscore_name_detected(self, engine: MigrationEngine) -> None:
|
||||
"""Unterstriche im Namen werden als Verletzung erkannt."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="my_project",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
|
||||
assert len(naming) == 1
|
||||
|
||||
def test_leading_hyphen_detected(self, engine: MigrationEngine) -> None:
|
||||
"""Führender Bindestrich wird als Verletzung erkannt."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="-invalid",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
|
||||
assert len(naming) == 1
|
||||
|
||||
def test_single_char_name_detected(self, engine: MigrationEngine) -> None:
|
||||
"""Einbuchstabiger Name ist zu kurz (min. 2 Zeichen)."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="x",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
|
||||
assert len(naming) == 1
|
||||
|
||||
def test_empty_name_detected(self, engine: MigrationEngine) -> None:
|
||||
"""Leerer Name wird als Verletzung erkannt."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
naming = [c for c in conflicts if c.conflict_type == "naming_convention"]
|
||||
assert len(naming) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konflikterkennung: Pfadkollision
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPathCollisionConflict:
|
||||
"""Tests für Pfadkollisions-Konflikterkennung."""
|
||||
|
||||
def test_no_collision_when_path_free(self, engine: MigrationEngine) -> None:
|
||||
"""Kein Konflikt wenn Zielpfad nicht existiert."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="new-project",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
path_conflicts = [c for c in conflicts if c.conflict_type == "path_collision"]
|
||||
assert len(path_conflicts) == 0
|
||||
|
||||
def test_collision_detected_when_path_exists(
|
||||
self, engine: MigrationEngine, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Pfadkollision wird erkannt wenn Zielpfad bereits existiert."""
|
||||
(monorepo_root / "dhive" / "existing-project").mkdir(parents=True)
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="existing-project",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
path_conflicts = [c for c in conflicts if c.conflict_type == "path_collision"]
|
||||
assert len(path_conflicts) == 1
|
||||
assert "existiert bereits" in path_conflicts[0].description
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Konflikterkennung: Branch-Namenskonflikt
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBranchConflict:
|
||||
"""Tests für Branch-Namens-Konflikterkennung."""
|
||||
|
||||
def test_no_branch_conflict_for_main(self, engine: MigrationEngine) -> None:
|
||||
"""main/master Branches werden nicht als Konflikte gewertet."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="new-project",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=["main", "master"]):
|
||||
with patch.object(engine, "_get_local_branches", return_value=["main", "master"]):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
branch = [c for c in conflicts if c.conflict_type == "branch_name_conflict"]
|
||||
assert len(branch) == 0
|
||||
|
||||
def test_branch_conflict_detected(self, engine: MigrationEngine) -> None:
|
||||
"""Branch-Namenskonflikt wird erkannt bei gleichnamigem Branch."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="new-project",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(
|
||||
engine, "_get_remote_branches", return_value=["main", "feature-x"]
|
||||
):
|
||||
with patch.object(
|
||||
engine, "_get_local_branches", return_value=["main", "feature-x"]
|
||||
):
|
||||
conflicts = engine._detect_conflicts(plan)
|
||||
branch = [c for c in conflicts if c.conflict_type == "branch_name_conflict"]
|
||||
assert len(branch) == 1
|
||||
assert "feature-x" in branch[0].description
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Migration pausiert bei Konflikten
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMigrationPauses:
|
||||
"""Tests dass Migration bei Konflikten pausiert."""
|
||||
|
||||
def test_migration_pauses_on_naming_conflict(self, engine: MigrationEngine) -> None:
|
||||
"""Migration pausiert und gibt Konflikte zurück bei Namensverstoß."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="Invalid_Name",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
result = engine.migrate(plan)
|
||||
assert result.success is False
|
||||
assert len(result.conflicts) >= 1
|
||||
assert "pausiert" in result.error_message
|
||||
|
||||
def test_migration_pauses_on_path_collision(
|
||||
self, engine: MigrationEngine, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Migration pausiert bei Pfadkollision."""
|
||||
(monorepo_root / "dhive" / "existing-repo").mkdir()
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="existing-repo",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
result = engine.migrate(plan)
|
||||
assert result.success is False
|
||||
path_conflicts = [c for c in result.conflicts if c.conflict_type == "path_collision"]
|
||||
assert len(path_conflicts) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registry-Verwaltung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMigrationRegistry:
|
||||
"""Tests für die Migrations-Registry."""
|
||||
|
||||
def test_empty_registry_on_init(self, engine: MigrationEngine) -> None:
|
||||
"""Neue Engine hat leere Registry."""
|
||||
assert engine.get_registry() == []
|
||||
|
||||
def test_registry_updated_on_conflict(self, engine: MigrationEngine) -> None:
|
||||
"""Registry wird mit 'failed' Status bei Konflikten aktualisiert."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="Invalid_Name",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
engine.migrate(plan)
|
||||
registry = engine.get_registry()
|
||||
assert len(registry) == 1
|
||||
assert registry[0].status == "failed"
|
||||
|
||||
def test_registry_persisted_to_yaml(
|
||||
self, engine: MigrationEngine, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Registry wird als YAML-Datei persistiert."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="Invalid_Name",
|
||||
mode="direct",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
engine.migrate(plan)
|
||||
assert engine.registry_path.exists()
|
||||
content = engine.registry_path.read_text(encoding="utf-8")
|
||||
assert "Invalid_Name" in content
|
||||
assert "failed" in content
|
||||
|
||||
def test_registry_loaded_on_init(self, monorepo_root: Path) -> None:
|
||||
"""Registry wird beim Initialisieren aus vorhandener Datei geladen."""
|
||||
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
|
||||
registry_path.write_text(
|
||||
"version: '1.0'\n"
|
||||
"migrations:\n"
|
||||
"- repo_name: old-project\n"
|
||||
" source_repo: /tmp/old\n"
|
||||
" target_context: privat\n"
|
||||
" target_name: old-project\n"
|
||||
" target_path: privat/old-project\n"
|
||||
" mode: direct\n"
|
||||
" status: completed\n"
|
||||
" migrated_at: '2025-01-01T00:00:00'\n"
|
||||
" backup_ref: pre-migration/old-project/20250101\n"
|
||||
" commits_count: 42\n"
|
||||
" branches: [main, develop]\n"
|
||||
" tags: [v1.0, v2.0]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
engine = MigrationEngine(monorepo_root)
|
||||
registry = engine.get_registry()
|
||||
assert len(registry) == 1
|
||||
assert registry[0].repo_name == "old-project"
|
||||
assert registry[0].status == "completed"
|
||||
assert registry[0].commits_count == 42
|
||||
|
||||
def test_is_migrated_returns_true_for_completed(
|
||||
self, monorepo_root: Path
|
||||
) -> None:
|
||||
"""is_migrated gibt True für abgeschlossene Migrationen."""
|
||||
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
|
||||
registry_path.write_text(
|
||||
"version: '1.0'\n"
|
||||
"migrations:\n"
|
||||
"- repo_name: done-project\n"
|
||||
" source_repo: /tmp/done\n"
|
||||
" target_context: privat\n"
|
||||
" target_name: done-project\n"
|
||||
" target_path: privat/done-project\n"
|
||||
" mode: direct\n"
|
||||
" status: completed\n"
|
||||
" migrated_at: '2025-01-01T00:00:00'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
engine = MigrationEngine(monorepo_root)
|
||||
assert engine.is_migrated("done-project") is True
|
||||
assert engine.is_migrated("unknown-project") is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Erfolgreiche Migration (mit gemocktem Git)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSuccessfulMigration:
|
||||
"""Tests für eine erfolgreiche Migration mit gemockten Git-Operationen."""
|
||||
|
||||
def test_successful_migration_returns_result(
|
||||
self, engine: MigrationEngine
|
||||
) -> None:
|
||||
"""Erfolgreiche Migration gibt MigrationResult mit success=True."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source-repo",
|
||||
target_context="dhive",
|
||||
target_name="new-project",
|
||||
mode="subtree",
|
||||
)
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch.object(engine, "_run_git", return_value=mock_result):
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
with patch.object(engine, "_detect_main_branch", return_value="main"):
|
||||
with patch.object(
|
||||
engine, "_get_fetched_branches",
|
||||
return_value=["main", "develop"],
|
||||
):
|
||||
with patch.object(
|
||||
engine, "_get_fetched_tags",
|
||||
return_value=["v1.0", "v2.0"],
|
||||
):
|
||||
with patch.object(
|
||||
engine, "_count_commits_in_subtree",
|
||||
return_value=25,
|
||||
):
|
||||
result = engine.migrate(plan)
|
||||
|
||||
assert result.success is True
|
||||
assert result.repo_name == "new-project"
|
||||
assert result.target_path == "dhive/new-project"
|
||||
assert result.commits_migrated == 25
|
||||
assert result.branches_migrated == ["main", "develop"]
|
||||
assert result.tags_migrated == ["v1.0", "v2.0"]
|
||||
|
||||
def test_successful_migration_updates_registry(
|
||||
self, engine: MigrationEngine
|
||||
) -> None:
|
||||
"""Erfolgreiche Migration aktualisiert Registry mit 'completed'."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source-repo",
|
||||
target_context="dhive",
|
||||
target_name="new-project",
|
||||
mode="subtree",
|
||||
)
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch.object(engine, "_run_git", return_value=mock_result):
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
with patch.object(engine, "_detect_main_branch", return_value="main"):
|
||||
with patch.object(
|
||||
engine, "_get_fetched_branches", return_value=["main"]
|
||||
):
|
||||
with patch.object(engine, "_get_fetched_tags", return_value=[]):
|
||||
with patch.object(
|
||||
engine, "_count_commits_in_subtree", return_value=10
|
||||
):
|
||||
engine.migrate(plan)
|
||||
|
||||
assert engine.is_migrated("new-project") is True
|
||||
registry = engine.get_registry()
|
||||
assert any(
|
||||
e.repo_name == "new-project" and e.status == "completed"
|
||||
for e in registry
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fehlerbehandlung
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMigrationErrors:
|
||||
"""Tests für Fehlerbehandlung bei der Migration."""
|
||||
|
||||
def test_git_error_returns_failed_result(self, engine: MigrationEngine) -> None:
|
||||
"""Git-Fehler führt zu MigrationResult mit success=False."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/broken-repo",
|
||||
target_context="dhive",
|
||||
target_name="broken-project",
|
||||
mode="subtree",
|
||||
)
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
call_count["n"] += 1
|
||||
# Let the backup tag call succeed, then fail on subtree add
|
||||
if "subtree" in args:
|
||||
err = subprocess.CalledProcessError(128, "git")
|
||||
err.stdout = ""
|
||||
err.stderr = "fatal: refusing to merge"
|
||||
raise err
|
||||
result = MagicMock()
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=mock_run_git):
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
with patch.object(engine, "_detect_main_branch", return_value="main"):
|
||||
result = engine.migrate(plan)
|
||||
|
||||
assert result.success is False
|
||||
assert result.error_message != ""
|
||||
|
||||
def test_failed_migration_in_registry(self, engine: MigrationEngine) -> None:
|
||||
"""Fehlgeschlagene Migration wird mit 'failed' in Registry gespeichert."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/broken-repo",
|
||||
target_context="dhive",
|
||||
target_name="broken-project",
|
||||
mode="subtree",
|
||||
)
|
||||
|
||||
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
if "subtree" in args:
|
||||
err = subprocess.CalledProcessError(1, "git")
|
||||
err.stdout = ""
|
||||
err.stderr = "fatal: refusing to merge"
|
||||
raise err
|
||||
result = MagicMock()
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=mock_run_git):
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
with patch.object(engine, "_detect_main_branch", return_value="main"):
|
||||
engine.migrate(plan)
|
||||
|
||||
registry = engine.get_registry()
|
||||
failed = [e for e in registry if e.status == "failed"]
|
||||
assert len(failed) >= 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Inkrementelle Migration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIncrementalMigration:
|
||||
"""Tests für inkrementelle Migration."""
|
||||
|
||||
def _run_successful_migration(
|
||||
self, engine: MigrationEngine, plan: MigrationPlan
|
||||
) -> MigrationResult:
|
||||
"""Hilfsmethode: führt eine erfolgreiche Migration mit Mocks durch."""
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch.object(engine, "_run_git", return_value=mock_result):
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
with patch.object(engine, "_detect_main_branch", return_value="main"):
|
||||
with patch.object(
|
||||
engine, "_get_fetched_branches", return_value=["main"]
|
||||
):
|
||||
with patch.object(engine, "_get_fetched_tags", return_value=[]):
|
||||
with patch.object(
|
||||
engine, "_count_commits_in_subtree", return_value=5
|
||||
):
|
||||
return engine.migrate(plan)
|
||||
|
||||
def test_independent_migrations_dont_interfere(
|
||||
self, engine: MigrationEngine
|
||||
) -> None:
|
||||
"""Zwei unabhängige Migrationen beeinflussen sich nicht."""
|
||||
plan_a = MigrationPlan(
|
||||
source_repo="/tmp/repo-a",
|
||||
target_context="dhive",
|
||||
target_name="repo-a",
|
||||
mode="subtree",
|
||||
)
|
||||
plan_b = MigrationPlan(
|
||||
source_repo="/tmp/repo-b",
|
||||
target_context="bahn",
|
||||
target_name="repo-b",
|
||||
mode="subtree",
|
||||
)
|
||||
|
||||
result_a = self._run_successful_migration(engine, plan_a)
|
||||
result_b = self._run_successful_migration(engine, plan_b)
|
||||
|
||||
assert result_a.success is True
|
||||
assert result_b.success is True
|
||||
assert engine.is_migrated("repo-a") is True
|
||||
assert engine.is_migrated("repo-b") is True
|
||||
|
||||
def test_failed_migration_doesnt_affect_previous(
|
||||
self, engine: MigrationEngine
|
||||
) -> None:
|
||||
"""Eine fehlgeschlagene Migration beeinträchtigt vorherige nicht."""
|
||||
plan_a = MigrationPlan(
|
||||
source_repo="/tmp/repo-a",
|
||||
target_context="dhive",
|
||||
target_name="repo-a",
|
||||
mode="subtree",
|
||||
)
|
||||
self._run_successful_migration(engine, plan_a)
|
||||
|
||||
# Zweite Migration schlägt fehl (Namenskonvention)
|
||||
plan_b = MigrationPlan(
|
||||
source_repo="/tmp/repo-b",
|
||||
target_context="bahn",
|
||||
target_name="Invalid_B",
|
||||
mode="subtree",
|
||||
)
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
engine.migrate(plan_b)
|
||||
|
||||
# Erste Migration ist weiterhin erfolgreich
|
||||
assert engine.is_migrated("repo-a") is True
|
||||
registry = engine.get_registry()
|
||||
repo_a_entry = next(e for e in registry if e.repo_name == "repo-a")
|
||||
assert repo_a_entry.status == "completed"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup-Referenz
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBackupRef:
|
||||
"""Tests für Backup-Referenz-Erstellung."""
|
||||
|
||||
def test_backup_tag_created_before_migration(
|
||||
self, engine: MigrationEngine
|
||||
) -> None:
|
||||
"""Ein Backup-Tag wird vor der Migration erstellt."""
|
||||
plan = MigrationPlan(
|
||||
source_repo="/tmp/source",
|
||||
target_context="dhive",
|
||||
target_name="backup-test",
|
||||
mode="subtree",
|
||||
)
|
||||
|
||||
git_calls: list[list[str]] = []
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
def tracking_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
git_calls.append(args)
|
||||
return mock_result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=tracking_run_git):
|
||||
with patch.object(engine, "_get_remote_branches", return_value=[]):
|
||||
with patch.object(engine, "_detect_main_branch", return_value="main"):
|
||||
with patch.object(
|
||||
engine, "_get_fetched_branches", return_value=["main"]
|
||||
):
|
||||
with patch.object(engine, "_get_fetched_tags", return_value=[]):
|
||||
with patch.object(
|
||||
engine, "_count_commits_in_subtree", return_value=1
|
||||
):
|
||||
engine.migrate(plan)
|
||||
|
||||
# Find the tag call
|
||||
tag_calls = [c for c in git_calls if c[0] == "tag" and "pre-migration" in c[1]]
|
||||
assert len(tag_calls) == 1
|
||||
assert "backup-test" in tag_calls[0][1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validierung (validate)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidation:
|
||||
"""Tests für die Migrations-Validierung."""
|
||||
|
||||
def _setup_registry_with_completed_migration(
|
||||
self, monorepo_root: Path, *, create_files: bool = True
|
||||
) -> MigrationEngine:
|
||||
"""Erstellt Engine mit einem abgeschlossenen Migrations-Eintrag."""
|
||||
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
|
||||
registry_path.write_text(
|
||||
"version: '1.0'\n"
|
||||
"migrations:\n"
|
||||
"- repo_name: my-project\n"
|
||||
" source_repo: /tmp/source\n"
|
||||
" target_context: dhive\n"
|
||||
" target_name: my-project\n"
|
||||
" target_path: dhive/my-project\n"
|
||||
" mode: subtree\n"
|
||||
" status: completed\n"
|
||||
" migrated_at: '2025-01-01T00:00:00'\n"
|
||||
" backup_ref: pre-migration/my-project/20250101-120000\n"
|
||||
" commits_count: 10\n"
|
||||
" branches: [main, develop]\n"
|
||||
" tags: [v1.0, v2.0]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
if create_files:
|
||||
project_dir = monorepo_root / "dhive" / "my-project"
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
(project_dir / "README.md").write_text("# My Project\n")
|
||||
return MigrationEngine(monorepo_root)
|
||||
|
||||
def test_validate_unknown_repo(self, monorepo_root: Path) -> None:
|
||||
"""Validierung eines unbekannten Repos gibt Fehler zurück."""
|
||||
engine = MigrationEngine(monorepo_root)
|
||||
result = engine.validate("unknown-repo")
|
||||
assert result.success is False
|
||||
assert "nicht in der Migrations-Registry" in result.error_message
|
||||
|
||||
def test_validate_successful_all_checks_pass(self, monorepo_root: Path) -> None:
|
||||
"""Validierung besteht wenn alle Checks erfolgreich sind."""
|
||||
engine = self._setup_registry_with_completed_migration(monorepo_root)
|
||||
|
||||
mock_git_result = MagicMock()
|
||||
mock_git_result.stdout = "\n".join([f"commit{i}" for i in range(10)])
|
||||
|
||||
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
result = MagicMock()
|
||||
if args[0] == "log":
|
||||
result.stdout = "\n".join([f"abc{i} Commit msg {i}" for i in range(10)])
|
||||
elif args[0] == "branch":
|
||||
result.stdout = "main\ndevelop\n"
|
||||
elif args[0] == "tag":
|
||||
result.stdout = "v1.0\nv2.0\npre-migration/my-project/20250101-120000\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=mock_run_git):
|
||||
result = engine.validate("my-project")
|
||||
|
||||
assert result.success is True
|
||||
assert result.checks["commits"] is True
|
||||
assert result.checks["branches"] is True
|
||||
assert result.checks["tags"] is True
|
||||
assert result.checks["files"] is True
|
||||
assert result.checks["tests"] is True
|
||||
assert len(result.details) == 5
|
||||
|
||||
def test_validate_fails_when_commits_missing(self, monorepo_root: Path) -> None:
|
||||
"""Validierung schlägt fehl wenn zu wenige Commits vorhanden."""
|
||||
engine = self._setup_registry_with_completed_migration(monorepo_root)
|
||||
|
||||
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
result = MagicMock()
|
||||
if args[0] == "log":
|
||||
# Nur 3 Commits statt erwartet 10
|
||||
result.stdout = "abc1 msg\nabc2 msg\nabc3 msg\n"
|
||||
elif args[0] == "branch":
|
||||
result.stdout = "main\ndevelop\n"
|
||||
elif args[0] == "tag":
|
||||
result.stdout = "v1.0\nv2.0\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=mock_run_git):
|
||||
result = engine.validate("my-project")
|
||||
|
||||
assert result.success is False
|
||||
assert result.checks["commits"] is False
|
||||
|
||||
def test_validate_fails_when_tags_missing(self, monorepo_root: Path) -> None:
|
||||
"""Validierung schlägt fehl wenn Tags fehlen."""
|
||||
engine = self._setup_registry_with_completed_migration(monorepo_root)
|
||||
|
||||
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
result = MagicMock()
|
||||
if args[0] == "log":
|
||||
result.stdout = "\n".join([f"abc{i} msg" for i in range(10)])
|
||||
elif args[0] == "branch":
|
||||
result.stdout = "main\ndevelop\n"
|
||||
elif args[0] == "tag":
|
||||
# v2.0 fehlt
|
||||
result.stdout = "v1.0\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=mock_run_git):
|
||||
result = engine.validate("my-project")
|
||||
|
||||
assert result.success is False
|
||||
assert result.checks["tags"] is False
|
||||
|
||||
def test_validate_fails_when_directory_missing(self, monorepo_root: Path) -> None:
|
||||
"""Validierung schlägt fehl wenn das Zielverzeichnis nicht existiert."""
|
||||
engine = self._setup_registry_with_completed_migration(
|
||||
monorepo_root, create_files=False
|
||||
)
|
||||
|
||||
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
result = MagicMock()
|
||||
if args[0] == "log":
|
||||
result.stdout = "\n".join([f"abc{i} msg" for i in range(10)])
|
||||
elif args[0] == "branch":
|
||||
result.stdout = "main\ndevelop\n"
|
||||
elif args[0] == "tag":
|
||||
result.stdout = "v1.0\nv2.0\n"
|
||||
else:
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=mock_run_git):
|
||||
result = engine.validate("my-project")
|
||||
|
||||
assert result.success is False
|
||||
assert result.checks["files"] is False
|
||||
|
||||
def test_validate_no_branches_registered_passes(self, monorepo_root: Path) -> None:
|
||||
"""Validierung besteht wenn keine Branches in der Registry registriert sind."""
|
||||
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
|
||||
registry_path.write_text(
|
||||
"version: '1.0'\n"
|
||||
"migrations:\n"
|
||||
"- repo_name: simple-project\n"
|
||||
" source_repo: /tmp/source\n"
|
||||
" target_context: privat\n"
|
||||
" target_name: simple-project\n"
|
||||
" target_path: privat/simple-project\n"
|
||||
" mode: direct\n"
|
||||
" status: completed\n"
|
||||
" migrated_at: '2025-01-01T00:00:00'\n"
|
||||
" commits_count: 5\n"
|
||||
" branches: []\n"
|
||||
" tags: []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
project_dir = monorepo_root / "privat" / "simple-project"
|
||||
project_dir.mkdir(parents=True)
|
||||
(project_dir / "main.py").write_text("print('hello')\n")
|
||||
engine = MigrationEngine(monorepo_root)
|
||||
|
||||
def mock_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
result = MagicMock()
|
||||
if args[0] == "log":
|
||||
result.stdout = "\n".join([f"abc{i} msg" for i in range(5)])
|
||||
else:
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=mock_run_git):
|
||||
result = engine.validate("simple-project")
|
||||
|
||||
assert result.success is True
|
||||
assert result.checks["branches"] is True
|
||||
assert result.checks["tags"] is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Rollback
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRollback:
|
||||
"""Tests für den Migrations-Rollback."""
|
||||
|
||||
def _setup_engine_with_migration(
|
||||
self, monorepo_root: Path, *, status: str = "completed"
|
||||
) -> MigrationEngine:
|
||||
"""Erstellt Engine mit einer Migration im gegebenen Status."""
|
||||
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
|
||||
registry_path.write_text(
|
||||
"version: '1.0'\n"
|
||||
"migrations:\n"
|
||||
f"- repo_name: rollback-project\n"
|
||||
f" source_repo: /tmp/source\n"
|
||||
f" target_context: dhive\n"
|
||||
f" target_name: rollback-project\n"
|
||||
f" target_path: dhive/rollback-project\n"
|
||||
f" mode: subtree\n"
|
||||
f" status: {status}\n"
|
||||
f" migrated_at: '2025-01-01T00:00:00'\n"
|
||||
f" backup_ref: pre-migration/rollback-project/20250101-120000\n"
|
||||
f" commits_count: 15\n"
|
||||
f" branches: [main]\n"
|
||||
f" tags: [v1.0]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Erstelle das migrierte Verzeichnis
|
||||
project_dir = monorepo_root / "dhive" / "rollback-project"
|
||||
project_dir.mkdir(parents=True, exist_ok=True)
|
||||
(project_dir / "README.md").write_text("# Rollback Project\n")
|
||||
return MigrationEngine(monorepo_root)
|
||||
|
||||
def test_rollback_unknown_repo_raises_error(self, monorepo_root: Path) -> None:
|
||||
"""Rollback eines unbekannten Repos wirft MigrationError."""
|
||||
engine = MigrationEngine(monorepo_root)
|
||||
with pytest.raises(MigrationError, match="nicht in der Migrations-Registry"):
|
||||
engine.rollback("unknown-repo")
|
||||
|
||||
def test_rollback_already_rolled_back_raises_error(
|
||||
self, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Rollback eines bereits zurückgerollten Repos wirft MigrationError."""
|
||||
engine = self._setup_engine_with_migration(monorepo_root, status="rolled_back")
|
||||
with pytest.raises(MigrationError, match="bereits zurückgerollt"):
|
||||
engine.rollback("rollback-project")
|
||||
|
||||
def test_rollback_removes_directory_and_updates_registry(
|
||||
self, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Rollback entfernt das Verzeichnis und setzt Registry auf 'rolled_back'."""
|
||||
engine = self._setup_engine_with_migration(monorepo_root)
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch.object(engine, "_run_git", return_value=mock_result):
|
||||
engine.rollback("rollback-project")
|
||||
|
||||
registry = engine.get_registry()
|
||||
entry = next(e for e in registry if e.repo_name == "rollback-project")
|
||||
assert entry.status == "rolled_back"
|
||||
|
||||
def test_rollback_calls_git_rm_and_commit(self, monorepo_root: Path) -> None:
|
||||
"""Rollback ruft git rm -rf und git commit auf."""
|
||||
engine = self._setup_engine_with_migration(monorepo_root)
|
||||
git_calls: list[list[str]] = []
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
def tracking_run_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
git_calls.append(args)
|
||||
return mock_result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=tracking_run_git):
|
||||
engine.rollback("rollback-project")
|
||||
|
||||
# Prüfe git rm -rf
|
||||
rm_calls = [c for c in git_calls if c[0] == "rm"]
|
||||
assert len(rm_calls) == 1
|
||||
assert "-rf" in rm_calls[0]
|
||||
assert "dhive/rollback-project" in rm_calls[0]
|
||||
|
||||
# Prüfe git commit
|
||||
commit_calls = [c for c in git_calls if c[0] == "commit"]
|
||||
assert len(commit_calls) == 1
|
||||
assert "Rollback" in commit_calls[0][2]
|
||||
|
||||
def test_rollback_does_not_affect_other_repos(self, monorepo_root: Path) -> None:
|
||||
"""Rollback eines Repos beeinflusst andere migrierte Repos nicht."""
|
||||
registry_path = monorepo_root / "shared" / "config" / "migrations.yaml"
|
||||
registry_path.write_text(
|
||||
"version: '1.0'\n"
|
||||
"migrations:\n"
|
||||
"- repo_name: rollback-project\n"
|
||||
" source_repo: /tmp/source-a\n"
|
||||
" target_context: dhive\n"
|
||||
" target_name: rollback-project\n"
|
||||
" target_path: dhive/rollback-project\n"
|
||||
" mode: subtree\n"
|
||||
" status: completed\n"
|
||||
" migrated_at: '2025-01-01T00:00:00'\n"
|
||||
" backup_ref: pre-migration/rollback-project/20250101-120000\n"
|
||||
" commits_count: 10\n"
|
||||
" branches: [main]\n"
|
||||
" tags: []\n"
|
||||
"- repo_name: other-project\n"
|
||||
" source_repo: /tmp/source-b\n"
|
||||
" target_context: bahn\n"
|
||||
" target_name: other-project\n"
|
||||
" target_path: bahn/other-project\n"
|
||||
" mode: subtree\n"
|
||||
" status: completed\n"
|
||||
" migrated_at: '2025-01-01T00:00:00'\n"
|
||||
" backup_ref: pre-migration/other-project/20250101-120000\n"
|
||||
" commits_count: 20\n"
|
||||
" branches: [main]\n"
|
||||
" tags: [v2.0]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
# Erstelle Verzeichnisse
|
||||
(monorepo_root / "dhive" / "rollback-project").mkdir(parents=True)
|
||||
(monorepo_root / "dhive" / "rollback-project" / "file.txt").write_text("x")
|
||||
(monorepo_root / "bahn" / "other-project").mkdir(parents=True)
|
||||
(monorepo_root / "bahn" / "other-project" / "file.txt").write_text("y")
|
||||
|
||||
engine = MigrationEngine(monorepo_root)
|
||||
mock_result = MagicMock()
|
||||
mock_result.stdout = ""
|
||||
mock_result.stderr = ""
|
||||
|
||||
with patch.object(engine, "_run_git", return_value=mock_result):
|
||||
engine.rollback("rollback-project")
|
||||
|
||||
# other-project bleibt unverändert
|
||||
registry = engine.get_registry()
|
||||
other = next(e for e in registry if e.repo_name == "other-project")
|
||||
assert other.status == "completed"
|
||||
|
||||
def test_rollback_git_rm_failure_raises_error(self, monorepo_root: Path) -> None:
|
||||
"""Wenn git rm fehlschlägt, wird MigrationError geworfen."""
|
||||
engine = self._setup_engine_with_migration(monorepo_root)
|
||||
|
||||
def failing_git(args: list[str], cwd=None): # type: ignore[no-untyped-def]
|
||||
if args[0] == "rm":
|
||||
err = subprocess.CalledProcessError(1, "git")
|
||||
err.stdout = ""
|
||||
err.stderr = "fatal: pathspec not found"
|
||||
raise err
|
||||
result = MagicMock()
|
||||
result.stdout = ""
|
||||
result.stderr = ""
|
||||
return result
|
||||
|
||||
with patch.object(engine, "_run_git", side_effect=failing_git):
|
||||
with pytest.raises(MigrationError, match="Rollback fehlgeschlagen"):
|
||||
engine.rollback("rollback-project")
|
||||
Reference in New Issue
Block a user