AI Implementation feature(1084): Blog Repository Clone and Pull Workflow (#3)
This commit was merged in pull request #3.
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from git import GitCommandError, InvalidGitRepositoryError, Repo
|
||||
|
||||
from tenbackward.blog import (
|
||||
BACKOFF_BASE_SECONDS,
|
||||
BlogRepoError,
|
||||
DEFAULT_BLOG_SUBDIR,
|
||||
DEFAULT_REPO_URL,
|
||||
blog_dir,
|
||||
blog_repo_url,
|
||||
ensure_repo,
|
||||
)
|
||||
|
||||
|
||||
def _git_err(stderr: str = "boom") -> GitCommandError:
|
||||
return GitCommandError(["git"], stderr=stderr, status=1)
|
||||
|
||||
|
||||
def test_blog_dir_default_subdir(tmp_path: Path) -> None:
|
||||
assert blog_dir(tmp_path) == tmp_path / DEFAULT_BLOG_SUBDIR
|
||||
|
||||
|
||||
def test_blog_repo_url_default(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("BLOG_REPO_URL", raising=False)
|
||||
assert blog_repo_url() == DEFAULT_REPO_URL
|
||||
|
||||
|
||||
def test_blog_repo_url_env_override(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("BLOG_REPO_URL", "https://example.com/repo.git")
|
||||
assert blog_repo_url() == "https://example.com/repo.git"
|
||||
|
||||
|
||||
def test_ensure_repo_clones_when_missing(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
target = tmp_path / "blog"
|
||||
fake_repo = MagicMock(spec=Repo)
|
||||
calls: list[tuple[str, str]] = []
|
||||
|
||||
def fake_clone_from(url: str, path: str) -> Repo:
|
||||
calls.append((url, path))
|
||||
target.mkdir(parents=True)
|
||||
(target / ".git").mkdir()
|
||||
return fake_repo
|
||||
|
||||
monkeypatch.setattr("tenbackward.blog.Repo.clone_from", fake_clone_from)
|
||||
sleeps: list[float] = []
|
||||
result = ensure_repo(
|
||||
tmp_path,
|
||||
repo_url="https://example.com/repo.git",
|
||||
blog_path=target,
|
||||
max_retries=2,
|
||||
sleep=sleeps.append,
|
||||
)
|
||||
assert result is fake_repo
|
||||
assert calls == [("https://example.com/repo.git", str(target))]
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_ensure_repo_pulls_when_clone_exists(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
target = tmp_path / "blog"
|
||||
target.mkdir()
|
||||
(target / ".git").mkdir()
|
||||
|
||||
fake_repo = MagicMock(spec=Repo)
|
||||
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||
|
||||
fetch_impl = MagicMock()
|
||||
pull_impl = MagicMock()
|
||||
sleeps: list[float] = []
|
||||
|
||||
result = ensure_repo(
|
||||
tmp_path,
|
||||
repo_url="https://example.com/repo.git",
|
||||
blog_path=target,
|
||||
max_retries=3,
|
||||
sleep=sleeps.append,
|
||||
fetch_impl=lambda r: fetch_impl,
|
||||
pull_impl=lambda r: pull_impl,
|
||||
)
|
||||
|
||||
assert result is fake_repo
|
||||
fetch_impl.assert_called_once_with()
|
||||
pull_impl.assert_called_once_with()
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_ensure_repo_aborts_on_local_modifications(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
target = tmp_path / "blog"
|
||||
target.mkdir()
|
||||
(target / ".git").mkdir()
|
||||
|
||||
fake_repo = MagicMock(spec=Repo)
|
||||
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||
|
||||
fetch_impl = MagicMock()
|
||||
pull_impl = MagicMock(
|
||||
side_effect=_git_err("Your local changes to 'foo' would be overwritten by merge")
|
||||
)
|
||||
sleeps: list[float] = []
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
with pytest.raises(BlogRepoError, match="local modifications"):
|
||||
ensure_repo(
|
||||
tmp_path,
|
||||
repo_url="https://example.com/repo.git",
|
||||
blog_path=target,
|
||||
max_retries=5,
|
||||
sleep=sleeps.append,
|
||||
fetch_impl=lambda r: fetch_impl,
|
||||
pull_impl=lambda r: pull_impl,
|
||||
)
|
||||
|
||||
assert fetch_impl.call_count == 1
|
||||
assert pull_impl.call_count == 1
|
||||
assert sleeps == []
|
||||
|
||||
|
||||
def test_ensure_repo_retries_transient_error_then_succeeds(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
target = tmp_path / "blog"
|
||||
target.mkdir()
|
||||
(target / ".git").mkdir()
|
||||
|
||||
fake_repo = MagicMock(spec=Repo)
|
||||
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||
|
||||
fetch_impl = MagicMock(side_effect=[_git_err("Could not resolve host"), None])
|
||||
pull_impl = MagicMock()
|
||||
sleeps: list[float] = []
|
||||
|
||||
ensure_repo(
|
||||
tmp_path,
|
||||
repo_url="https://example.com/repo.git",
|
||||
blog_path=target,
|
||||
max_retries=5,
|
||||
sleep=sleeps.append,
|
||||
fetch_impl=lambda r: fetch_impl,
|
||||
pull_impl=lambda r: pull_impl,
|
||||
)
|
||||
|
||||
assert fetch_impl.call_count == 2
|
||||
pull_impl.assert_called_once_with()
|
||||
assert sleeps == [BACKOFF_BASE_SECONDS ** 1]
|
||||
|
||||
|
||||
def test_ensure_repo_raises_after_exhausted_retries(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
target = tmp_path / "blog"
|
||||
target.mkdir()
|
||||
(target / ".git").mkdir()
|
||||
|
||||
fake_repo = MagicMock(spec=Repo)
|
||||
monkeypatch.setattr("tenbackward.blog.Repo", MagicMock(return_value=fake_repo))
|
||||
|
||||
fetch_impl = MagicMock(side_effect=_git_err("Could not resolve host"))
|
||||
pull_impl = MagicMock()
|
||||
sleeps: list[float] = []
|
||||
|
||||
with pytest.raises(BlogRepoError, match="blog fetch/pull failed"):
|
||||
ensure_repo(
|
||||
tmp_path,
|
||||
repo_url="https://example.com/repo.git",
|
||||
blog_path=target,
|
||||
max_retries=2,
|
||||
sleep=sleeps.append,
|
||||
fetch_impl=lambda r: fetch_impl,
|
||||
pull_impl=lambda r: pull_impl,
|
||||
)
|
||||
|
||||
assert fetch_impl.call_count == 3
|
||||
pull_impl.assert_not_called()
|
||||
assert sleeps == [BACKOFF_BASE_SECONDS ** 1, BACKOFF_BASE_SECONDS ** 2]
|
||||
|
||||
|
||||
def test_ensure_repo_invalid_clone_dir_raises(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
target = tmp_path / "blog"
|
||||
target.mkdir()
|
||||
(target / "not-a-repo.txt").write_text("hi")
|
||||
|
||||
def fake_repo(_path: str) -> Repo:
|
||||
raise InvalidGitRepositoryError(f"{_path} is not a repo")
|
||||
|
||||
monkeypatch.setattr("tenbackward.blog.Repo", fake_repo)
|
||||
|
||||
with pytest.raises(BlogRepoError, match="not a valid git repository"):
|
||||
ensure_repo(
|
||||
tmp_path,
|
||||
repo_url="https://example.com/repo.git",
|
||||
blog_path=target,
|
||||
max_retries=0,
|
||||
)
|
||||
@@ -35,6 +35,35 @@ def test_load_config_succeeds_with_complete_env(env_setup) -> None:
|
||||
assert config.visibility == "public"
|
||||
|
||||
|
||||
def test_load_config_blog_defaults(env_setup) -> None:
|
||||
config = load_config()
|
||||
assert config.blog_repo_url == "https://git.chaospott.de/Chaospott/site"
|
||||
assert config.blog_dir.name == "blog"
|
||||
|
||||
|
||||
def test_load_config_blog_repo_url_override(env_setup, monkeypatch) -> None:
|
||||
monkeypatch.setenv("BLOG_REPO_URL", "https://example.com/repo.git")
|
||||
config = load_config()
|
||||
assert config.blog_repo_url == "https://example.com/repo.git"
|
||||
|
||||
|
||||
def test_validate_config_rejects_bad_blog_repo_url(env_setup) -> None:
|
||||
values = {
|
||||
"MASTODON_BASE_URL": "https://mastodon.example",
|
||||
"MASTODON_ACCESS_TOKEN": "x",
|
||||
"VISIBILITY": "public",
|
||||
"SITE_URL": "https://blog.example.com",
|
||||
"HASHTAGS": "#throwback",
|
||||
"THROWNBACK_PREFIX": "Throwback:",
|
||||
"MAX_RETRIES": "3",
|
||||
"RUN_AT": "09:00",
|
||||
"TZ": "Europe/Berlin",
|
||||
"BLOG_REPO_URL": "ftp://bad",
|
||||
}
|
||||
with pytest.raises(ConfigError, match="BLOG_REPO_URL"):
|
||||
validate_config(values)
|
||||
|
||||
|
||||
def test_validate_config_lists_every_missing_key(env_setup, monkeypatch) -> None:
|
||||
monkeypatch.delenv("MASTODON_ACCESS_TOKEN")
|
||||
monkeypatch.delenv("SITE_URL")
|
||||
|
||||
Reference in New Issue
Block a user