"""Unit-Tests für den RepoManager. Testet die Einbindung und Synchronisation externer Repositories: - Subtree-Add und Submodule-Add - Sync-Ergebnismeldungen (Erfolg, Fehler, unbekannt) - Bidirektionale Sync für Upstream-Repos - Abbruch bei Merge-Konflikten - Konfigurationsmanagement (Laden/Speichern) Validates: Requirements 4.1, 4.3, 4.4, 4.6, 4.7, 4.8 """ from __future__ import annotations import subprocess from pathlib import Path from unittest.mock import MagicMock, patch import pytest import yaml from monorepo.models import RepoEntry from monorepo.repos import RepoManager @pytest.fixture def repos_config(tmp_path: Path) -> Path: """Erstellt eine temporäre repos.yaml mit Testdaten.""" config_path = tmp_path / "config" / "repos.yaml" config_path.parent.mkdir(parents=True) data = { "repos": [ { "name": "symphony-spec", "url": "https://github.com/openai/symphony", "mode": "read-only", "target": "shared/references/symphony", "pinned": "v1.0.0", "mechanism": "subtree", }, { "name": "db-wissensdatenbank", "url": "https://gitlab.example.com/db-wissen", "mode": "upstream", "target": "bahn/db-wissensdatenbank", "pinned": "main", "mechanism": "subtree", }, ] } with open(config_path, "w", encoding="utf-8") as f: yaml.dump(data, f) return config_path @pytest.fixture def empty_config(tmp_path: Path) -> Path: """Erstellt eine leere repos.yaml.""" config_path = tmp_path / "config" / "repos.yaml" config_path.parent.mkdir(parents=True) with open(config_path, "w", encoding="utf-8") as f: yaml.dump({"repos": []}, f) return config_path @pytest.fixture def monorepo_root(tmp_path: Path) -> Path: """Erstellt ein temporäres Monorepo-Verzeichnis.""" root = tmp_path / "monorepo" root.mkdir() return root @pytest.fixture def manager(repos_config: Path, monorepo_root: Path) -> RepoManager: """Erstellt einen RepoManager mit Testkonfiguration.""" return RepoManager(config_path=repos_config, monorepo_root=monorepo_root) # --------------------------------------------------------------------------- # Konfiguration laden # --------------------------------------------------------------------------- class TestLoadConfig: """Testet das Laden der Repo-Konfiguration.""" def test_loads_repos_from_yaml(self, manager: RepoManager) -> None: """Lädt alle Repos aus der Konfigurationsdatei.""" assert len(manager.repos) == 2 def test_first_repo_is_readonly(self, manager: RepoManager) -> None: """Erstes Repo ist als read-only konfiguriert.""" assert manager.repos[0].name == "symphony-spec" assert manager.repos[0].mode == "read-only" def test_second_repo_is_upstream(self, manager: RepoManager) -> None: """Zweites Repo ist als upstream konfiguriert.""" assert manager.repos[1].name == "db-wissensdatenbank" assert manager.repos[1].mode == "upstream" def test_mechanism_defaults_to_subtree(self, tmp_path: Path) -> None: """Mechanismus fällt auf 'subtree' zurück wenn nicht angegeben.""" config_path = tmp_path / "repos.yaml" data = { "repos": [ { "name": "test-repo", "url": "https://example.com/repo", "mode": "read-only", "target": "shared/test", "pinned": "main", } ] } with open(config_path, "w") as f: yaml.dump(data, f) mgr = RepoManager(config_path=config_path, monorepo_root=tmp_path) assert mgr.repos[0].mechanism == "subtree" def test_missing_config_file_returns_empty_list(self, tmp_path: Path) -> None: """Fehlende Konfigurationsdatei ergibt leere Repo-Liste.""" config_path = tmp_path / "nonexistent.yaml" mgr = RepoManager(config_path=config_path, monorepo_root=tmp_path) assert mgr.repos == [] # --------------------------------------------------------------------------- # add_repo: Subtree # --------------------------------------------------------------------------- class TestAddRepoSubtree: """Testet das Einbinden eines Repos via Git-Subtree.""" @patch("monorepo.repos.RepoManager._run_git") def test_subtree_add_calls_git_correctly( self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path ) -> None: """Subtree-Add ruft git subtree add mit korrekten Argumenten auf.""" mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root) entry = RepoEntry( name="test-repo", url="https://example.com/repo.git", mode="read-only", target="shared/refs/test", pinned="v2.0.0", mechanism="subtree", ) mock_git.return_value = subprocess.CompletedProcess( args=[], returncode=0, stdout="", stderr="" ) mgr.add_repo(entry) mock_git.assert_called_once_with([ "subtree", "add", "--prefix=shared/refs/test", "https://example.com/repo.git", "v2.0.0", "--squash", ]) @patch("monorepo.repos.RepoManager._run_git") def test_subtree_add_saves_to_config( self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path ) -> None: """Nach dem Hinzufügen wird das Repo in repos.yaml gespeichert.""" mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root) entry = RepoEntry( name="new-repo", url="https://example.com/new.git", mode="read-only", target="shared/new", pinned="main", mechanism="subtree", ) mock_git.return_value = subprocess.CompletedProcess( args=[], returncode=0, stdout="", stderr="" ) mgr.add_repo(entry) # Konfigurationsdatei prüfen with open(empty_config, encoding="utf-8") as f: saved = yaml.safe_load(f) assert len(saved["repos"]) == 1 assert saved["repos"][0]["name"] == "new-repo" def test_add_repo_fails_if_target_exists( self, empty_config: Path, monorepo_root: Path ) -> None: """Fehler wenn das Zielverzeichnis bereits existiert.""" mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root) target = monorepo_root / "shared" / "existing" target.mkdir(parents=True) entry = RepoEntry( name="conflict-repo", url="https://example.com/repo.git", mode="read-only", target="shared/existing", pinned="main", mechanism="subtree", ) with pytest.raises(ValueError, match="existiert bereits"): mgr.add_repo(entry) # --------------------------------------------------------------------------- # add_repo: Submodule # --------------------------------------------------------------------------- class TestAddRepoSubmodule: """Testet das Einbinden eines Repos via Git-Submodule.""" @patch("monorepo.repos.RepoManager._run_git") def test_submodule_add_calls_git_correctly( self, mock_git: MagicMock, empty_config: Path, monorepo_root: Path ) -> None: """Submodule-Add ruft git submodule add und checkout auf.""" mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root) entry = RepoEntry( name="sub-repo", url="https://example.com/sub.git", mode="read-only", target="shared/refs/sub", pinned="v1.0.0", mechanism="submodule", ) # Target-Verzeichnis darf NICHT existieren (wird von git erstellt) # Aber _add_submodule braucht es für den checkout-Aufruf. # Wir simulieren, dass git submodule add das Verzeichnis erstellt: def side_effect(args: list[str], cwd: Path | None = None) -> subprocess.CompletedProcess[str]: if args[0] == "submodule": # git submodule add erstellt das Verzeichnis (monorepo_root / "shared" / "refs" / "sub").mkdir(parents=True) return subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="") mock_git.side_effect = side_effect mgr.add_repo(entry) # Erstes Call: submodule add assert mock_git.call_count == 2 first_call = mock_git.call_args_list[0] assert first_call[0][0] == [ "submodule", "add", "https://example.com/sub.git", "shared/refs/sub", ] # Zweites Call: checkout pinned version second_call = mock_git.call_args_list[1] assert second_call[0][0] == ["checkout", "v1.0.0"] def test_unknown_mechanism_raises_error( self, empty_config: Path, monorepo_root: Path ) -> None: """Unbekannter Mechanismus führt zu ValueError.""" mgr = RepoManager(config_path=empty_config, monorepo_root=monorepo_root) entry = RepoEntry( name="bad-repo", url="https://example.com/bad.git", mode="read-only", target="shared/bad", pinned="main", mechanism="unknown", # type: ignore[arg-type] ) with pytest.raises(ValueError, match="Unbekannter Mechanismus"): mgr.add_repo(entry) # --------------------------------------------------------------------------- # sync: Read-Only # --------------------------------------------------------------------------- class TestSyncReadOnly: """Testet die Synchronisation von Read-Only-Repos.""" @patch("monorepo.repos.RepoManager._run_git") def test_sync_readonly_success( self, mock_git: MagicMock, manager: RepoManager ) -> None: """Erfolgreiche Synchronisation eines Read-Only-Repos.""" mock_git.return_value = subprocess.CompletedProcess( args=[], returncode=0, stdout="1 file changed, 10 insertions(+)", stderr="" ) result = manager.sync("symphony-spec") assert result.success is True assert result.direction == "pull" assert result.commits_synced >= 1 assert result.conflicts == [] @patch("monorepo.repos.RepoManager._run_git") def test_sync_readonly_already_up_to_date( self, mock_git: MagicMock, manager: RepoManager ) -> None: """Repo ist bereits auf dem neuesten Stand.""" mock_git.return_value = subprocess.CompletedProcess( args=[], returncode=0, stdout="Already up to date.\n", stderr="" ) result = manager.sync("symphony-spec") assert result.success is True assert result.commits_synced == 0 @patch("monorepo.repos.RepoManager._run_git") def test_sync_readonly_network_error( self, mock_git: MagicMock, manager: RepoManager ) -> None: """Netzwerkfehler bei Synchronisation liefert Fehlerergebnis.""" mock_git.side_effect = subprocess.CalledProcessError( returncode=128, cmd=["git", "subtree", "pull"], output="", stderr="fatal: unable to access 'https://...': Could not resolve host", ) result = manager.sync("symphony-spec") assert result.success is False assert result.direction == "pull" assert len(result.conflicts) == 1 assert "fehlgeschlagen" in result.conflicts[0].details def test_sync_unknown_repo_returns_failure(self, manager: RepoManager) -> None: """Sync eines unbekannten Repos gibt Fehlermeldung zurück.""" result = manager.sync("nonexistent-repo") assert result.success is False assert "nicht in Konfiguration" in result.conflicts[0].details # --------------------------------------------------------------------------- # sync: Upstream (bidirektional) # --------------------------------------------------------------------------- class TestSyncUpstream: """Testet die bidirektionale Synchronisation für Upstream-Repos.""" @patch("monorepo.repos.RepoManager._run_git") def test_sync_upstream_success_pull_and_push( self, mock_git: MagicMock, manager: RepoManager ) -> None: """Erfolgreiche bidirektionale Sync (Pull + Push).""" mock_git.return_value = subprocess.CompletedProcess( args=[], returncode=0, stdout="1 file changed", stderr="" ) result = manager.sync("db-wissensdatenbank") assert result.success is True assert result.direction == "full" # Pull + Push → mindestens 2 Aufrufe assert mock_git.call_count == 2 @patch("monorepo.repos.RepoManager._run_git") def test_sync_upstream_merge_conflict_aborts( self, mock_git: MagicMock, manager: RepoManager ) -> None: """Merge-Konflikt bei Upstream-Sync bricht ab.""" # Erster Call (Pull) schlägt mit Merge-Konflikt fehl mock_git.side_effect = subprocess.CalledProcessError( returncode=1, cmd=["git", "subtree", "pull"], output="CONFLICT (content): Merge conflict in src/main.py\n" "Automatic merge failed; fix conflicts and then commit the result.", stderr="", ) result = manager.sync("db-wissensdatenbank") assert result.success is False assert len(result.conflicts) >= 1 # Merge-Abort wurde aufgerufen assert mock_git.call_count >= 2 # pull + merge --abort @patch("monorepo.repos.RepoManager._run_git") def test_sync_upstream_push_failure( self, mock_git: MagicMock, manager: RepoManager ) -> None: """Push-Fehler bei Upstream-Sync liefert Fehlerergebnis.""" # Erstes Call (Pull) erfolgreich, zweites (Push) schlägt fehl success = subprocess.CompletedProcess( args=[], returncode=0, stdout="Already up to date.\n", stderr="" ) push_error = subprocess.CalledProcessError( returncode=128, cmd=["git", "subtree", "push"], output="", stderr="fatal: Authentication failed", ) mock_git.side_effect = [success, push_error] result = manager.sync("db-wissensdatenbank") assert result.success is False assert "fehlgeschlagen" in result.conflicts[0].details # --------------------------------------------------------------------------- # Merge-Konflikt-Handling # --------------------------------------------------------------------------- class TestMergeConflictHandling: """Testet das Verhalten bei Merge-Konflikten.""" def test_is_merge_conflict_detects_conflict(self, manager: RepoManager) -> None: """Erkennt Merge-Konflikte anhand typischer Git-Meldungen.""" error = subprocess.CalledProcessError( returncode=1, cmd=["git"], output="CONFLICT (content): Merge conflict in file.py", stderr="", ) assert manager._is_merge_conflict(error) is True def test_is_merge_conflict_not_a_conflict(self, manager: RepoManager) -> None: """Erkennt Nicht-Konflikte korrekt.""" error = subprocess.CalledProcessError( returncode=128, cmd=["git"], output="", stderr="fatal: unable to access remote", ) assert manager._is_merge_conflict(error) is False def test_extract_conflict_files(self, manager: RepoManager) -> None: """Extrahiert Dateinamen aus Konfliktausgabe.""" output = ( "CONFLICT (content): Merge conflict in src/main.py\n" "CONFLICT (content): Merge conflict in tests/test_app.py\n" "Automatic merge failed" ) files = manager._extract_conflict_files(output) assert "src/main.py" in files assert "tests/test_app.py" in files def test_extract_conflict_files_empty_output(self, manager: RepoManager) -> None: """Leere Ausgabe ergibt leere Dateiliste.""" assert manager._extract_conflict_files("") == [] # --------------------------------------------------------------------------- # Hilfsmethoden # --------------------------------------------------------------------------- class TestHelperMethods: """Testet interne Hilfsmethoden.""" def test_count_commits_files_changed(self, manager: RepoManager) -> None: """Erkennt Commits anhand 'files changed' in Ausgabe.""" output = " 3 files changed, 20 insertions(+), 5 deletions(-)" assert manager._count_commits_from_output(output) == 1 def test_count_commits_already_up_to_date(self, manager: RepoManager) -> None: """'Already up to date' bedeutet 0 Commits.""" output = "Already up to date.\n" assert manager._count_commits_from_output(output) == 0 def test_count_commits_empty_output(self, manager: RepoManager) -> None: """Leere Ausgabe ergibt 0 Commits.""" assert manager._count_commits_from_output("") == 0 def test_find_repo_exists(self, manager: RepoManager) -> None: """Findet ein existierendes Repo.""" entry = manager._find_repo("symphony-spec") assert entry is not None assert entry.url == "https://github.com/openai/symphony" def test_find_repo_not_found(self, manager: RepoManager) -> None: """Gibt None zurück wenn Repo nicht gefunden.""" assert manager._find_repo("nonexistent") is None