492 lines
17 KiB
Python
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
|