Files
Orchestrator/shared/tools/monorepo-cli/tests/test_federation.py
2026-06-30 20:37:40 +02:00

1063 lines
42 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Unit-Tests für FederationManager und SubtreeSyncEngine.
Testet die Föderations-Basisklasse mit Hub-and-Spoke-Topologie:
- Konfigurationsladung aus team-repos.yaml
- Sync-Richtungsprüfung (bidirectional, hub-to-spoke, spoke-to-hub)
- SubtreeSyncEngine Pull/Push-Operationen (gemocked)
- Konflikterkennung
- Fehlerbehandlung bei ungültigen Kontexten
Validates: Requirements 10.1, 10.2, 10.3, 10.4, 10.8
"""
from __future__ import annotations
import subprocess
from datetime import datetime
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
import yaml
from monorepo.federation import (
ConflictResult,
FederationManager,
FederationTopologyConfig,
MemberInfo,
MemberOnboardingResult,
MirrorResult,
SubtreeSyncEngine,
)
from monorepo.models import ConflictInfo, SyncResult, TeamRepoEntry
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def team_repos_config(tmp_path: Path) -> Path:
"""Erstellt eine temporäre team-repos.yaml mit Testdaten."""
config_path = tmp_path / "config" / "team-repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": "privat",
"url": "https://github.com/test/privat-team.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
"shared_mirror": {
"enabled": True,
"paths": ["shared/tools/common/"],
"mode": "read-only",
},
},
{
"context": "dhive",
"url": "https://gitlab.dhive.io/team/dhive-mono.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "on-push",
},
{
"context": "bahn",
"url": "https://gitlab.example.com/bahn-workspace.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "daily",
},
],
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
return config_path
@pytest.fixture
def directional_config(tmp_path: Path) -> Path:
"""Erstellt eine team-repos.yaml mit verschiedenen Sync-Richtungen."""
config_path = tmp_path / "config" / "team-repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": "privat",
"url": "https://github.com/test/privat.git",
"branch": "main",
"sync_direction": "hub-to-spoke",
"sync_frequency": "manual",
},
{
"context": "dhive",
"url": "https://gitlab.dhive.io/dhive.git",
"branch": "main",
"sync_direction": "spoke-to-hub",
"sync_frequency": "manual",
},
],
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, 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()
(root / "privat").mkdir()
(root / "dhive").mkdir()
(root / "bahn").mkdir()
(root / "shared").mkdir()
return root
@pytest.fixture
def federation_manager(team_repos_config: Path, monorepo_root: Path) -> FederationManager:
"""Erstellt einen FederationManager mit Testkonfiguration."""
return FederationManager(
config_path=team_repos_config,
monorepo_root=monorepo_root,
)
# ---------------------------------------------------------------------------
# Tests: Konfigurationsladung
# ---------------------------------------------------------------------------
class TestFederationConfig:
"""Tests für die Konfigurationsladung aus team-repos.yaml."""
def test_load_config_topology(self, federation_manager: FederationManager) -> None:
"""Prüft korrekte Topologie-Konfiguration."""
config = federation_manager.topology_config
assert config.topology == "hub-and-spoke"
assert config.hub_owner == "andre"
assert config.conflict_strategy == "team-wins"
def test_load_config_team_repos(self, federation_manager: FederationManager) -> None:
"""Prüft dass alle Team-Repos geladen werden."""
repos = federation_manager.get_team_repos()
assert len(repos) == 3
contexts = [r.context for r in repos]
assert "privat" in contexts
assert "dhive" in contexts
assert "bahn" in contexts
def test_load_config_shared_mirror(self, federation_manager: FederationManager) -> None:
"""Prüft dass SharedMirrorConfig korrekt geparst wird."""
repos = federation_manager.get_team_repos()
privat_repo = next(r for r in repos if r.context == "privat")
assert privat_repo.shared_mirror is not None
assert privat_repo.shared_mirror.enabled is True
assert "shared/tools/common/" in privat_repo.shared_mirror.paths
assert privat_repo.shared_mirror.mode == "read-only"
def test_load_config_no_shared_mirror(self, federation_manager: FederationManager) -> None:
"""Prüft dass fehlende SharedMirrorConfig als None geparst wird."""
repos = federation_manager.get_team_repos()
dhive_repo = next(r for r in repos if r.context == "dhive")
assert dhive_repo.shared_mirror is None
def test_load_config_file_not_found(self, tmp_path: Path) -> None:
"""Prüft FileNotFoundError bei fehlender Konfiguration."""
with pytest.raises(FileNotFoundError):
FederationManager(
config_path=tmp_path / "nonexistent.yaml",
monorepo_root=tmp_path,
)
def test_load_config_invalid_yaml(self, tmp_path: Path) -> None:
"""Prüft ValueError bei ungültigem YAML-Format."""
config_path = tmp_path / "bad.yaml"
config_path.write_text("- just a list", encoding="utf-8")
with pytest.raises(ValueError, match="Ungültiges team-repos.yaml"):
FederationManager(config_path=config_path, monorepo_root=tmp_path)
def test_get_conflict_strategy(self, federation_manager: FederationManager) -> None:
"""Prüft dass get_conflict_strategy den konfigurierten Wert zurückgibt."""
assert federation_manager.get_conflict_strategy() == "team-wins"
# ---------------------------------------------------------------------------
# Tests: sync_from_team
# ---------------------------------------------------------------------------
class TestSyncFromTeam:
"""Tests für sync_from_team (Pull vom Team-Repo)."""
def test_sync_from_team_success(self, federation_manager: FederationManager) -> None:
"""Prüft erfolgreichen Pull vom Team-Repo."""
with patch.object(
federation_manager.sync_engine, "subtree_pull"
) as mock_pull:
mock_pull.return_value = SyncResult(
success=True,
context="privat",
direction="pull",
commits_synced=3,
conflicts=[],
timestamp=datetime.now(),
)
result = federation_manager.sync_from_team("privat")
assert result.success is True
assert result.context == "privat"
assert result.direction == "pull"
assert result.commits_synced == 3
mock_pull.assert_called_once_with(
remote="https://github.com/test/privat-team.git",
prefix="privat",
branch="main",
)
def test_sync_from_team_unknown_context(self, federation_manager: FederationManager) -> None:
"""Prüft Fehlermeldung bei unbekanntem Kontext."""
result = federation_manager.sync_from_team("nonexistent")
assert result.success is False
assert len(result.conflicts) == 1
assert "nonexistent" in result.conflicts[0].details
def test_sync_from_team_hub_to_spoke_blocked(
self, directional_config: Path, monorepo_root: Path
) -> None:
"""Prüft dass Pull bei hub-to-spoke Richtung blockiert wird."""
fm = FederationManager(config_path=directional_config, monorepo_root=monorepo_root)
result = fm.sync_from_team("privat")
assert result.success is False
assert "hub-to-spoke" in result.conflicts[0].details
def test_sync_from_team_merge_conflict(self, federation_manager: FederationManager) -> None:
"""Prüft Behandlung von Merge-Konflikten beim Pull."""
with patch.object(
federation_manager.sync_engine, "subtree_pull"
) as mock_pull:
mock_pull.return_value = SyncResult(
success=False,
context="dhive",
direction="pull",
commits_synced=0,
conflicts=[ConflictInfo(
file_path="dhive/src/main.py",
conflict_type="content",
source="team-repo",
details="CONFLICT (content): Merge conflict in dhive/src/main.py",
)],
timestamp=datetime.now(),
)
result = federation_manager.sync_from_team("dhive")
assert result.success is False
assert result.context == "dhive"
assert len(result.conflicts) == 1
assert result.conflicts[0].file_path == "dhive/src/main.py"
# ---------------------------------------------------------------------------
# Tests: sync_to_team
# ---------------------------------------------------------------------------
class TestSyncToTeam:
"""Tests für sync_to_team (Push zum Team-Repo)."""
def test_sync_to_team_success(self, federation_manager: FederationManager) -> None:
"""Prüft erfolgreichen Push zum Team-Repo."""
with patch.object(
federation_manager.sync_engine, "subtree_push"
) as mock_push:
mock_push.return_value = SyncResult(
success=True,
context="bahn",
direction="push",
commits_synced=2,
conflicts=[],
timestamp=datetime.now(),
)
result = federation_manager.sync_to_team("bahn")
assert result.success is True
assert result.context == "bahn"
assert result.direction == "push"
assert result.commits_synced == 2
mock_push.assert_called_once_with(
remote="https://gitlab.example.com/bahn-workspace.git",
prefix="bahn",
branch="main",
)
def test_sync_to_team_unknown_context(self, federation_manager: FederationManager) -> None:
"""Prüft Fehlermeldung bei unbekanntem Kontext."""
result = federation_manager.sync_to_team("nonexistent")
assert result.success is False
assert "nonexistent" in result.conflicts[0].details
def test_sync_to_team_spoke_to_hub_blocked(
self, directional_config: Path, monorepo_root: Path
) -> None:
"""Prüft dass Push bei spoke-to-hub Richtung blockiert wird."""
fm = FederationManager(config_path=directional_config, monorepo_root=monorepo_root)
result = fm.sync_to_team("dhive")
assert result.success is False
assert "spoke-to-hub" in result.conflicts[0].details
# ---------------------------------------------------------------------------
# Tests: full_sync
# ---------------------------------------------------------------------------
class TestFullSync:
"""Tests für full_sync (bidirektionale Synchronisation)."""
def test_full_sync_success(self, federation_manager: FederationManager) -> None:
"""Prüft erfolgreiche bidirektionale Synchronisation."""
with patch.object(
federation_manager.sync_engine, "subtree_pull"
) as mock_pull, patch.object(
federation_manager.sync_engine, "subtree_push"
) as mock_push, patch.object(
federation_manager.sync_engine, "detect_conflicts",
return_value=[],
):
mock_pull.return_value = SyncResult(
success=True, context="privat", direction="pull",
commits_synced=2, conflicts=[], timestamp=datetime.now(),
)
mock_push.return_value = SyncResult(
success=True, context="privat", direction="push",
commits_synced=1, conflicts=[], timestamp=datetime.now(),
)
result = federation_manager.full_sync("privat")
assert result.success is True
assert result.direction == "full"
assert result.commits_synced == 3 # 2 + 1
assert result.conflicts == []
def test_full_sync_aborts_on_pull_conflict(
self, federation_manager: FederationManager
) -> None:
"""Prüft Abbruch bei Merge-Konflikt während Pull."""
with patch.object(
federation_manager.sync_engine, "subtree_pull"
) as mock_pull, patch.object(
federation_manager.sync_engine, "detect_conflicts",
return_value=[],
):
mock_pull.return_value = SyncResult(
success=False, context="privat", direction="pull",
commits_synced=0,
conflicts=[ConflictInfo(
file_path="privat/config.yaml",
conflict_type="content",
source="team-repo",
details="Merge-Konflikt",
)],
timestamp=datetime.now(),
)
result = federation_manager.full_sync("privat")
assert result.success is False
assert result.direction == "full"
assert len(result.conflicts) == 1
def test_full_sync_aborts_on_pre_conflicts(
self, federation_manager: FederationManager
) -> None:
"""Prüft Abbruch bei vorab erkannten Konflikten."""
with patch.object(
federation_manager.sync_engine, "detect_conflicts",
return_value=[ConflictInfo(
file_path="privat/local-change.py",
conflict_type="content",
source="monorepo",
details="Uncommitted lokale Änderungen",
)],
):
result = federation_manager.full_sync("privat")
assert result.success is False
assert len(result.conflicts) == 1
assert "Uncommitted" in result.conflicts[0].details
def test_full_sync_non_bidirectional_blocked(
self, directional_config: Path, monorepo_root: Path
) -> None:
"""Prüft dass full_sync bei nicht-bidirektionaler Konfiguration blockiert."""
fm = FederationManager(config_path=directional_config, monorepo_root=monorepo_root)
result = fm.full_sync("privat")
assert result.success is False
assert "nicht erlaubt" in result.conflicts[0].details
# ---------------------------------------------------------------------------
# Tests: detect_conflicts
# ---------------------------------------------------------------------------
class TestDetectConflicts:
"""Tests für detect_conflicts."""
def test_detect_conflicts_no_conflicts(
self, federation_manager: FederationManager
) -> None:
"""Prüft leere Liste wenn keine Konflikte vorliegen."""
with patch.object(
federation_manager.sync_engine, "detect_conflicts",
return_value=[],
):
conflicts = federation_manager.detect_conflicts("privat")
assert conflicts == []
def test_detect_conflicts_unknown_context(
self, federation_manager: FederationManager
) -> None:
"""Prüft Fehler bei unbekanntem Kontext."""
conflicts = federation_manager.detect_conflicts("nonexistent")
assert len(conflicts) == 1
assert "nonexistent" in conflicts[0].details
def test_detect_conflicts_with_uncommitted_changes(
self, federation_manager: FederationManager
) -> None:
"""Prüft Erkennung von uncommitted Änderungen."""
with patch.object(
federation_manager.sync_engine, "detect_conflicts",
return_value=[ConflictInfo(
file_path="privat/modified.py",
conflict_type="content",
source="monorepo",
details="Uncommitted lokale Änderungen erkannt",
)],
):
conflicts = federation_manager.detect_conflicts("privat")
assert len(conflicts) == 1
assert conflicts[0].file_path == "privat/modified.py"
# ---------------------------------------------------------------------------
# Tests: SubtreeSyncEngine
# ---------------------------------------------------------------------------
class TestSubtreeSyncEngine:
"""Tests für die SubtreeSyncEngine."""
@pytest.fixture
def engine(self, monorepo_root: Path) -> SubtreeSyncEngine:
"""Erstellt eine SubtreeSyncEngine mit Test-Root."""
return SubtreeSyncEngine(monorepo_root=monorepo_root)
def test_subtree_pull_success(self, engine: SubtreeSyncEngine) -> None:
"""Prüft erfolgreichen subtree pull."""
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(
stdout="Squash commit -- not updating HEAD\n1 file changed\n",
stderr="",
)
result = engine.subtree_pull(
remote="https://github.com/test/repo.git",
prefix="privat",
branch="main",
)
assert result.success is True
assert result.direction == "pull"
assert result.commits_synced >= 1
mock_git.assert_called_once_with([
"subtree", "pull",
"--prefix=privat",
"https://github.com/test/repo.git", "main",
"--squash",
])
def test_subtree_pull_merge_conflict(self, engine: SubtreeSyncEngine) -> None:
"""Prüft Behandlung von Merge-Konflikten beim Pull."""
with patch.object(engine, "_run_git") as mock_git, \
patch.object(engine, "_abort_merge") as mock_abort:
error = subprocess.CalledProcessError(1, "git")
error.stdout = "CONFLICT (content): Merge conflict in privat/main.py"
error.stderr = ""
mock_git.side_effect = error
result = engine.subtree_pull(
remote="https://github.com/test/repo.git",
prefix="privat",
branch="main",
)
assert result.success is False
assert len(result.conflicts) >= 1
assert "privat/main.py" in result.conflicts[0].file_path
mock_abort.assert_called_once()
def test_subtree_pull_network_error(self, engine: SubtreeSyncEngine) -> None:
"""Prüft Fehlerbehandlung bei Netzwerkfehler."""
with patch.object(engine, "_run_git") as mock_git:
error = subprocess.CalledProcessError(128, "git")
error.stdout = ""
error.stderr = "fatal: Could not read from remote repository."
mock_git.side_effect = error
result = engine.subtree_pull(
remote="https://github.com/test/repo.git",
prefix="privat",
branch="main",
)
assert result.success is False
assert len(result.conflicts) >= 1
assert "fehlgeschlagen" in result.conflicts[0].details
def test_subtree_push_success(self, engine: SubtreeSyncEngine) -> None:
"""Prüft erfolgreichen subtree push."""
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(
stdout="git push using: origin main\n",
stderr="",
)
result = engine.subtree_push(
remote="https://github.com/test/repo.git",
prefix="dhive",
branch="main",
)
assert result.success is True
assert result.direction == "push"
mock_git.assert_called_once_with([
"subtree", "push",
"--prefix=dhive",
"https://github.com/test/repo.git", "main",
])
def test_subtree_push_failure(self, engine: SubtreeSyncEngine) -> None:
"""Prüft Fehlerbehandlung beim Push."""
with patch.object(engine, "_run_git") as mock_git:
error = subprocess.CalledProcessError(1, "git")
error.stdout = ""
error.stderr = "error: failed to push some refs"
mock_git.side_effect = error
result = engine.subtree_push(
remote="https://github.com/test/repo.git",
prefix="dhive",
branch="main",
)
assert result.success is False
assert "fehlgeschlagen" in result.conflicts[0].details
def test_detect_conflicts_clean(self, engine: SubtreeSyncEngine) -> None:
"""Prüft leere Konfliktliste bei sauberem Zustand."""
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(stdout="", stderr="")
conflicts = engine.detect_conflicts("privat")
assert conflicts == []
def test_detect_conflicts_uncommitted(self, engine: SubtreeSyncEngine) -> None:
"""Prüft Erkennung von uncommitted Änderungen."""
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(
stdout=" M privat/src/app.py\n M privat/config.yaml\n",
stderr="",
)
conflicts = engine.detect_conflicts("privat")
assert len(conflicts) == 2
assert conflicts[0].source == "monorepo"
def test_detect_conflicts_git_error_returns_empty(
self, engine: SubtreeSyncEngine
) -> None:
"""Prüft dass Git-Fehler zu leerer Liste führen (kein Crash)."""
with patch.object(engine, "_run_git") as mock_git:
mock_git.side_effect = subprocess.CalledProcessError(1, "git")
conflicts = engine.detect_conflicts("privat")
assert conflicts == []
def test_preserve_history_success(self, engine: SubtreeSyncEngine) -> None:
"""Prüft Historie-Verifikation bei vorhandenen Commits."""
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(
stdout="abc1234 Initial commit\ndef5678 Add feature\n",
stderr="",
)
assert engine.preserve_history(Path("privat")) is True
def test_preserve_history_empty(self, engine: SubtreeSyncEngine) -> None:
"""Prüft Historie-Verifikation bei leerer Historie."""
with patch.object(engine, "_run_git") as mock_git:
mock_git.return_value = MagicMock(stdout="", stderr="")
assert engine.preserve_history(Path("privat")) is False
# ---------------------------------------------------------------------------
# Tests: Onboarding und Hilfsmethoden
# ---------------------------------------------------------------------------
class TestOnboarding:
"""Tests für das Mitglied-Onboarding."""
def test_onboard_member_success(self, federation_manager: FederationManager) -> None:
"""Prüft erfolgreiches Onboarding eines Team-Mitglieds."""
member = MemberInfo(name="Max Mustermann", email="max@dhive.io")
result = federation_manager.onboard_member("dhive", member)
assert result.success is True
assert result.member_name == "Max Mustermann"
assert "dhive" in result.team_repo_url
def test_onboard_member_invalid_context(
self, federation_manager: FederationManager
) -> None:
"""Prüft Fehler bei ungültigem Kontext."""
member = MemberInfo(name="Test User", email="test@example.com")
result = federation_manager.onboard_member("nonexistent", member)
assert result.success is False
assert len(result.errors) > 0
# ---------------------------------------------------------------------------
# Tests: mirror_shared (Task 15.3)
# ---------------------------------------------------------------------------
class TestMirrorShared:
"""Tests für mirror_shared Spiegelung des shared-Bereichs ins Team_Repo.
Validates: Requirements 10.12
"""
def test_mirror_shared_single_file(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft Spiegelung einer einzelnen Datei aus dem shared-Bereich."""
# Erstelle shared-Datei
shared_dir = monorepo_root / "shared" / "tools" / "common"
shared_dir.mkdir(parents=True)
script = shared_dir / "build.sh"
script.write_text("#!/bin/bash\necho hello", encoding="utf-8")
result = federation_manager.mirror_shared(
"privat", ["shared/tools/common/build.sh"]
)
assert result.success is True
assert result.context == "privat"
assert "shared/tools/common/build.sh" in result.mirrored_paths
assert result.errors == []
# Prüfe dass die Datei gespiegelt wurde
mirror_target = monorepo_root / "privat" / ".shared-mirror" / "shared" / "tools" / "common" / "build.sh"
assert mirror_target.exists()
assert mirror_target.read_text(encoding="utf-8") == "#!/bin/bash\necho hello"
def test_mirror_shared_directory(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft Spiegelung eines kompletten Verzeichnisses."""
# Erstelle shared-Verzeichnis mit mehreren Dateien
shared_dir = monorepo_root / "shared" / "tools" / "common"
shared_dir.mkdir(parents=True)
(shared_dir / "script1.sh").write_text("script1", encoding="utf-8")
(shared_dir / "script2.sh").write_text("script2", encoding="utf-8")
sub = shared_dir / "sub"
sub.mkdir()
(sub / "nested.py").write_text("nested", encoding="utf-8")
result = federation_manager.mirror_shared("privat", ["shared/tools/common/"])
assert result.success is True
assert "shared/tools/common/" in result.mirrored_paths
# Prüfe dass alle Dateien rekursiv gespiegelt wurden
mirror_base = monorepo_root / "privat" / ".shared-mirror" / "shared" / "tools" / "common"
assert (mirror_base / "script1.sh").exists()
assert (mirror_base / "script2.sh").exists()
assert (mirror_base / "sub" / "nested.py").exists()
def test_mirror_shared_read_only_permissions(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass gespiegelte Dateien read-only sind."""
shared_dir = monorepo_root / "shared" / "tools" / "common"
shared_dir.mkdir(parents=True)
(shared_dir / "config.yaml").write_text("key: value", encoding="utf-8")
federation_manager.mirror_shared("privat", ["shared/tools/common/config.yaml"])
mirror_file = monorepo_root / "privat" / ".shared-mirror" / "shared" / "tools" / "common" / "config.yaml"
assert mirror_file.exists()
# Prüfe read-only (0o444 = r--r--r--)
import stat
mode = mirror_file.stat().st_mode
assert not (mode & stat.S_IWUSR) # Kein Schreibzugriff für Owner
assert not (mode & stat.S_IWGRP) # Kein Schreibzugriff für Gruppe
assert not (mode & stat.S_IWOTH) # Kein Schreibzugriff für Other
def test_mirror_shared_invalid_context(
self, federation_manager: FederationManager
) -> None:
"""Prüft Fehler bei ungültigem Kontext."""
result = federation_manager.mirror_shared("nonexistent", ["shared/tools/"])
assert result.success is False
assert len(result.errors) == 1
assert "nonexistent" in result.errors[0]
def test_mirror_shared_non_shared_path_rejected(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass Pfade außerhalb von shared/ abgelehnt werden."""
result = federation_manager.mirror_shared("privat", ["privat/secret.env"])
assert result.success is False
assert len(result.errors) == 1
assert "shared/" in result.errors[0]
def test_mirror_shared_nonexistent_source(
self, federation_manager: FederationManager
) -> None:
"""Prüft Fehler bei nicht-existierendem Quellpfad."""
# dhive hat keine shared_mirror-Konfiguration, daher alle Pfade erlaubt
result = federation_manager.mirror_shared(
"dhive", ["shared/tools/nonexistent.sh"]
)
assert result.success is False
assert any("existiert nicht" in e for e in result.errors)
def test_mirror_shared_path_not_in_config(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass Pfade außerhalb der erlaubten Mirror-Pfade abgelehnt werden."""
# privat hat nur "shared/tools/common/" als erlaubten Mirror-Pfad
# Versuche einen nicht-erlaubten Pfad zu spiegeln
other_dir = monorepo_root / "shared" / "powers" / "something"
other_dir.mkdir(parents=True)
(other_dir / "power.md").write_text("power", encoding="utf-8")
result = federation_manager.mirror_shared(
"privat", ["shared/powers/something/power.md"]
)
assert result.success is False
assert any("nicht in der Mirror-Konfiguration" in e for e in result.errors)
def test_mirror_shared_disabled_config(
self, tmp_path: Path, monorepo_root: Path
) -> None:
"""Prüft dass Spiegelung bei deaktivierter Konfiguration abgelehnt wird."""
config_path = tmp_path / "config" / "team-repos.yaml"
config_path.parent.mkdir(parents=True)
data = {
"version": "1.0",
"federation": {
"topology": "hub-and-spoke",
"hub_owner": "andre",
"conflict_strategy": "team-wins",
},
"team_repos": [
{
"context": "privat",
"url": "https://github.com/test/privat.git",
"branch": "main",
"sync_direction": "bidirectional",
"sync_frequency": "manual",
"shared_mirror": {
"enabled": False,
"paths": ["shared/tools/"],
"mode": "read-only",
},
},
],
}
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f)
fm = FederationManager(config_path=config_path, monorepo_root=monorepo_root)
result = fm.mirror_shared("privat", ["shared/tools/something"])
assert result.success is False
assert any("deaktiviert" in e for e in result.errors)
def test_mirror_shared_no_mirror_config_allows_all(
self, federation_manager: FederationManager, monorepo_root: Path
) -> None:
"""Prüft dass ohne SharedMirrorConfig alle shared-Pfade erlaubt sind."""
# dhive hat keine shared_mirror-Konfiguration (None)
shared_dir = monorepo_root / "shared" / "tools"
shared_dir.mkdir(parents=True, exist_ok=True)
(shared_dir / "util.py").write_text("util", encoding="utf-8")
result = federation_manager.mirror_shared("dhive", ["shared/tools/util.py"])
assert result.success is True
assert "shared/tools/util.py" in result.mirrored_paths
# ---------------------------------------------------------------------------
# Tests: resolve_conflict (Task 15.3)
# ---------------------------------------------------------------------------
class TestResolveConflict:
"""Tests für resolve_conflict Konflikt-Auflösung mit team-wins Strategie.
Validates: Requirements 10.6, 10.7
"""
def test_resolve_conflict_team_wins_default(
self, federation_manager: FederationManager
) -> None:
"""Prüft team-wins als Standard-Strategie (Single Source of Truth)."""
with patch.object(
federation_manager.sync_engine, "_run_git"
) as mock_git:
# Simuliere konfliktierende Dateien
mock_git.return_value = MagicMock(
stdout="privat/src/app.py\nprivat/config.yaml\n",
stderr="",
)
result = federation_manager.resolve_conflict("privat")
assert result.success is True
assert result.strategy == "team-wins"
assert len(result.resolved_files) == 2
assert "privat/src/app.py" in result.resolved_files
assert "privat/config.yaml" in result.resolved_files
# Prüfe dass --theirs verwendet wurde (team-wins)
calls = mock_git.call_args_list
checkout_calls = [c for c in calls if "checkout" in c[0][0]]
for call in checkout_calls:
assert "--theirs" in call[0][0]
def test_resolve_conflict_hub_wins(
self, federation_manager: FederationManager
) -> None:
"""Prüft hub-wins Strategie (Monorepo hat Vorrang)."""
with patch.object(
federation_manager.sync_engine, "_run_git"
) as mock_git:
mock_git.return_value = MagicMock(
stdout="dhive/main.py\n",
stderr="",
)
result = federation_manager.resolve_conflict("dhive", strategy="hub-wins")
assert result.success is True
assert result.strategy == "hub-wins"
assert "dhive/main.py" in result.resolved_files
# Prüfe dass --ours verwendet wurde (hub-wins)
calls = mock_git.call_args_list
checkout_calls = [c for c in calls if "checkout" in c[0][0]]
for call in checkout_calls:
assert "--ours" in call[0][0]
def test_resolve_conflict_manual_strategy(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass manual-Strategie keine automatische Auflösung durchführt."""
result = federation_manager.resolve_conflict("privat", strategy="manual")
assert result.success is True
assert result.strategy == "manual"
assert result.resolved_files == []
def test_resolve_conflict_invalid_strategy(
self, federation_manager: FederationManager
) -> None:
"""Prüft Fehler bei ungültiger Strategie."""
result = federation_manager.resolve_conflict("privat", strategy="invalid")
assert result.success is False
assert any("Ungültige Strategie" in e for e in result.errors)
def test_resolve_conflict_invalid_context(
self, federation_manager: FederationManager
) -> None:
"""Prüft Fehler bei ungültigem Kontext."""
result = federation_manager.resolve_conflict("nonexistent")
assert result.success is False
assert any("nonexistent" in e for e in result.errors)
def test_resolve_conflict_no_conflicts_found(
self, federation_manager: FederationManager
) -> None:
"""Prüft Erfolg wenn keine Konflikte vorhanden sind."""
with patch.object(
federation_manager.sync_engine, "_run_git"
) as mock_git:
mock_git.return_value = MagicMock(stdout="", stderr="")
result = federation_manager.resolve_conflict("privat")
assert result.success is True
assert result.resolved_files == []
def test_resolve_conflict_partial_failure(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass bei teilweisem Fehler beide Listen gefüllt werden."""
call_count = [0]
def side_effect(args: list[str]) -> MagicMock:
call_count[0] += 1
if args[0] == "diff":
return MagicMock(
stdout="privat/ok.py\nprivat/broken.py\n",
stderr="",
)
elif args[0] == "checkout" and "broken.py" in args[-1]:
error = subprocess.CalledProcessError(1, "git")
error.stdout = ""
error.stderr = "error: path not found"
raise error
return MagicMock(stdout="", stderr="")
with patch.object(
federation_manager.sync_engine, "_run_git", side_effect=side_effect
):
result = federation_manager.resolve_conflict("privat")
assert result.success is False
assert "privat/ok.py" in result.resolved_files
assert len(result.errors) == 1
assert "broken.py" in result.errors[0]
def test_resolve_conflict_git_diff_fails_uses_ls_files(
self, federation_manager: FederationManager
) -> None:
"""Prüft Fallback auf ls-files wenn diff --diff-filter=U fehlschlägt."""
call_count = [0]
def side_effect(args: list[str]) -> MagicMock:
call_count[0] += 1
if args[0] == "diff" and "--diff-filter=U" in args:
error = subprocess.CalledProcessError(1, "git")
error.stdout = ""
error.stderr = "error"
raise error
elif args[0] == "ls-files":
return MagicMock(
stdout="100644 abc123 1\tprivat/conflict.py\n"
"100644 def456 2\tprivat/conflict.py\n"
"100644 ghi789 3\tprivat/conflict.py\n",
stderr="",
)
return MagicMock(stdout="", stderr="")
with patch.object(
federation_manager.sync_engine, "_run_git", side_effect=side_effect
):
result = federation_manager.resolve_conflict("privat")
assert result.success is True
assert "privat/conflict.py" in result.resolved_files
def test_resolve_conflict_only_resolves_own_context(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass nur Dateien des eigenen Kontexts aufgelöst werden."""
with patch.object(
federation_manager.sync_engine, "_run_git"
) as mock_git:
# Git meldet Konflikte in verschiedenen Kontexten
mock_git.return_value = MagicMock(
stdout="privat/my-file.py\ndhive/other-file.py\nbahn/third.py\n",
stderr="",
)
result = federation_manager.resolve_conflict("privat")
# Nur privat/ Dateien sollten aufgelöst werden
assert result.success is True
assert "privat/my-file.py" in result.resolved_files
assert "dhive/other-file.py" not in result.resolved_files
assert "bahn/third.py" not in result.resolved_files
# ---------------------------------------------------------------------------
# Tests: onboard_member erweitert (Task 15.3)
# ---------------------------------------------------------------------------
class TestOnboardMemberExtended:
"""Erweiterte Tests für onboard_member Zugang nur zum Team_Repo.
Validates: Requirements 10.9
"""
def test_onboard_member_no_monorepo_info_leaked(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass kein Hinweis auf das Monorepo im Ergebnis enthalten ist."""
member = MemberInfo(name="Lisa Schmidt", email="lisa@dhive.io", role="developer")
result = federation_manager.onboard_member("dhive", member)
assert result.success is True
# Die URL sollte nur die Team-Repo-URL sein, nicht die Monorepo-URL
assert "dhive" in result.team_repo_url
# Keine Referenz auf "monorepo", "hub", andere Kontexte
result_str = str(result)
assert "privat" not in result.team_repo_url
assert "bahn" not in result.team_repo_url
def test_onboard_member_empty_name_rejected(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass leerer Name abgelehnt wird."""
member = MemberInfo(name="", email="test@example.com")
result = federation_manager.onboard_member("privat", member)
assert result.success is False
assert any("Name" in e for e in result.errors)
def test_onboard_member_empty_email_rejected(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass leere E-Mail abgelehnt wird."""
member = MemberInfo(name="Test User", email="")
result = federation_manager.onboard_member("privat", member)
assert result.success is False
assert any("E-Mail" in e for e in result.errors)
def test_onboard_member_whitespace_name_rejected(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass Name mit nur Whitespace abgelehnt wird."""
member = MemberInfo(name=" ", email="test@example.com")
result = federation_manager.onboard_member("privat", member)
assert result.success is False
def test_onboard_member_returns_correct_team_repo_url(
self, federation_manager: FederationManager
) -> None:
"""Prüft dass die korrekte Team-Repo-URL zurückgegeben wird."""
member = MemberInfo(name="Hans Müller", email="hans@bahn.de", role="architect")
result = federation_manager.onboard_member("bahn", member)
assert result.success is True
assert result.team_repo_url == "https://gitlab.example.com/bahn-workspace.git"
assert result.member_name == "Hans Müller"
assert result.context == "bahn"
def test_onboard_member_custom_role(
self, federation_manager: FederationManager
) -> None:
"""Prüft Onboarding mit benutzerdefinierter Rolle."""
member = MemberInfo(name="Admin User", email="admin@dhive.io", role="admin")
result = federation_manager.onboard_member("dhive", member)
assert result.success is True
assert result.member_name == "Admin User"