Initial monorepo structure
This commit is contained in:
@@ -0,0 +1,438 @@
|
||||
"""Unit-Tests für den Read-Only-Schutzmechanismus und Sync-Fehlerprotokollierung.
|
||||
|
||||
Testet:
|
||||
- protect_readonly installiert pre-commit Hook
|
||||
- Hook verhindert Commits auf geschützte Verzeichnisse
|
||||
- Fehlermeldung enthält Repo-Name und Read-Only-Status
|
||||
- install_all_readonly_hooks schützt alle read-only Repos
|
||||
- log_sync_error protokolliert Fehler in Audit-Datei
|
||||
- Bestehende Hooks werden nicht überschrieben
|
||||
|
||||
Validates: Requirements 4.2, 4.5, 4.9
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from monorepo.models import RepoEntry
|
||||
from monorepo.repos import RepoManager, _HOOK_END_MARKER, _HOOK_START_MARKER
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repos_config(tmp_path: Path) -> Path:
|
||||
"""Erstellt eine repos.yaml mit Read-Only- und Upstream-Repos."""
|
||||
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": "another-readonly",
|
||||
"url": "https://github.com/example/lib",
|
||||
"mode": "read-only",
|
||||
"target": "shared/references/lib",
|
||||
"pinned": "main",
|
||||
"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 monorepo_root(tmp_path: Path) -> Path:
|
||||
"""Erstellt ein Monorepo-Verzeichnis mit .git/hooks Struktur."""
|
||||
root = tmp_path / "monorepo"
|
||||
root.mkdir()
|
||||
(root / ".git" / "hooks").mkdir(parents=True)
|
||||
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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# protect_readonly: Hook-Installation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtectReadonly:
|
||||
"""Testet die Installation des Read-Only-Schutzmechanismus."""
|
||||
|
||||
def test_creates_pre_commit_hook(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""protect_readonly erstellt einen pre-commit Hook."""
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
assert hook_path.exists()
|
||||
|
||||
def test_hook_contains_protected_path(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Der Hook enthält den geschützten Pfad."""
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
assert "shared/references/symphony" in content
|
||||
|
||||
def test_hook_contains_error_message_template(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Der Hook enthält die Fehlermeldung mit Read-Only-Status."""
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
assert "FEHLER:" in content
|
||||
assert "read-only" in content
|
||||
assert "Schreibzugriff" in content
|
||||
|
||||
def test_hook_is_executable(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Der Hook hat Ausführungsrechte (nur relevant auf Unix-Systemen)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
|
||||
if sys.platform != "win32":
|
||||
mode = hook_path.stat().st_mode
|
||||
# Prüfe Owner-Execute-Bit
|
||||
assert mode & 0o100
|
||||
else:
|
||||
# Auf Windows existiert das Execute-Bit-Konzept nicht,
|
||||
# aber die chmod-Operation soll keinen Fehler werfen.
|
||||
assert hook_path.exists()
|
||||
|
||||
def test_hook_uses_git_diff_cached(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Der Hook verwendet 'git diff --cached --name-only'."""
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
assert "git diff --cached --name-only" in content
|
||||
|
||||
def test_hook_starts_with_shebang(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Der Hook beginnt mit einem Shell-Shebang."""
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
assert content.startswith("#!/bin/sh")
|
||||
|
||||
def test_raises_error_for_path_outside_monorepo(
|
||||
self, manager: RepoManager, tmp_path: Path
|
||||
) -> None:
|
||||
"""ValueError wenn Pfad außerhalb des Monorepo-Roots liegt."""
|
||||
outside_path = tmp_path / "outside" / "some-repo"
|
||||
outside_path.mkdir(parents=True)
|
||||
|
||||
with pytest.raises(ValueError, match="liegt nicht innerhalb"):
|
||||
manager.protect_readonly(outside_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# protect_readonly: Bestehenden Hook aktualisieren
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProtectReadonlyUpdate:
|
||||
"""Testet das Aktualisieren eines bestehenden pre-commit Hooks."""
|
||||
|
||||
def test_appends_to_existing_hook(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Fügt Schutzabschnitt an bestehenden Hook an."""
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
hook_path.write_text("#!/bin/sh\necho 'existing hook'\n", encoding="utf-8")
|
||||
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
assert "existing hook" in content
|
||||
assert "PROTECTED_PATHS" in content
|
||||
|
||||
def test_updates_existing_readonly_section(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Ersetzt bestehenden Read-Only-Abschnitt statt ihn zu duplizieren."""
|
||||
target1 = monorepo_root / "shared" / "references" / "symphony"
|
||||
target1.mkdir(parents=True)
|
||||
target2 = monorepo_root / "shared" / "references" / "lib"
|
||||
target2.mkdir(parents=True)
|
||||
|
||||
# Erster Aufruf: Symphony schützen
|
||||
manager.protect_readonly(target1)
|
||||
|
||||
# Zweiter Aufruf: Lib schützen (erweitert den bestehenden Abschnitt)
|
||||
manager.protect_readonly(target2)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
# Beide Pfade sollen im Hook sein
|
||||
assert "shared/references/symphony" in content
|
||||
assert "shared/references/lib" in content
|
||||
|
||||
# Nur ein Start-Marker (kein Duplizieren)
|
||||
assert content.count(_HOOK_START_MARKER) == 1
|
||||
assert content.count(_HOOK_END_MARKER) == 1
|
||||
|
||||
def test_does_not_duplicate_path(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Fügt denselben Pfad nicht doppelt hinzu."""
|
||||
target = monorepo_root / "shared" / "references" / "symphony"
|
||||
target.mkdir(parents=True)
|
||||
|
||||
manager.protect_readonly(target)
|
||||
manager.protect_readonly(target)
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
# Der Pfad soll nur einmal im PROTECTED_PATHS-Array vorkommen
|
||||
assert content.count('"shared/references/symphony"') == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# install_all_readonly_hooks
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestInstallAllReadonlyHooks:
|
||||
"""Testet die Installation für alle read-only Repos."""
|
||||
|
||||
def test_installs_hooks_for_all_readonly_repos(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Installiert Schutz für alle read-only konfigurierten Repos."""
|
||||
# Erstelle die Target-Verzeichnisse
|
||||
(monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True)
|
||||
(monorepo_root / "shared" / "references" / "lib").mkdir(parents=True)
|
||||
|
||||
manager.install_all_readonly_hooks()
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
assert hook_path.exists()
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
# Beide read-only Repos sollen geschützt sein
|
||||
assert "shared/references/symphony" in content
|
||||
assert "shared/references/lib" in content
|
||||
|
||||
def test_does_not_protect_upstream_repos(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Upstream-Repos werden NICHT als read-only geschützt."""
|
||||
(monorepo_root / "shared" / "references" / "symphony").mkdir(parents=True)
|
||||
(monorepo_root / "shared" / "references" / "lib").mkdir(parents=True)
|
||||
|
||||
manager.install_all_readonly_hooks()
|
||||
|
||||
hook_path = monorepo_root / ".git" / "hooks" / "pre-commit"
|
||||
content = hook_path.read_text(encoding="utf-8")
|
||||
|
||||
# Upstream-Repo darf NICHT im Schutzbereich sein
|
||||
assert "bahn/db-wissensdatenbank" not in content
|
||||
|
||||
def test_no_hook_when_no_readonly_repos(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Kein Hook wenn keine read-only Repos konfiguriert sind."""
|
||||
config_path = tmp_path / "config" / "repos.yaml"
|
||||
config_path.parent.mkdir(parents=True)
|
||||
data = {
|
||||
"repos": [
|
||||
{
|
||||
"name": "upstream-only",
|
||||
"url": "https://example.com/repo.git",
|
||||
"mode": "upstream",
|
||||
"target": "bahn/repo",
|
||||
"pinned": "main",
|
||||
"mechanism": "subtree",
|
||||
}
|
||||
]
|
||||
}
|
||||
with open(config_path, "w", encoding="utf-8") as f:
|
||||
yaml.dump(data, f)
|
||||
|
||||
root = tmp_path / "monorepo"
|
||||
root.mkdir()
|
||||
(root / ".git" / "hooks").mkdir(parents=True)
|
||||
|
||||
mgr = RepoManager(config_path=config_path, monorepo_root=root)
|
||||
mgr.install_all_readonly_hooks()
|
||||
|
||||
hook_path = root / ".git" / "hooks" / "pre-commit"
|
||||
assert not hook_path.exists()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# log_sync_error: Fehlerprotokollierung (Req 4.9)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestLogSyncError:
|
||||
"""Testet die Sync-Fehlerprotokollierung."""
|
||||
|
||||
def test_creates_audit_directory(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Erstellt das .audit-Verzeichnis falls nicht vorhanden."""
|
||||
manager.log_sync_error("test-repo", "Network timeout", "pull")
|
||||
|
||||
assert (monorepo_root / ".audit").exists()
|
||||
|
||||
def test_creates_sync_errors_log(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Erstellt die sync-errors.log Datei."""
|
||||
manager.log_sync_error("test-repo", "Connection refused", "push")
|
||||
|
||||
log_path = monorepo_root / ".audit" / "sync-errors.log"
|
||||
assert log_path.exists()
|
||||
|
||||
def test_log_contains_repo_name(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Log-Eintrag enthält den Repo-Namen."""
|
||||
manager.log_sync_error("symphony-spec", "Auth failed", "pull")
|
||||
|
||||
log_path = monorepo_root / ".audit" / "sync-errors.log"
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
assert "repo=symphony-spec" in content
|
||||
|
||||
def test_log_contains_direction(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Log-Eintrag enthält die Sync-Richtung."""
|
||||
manager.log_sync_error("test-repo", "Merge conflict", "full")
|
||||
|
||||
log_path = monorepo_root / ".audit" / "sync-errors.log"
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
assert "direction=full" in content
|
||||
|
||||
def test_log_contains_error_reason(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Log-Eintrag enthält den Fehlergrund."""
|
||||
manager.log_sync_error("test-repo", "fatal: unable to access", "pull")
|
||||
|
||||
log_path = monorepo_root / ".audit" / "sync-errors.log"
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
assert "error=fatal: unable to access" in content
|
||||
|
||||
def test_log_contains_timestamp(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Log-Eintrag enthält einen ISO-Zeitstempel."""
|
||||
manager.log_sync_error("test-repo", "Network error", "pull")
|
||||
|
||||
log_path = monorepo_root / ".audit" / "sync-errors.log"
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
# ISO-Timestamp Format: [YYYY-MM-DDTHH:MM:SS]
|
||||
assert content.startswith("[20")
|
||||
assert "T" in content.split("]")[0]
|
||||
|
||||
def test_appends_to_existing_log(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Mehrere Fehler werden untereinander protokolliert."""
|
||||
manager.log_sync_error("repo-1", "Error 1", "pull")
|
||||
manager.log_sync_error("repo-2", "Error 2", "push")
|
||||
|
||||
log_path = monorepo_root / ".audit" / "sync-errors.log"
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
lines = [line for line in content.strip().splitlines() if line]
|
||||
assert len(lines) == 2
|
||||
assert "repo-1" in lines[0]
|
||||
assert "repo-2" in lines[1]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration: _handle_sync_error schreibt auch in Audit-Log
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncErrorIntegration:
|
||||
"""Testet dass Sync-Fehler automatisch in die Audit-Datei geschrieben werden."""
|
||||
|
||||
def test_sync_error_writes_to_audit_log(
|
||||
self, manager: RepoManager, monorepo_root: Path
|
||||
) -> None:
|
||||
"""Ein Netzwerkfehler bei Sync wird in sync-errors.log protokolliert."""
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch("monorepo.repos.RepoManager._run_git") as mock_git:
|
||||
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
|
||||
log_path = monorepo_root / ".audit" / "sync-errors.log"
|
||||
assert log_path.exists()
|
||||
content = log_path.read_text(encoding="utf-8")
|
||||
assert "repo=symphony-spec" in content
|
||||
assert "Could not resolve host" in content
|
||||
Reference in New Issue
Block a user