Files
2026-06-30 20:37:40 +02:00

492 lines
17 KiB
Python

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