mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Subsequent backups against Gitea 1.24+ failed with the opaque
"Backup failed: 'tree'" message after the initial-backup fix landed in
7ee89b56. Root cause: Gitea's GET /repos/{owner}/{repo}/git/commits/{sha}
returns the wrapped Commit schema where the tree lives at
data["commit"]["tree"]["sha"], whereas GitHub's same-named Git Database
endpoint returns the unwrapped GitCommit schema with tree at the top
level. The bare commit_response.json()["tree"]["sha"] lookup at
gitea.py:109 raised KeyError: 'tree' and the broad except in push_files
surfaced it as the opaque "Backup failed: 'tree'" string — masking the
real shape mismatch.
Adds a _commit_tree_sha() helper that tries the flat shape first
(GitHub-compatible / older Gitea) and falls back to the wrapped shape
(Gitea 1.24+, Forgejo). Returns None on truly malformed responses;
push_files maps that to a clear "Failed to extract tree SHA from commit
response" instead of leaking a KeyError repr. Keeps the existing-files
diff working on both shapes so subsequent backups don't re-upload every
blob — preferred over the .get()-and-skip approach which would have
required also dropping base_tree from the tree POST and re-uploading
unchanged files on every backup.
783 lines
32 KiB
Python
783 lines
32 KiB
Python
"""Unit tests for the git_providers abstraction package."""
|
|
|
|
import base64
|
|
import hashlib
|
|
import json
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
import pytest
|
|
|
|
from backend.app.services.git_providers.factory import get_provider_backend
|
|
from backend.app.services.git_providers.forgejo import ForgejoBackend
|
|
from backend.app.services.git_providers.gitea import GiteaBackend
|
|
from backend.app.services.git_providers.github import GitHubBackend
|
|
from backend.app.services.git_providers.gitlab import GitLabBackend
|
|
|
|
|
|
class TestFactory:
|
|
def test_known_providers_return_correct_class(self):
|
|
assert isinstance(get_provider_backend("github"), GitHubBackend)
|
|
assert isinstance(get_provider_backend("gitea"), GiteaBackend)
|
|
assert isinstance(get_provider_backend("forgejo"), ForgejoBackend)
|
|
assert isinstance(get_provider_backend("gitlab"), GitLabBackend)
|
|
|
|
def test_unknown_provider_raises_value_error(self):
|
|
with pytest.raises(ValueError, match="Unknown Git provider"):
|
|
get_provider_backend("bitbucket")
|
|
|
|
|
|
class TestGitHubBackendParseUrl:
|
|
def setup_method(self):
|
|
self.backend = GitHubBackend()
|
|
|
|
def test_https_url(self):
|
|
owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_https_url_with_git_suffix(self):
|
|
owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo.git")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_ssh_url(self):
|
|
owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_ssh_url_with_git_suffix(self):
|
|
owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo.git")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_invalid_url_raises_value_error(self):
|
|
with pytest.raises(ValueError, match="Cannot parse repository URL"):
|
|
self.backend.parse_repo_url("https://example.com/not-a-repo")
|
|
|
|
def test_empty_url_raises_value_error(self):
|
|
with pytest.raises(ValueError):
|
|
self.backend.parse_repo_url("")
|
|
|
|
|
|
class TestGitHubBackendApiBase:
|
|
def setup_method(self):
|
|
self.backend = GitHubBackend()
|
|
|
|
def test_github_com_returns_api_github_com(self):
|
|
assert self.backend.get_api_base("https://github.com/owner/repo") == "https://api.github.com"
|
|
|
|
def test_ghe_host_returns_v3_endpoint(self):
|
|
assert self.backend.get_api_base("https://github.example.com/owner/repo") == "https://github.example.com/api/v3"
|
|
|
|
def test_ghe_host_with_port(self):
|
|
assert (
|
|
self.backend.get_api_base("https://github.example.com:8443/owner/repo")
|
|
== "https://github.example.com:8443/api/v3"
|
|
)
|
|
|
|
def test_ssh_github_com_returns_api_github_com(self):
|
|
assert self.backend.get_api_base("git@github.com:owner/repo.git") == "https://api.github.com"
|
|
|
|
def test_ssh_ghe_host_returns_v3_endpoint(self):
|
|
assert self.backend.get_api_base("git@github.example.com:owner/repo.git") == "https://github.example.com/api/v3"
|
|
|
|
|
|
class TestGiteaBackendApiBase:
|
|
def setup_method(self):
|
|
self.backend = GiteaBackend()
|
|
|
|
def test_derives_api_base_from_repo_url(self):
|
|
result = self.backend.get_api_base("https://git.example.com/owner/repo")
|
|
assert result == "https://git.example.com/api/v1"
|
|
|
|
def test_derives_api_base_with_port(self):
|
|
result = self.backend.get_api_base("https://git.example.com:3000/owner/repo")
|
|
assert result == "https://git.example.com:3000/api/v1"
|
|
|
|
def test_invalid_url_raises_value_error(self):
|
|
with pytest.raises(ValueError, match="Cannot derive API base"):
|
|
self.backend.get_api_base("not-a-url")
|
|
|
|
def test_parse_url_uses_instance_host(self):
|
|
owner, repo = self.backend.parse_repo_url("https://git.example.com/owner/repo")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
|
|
class TestGiteaBackendPushFiles:
|
|
def setup_method(self):
|
|
self.backend = GiteaBackend()
|
|
self.repo_url = "https://git.example.com/owner/repo"
|
|
self.token = "gitea-token"
|
|
self.branch = "bambuddy-backup"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_n_files_produce_single_commit(self):
|
|
"""All changed files are bundled into one commit via the Git Data API."""
|
|
files = {"a.json": {"k": "v1"}, "b.json": {"k": "v2"}}
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
|
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(201, {"sha": "blob2"}),
|
|
_make_mock_response(201, {"sha": "new-tree"}),
|
|
_make_mock_response(201, {"sha": "new-commit"}),
|
|
]
|
|
)
|
|
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
|
|
|
|
assert result["status"] == "success"
|
|
assert result["files_changed"] == 2
|
|
commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
|
|
assert len(commit_calls) == 1
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_uses_gitea_api_v1_base_not_github(self):
|
|
"""Git Data API calls target the instance's /api/v1, not api.github.com."""
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
|
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(201, {"sha": "new-tree"}),
|
|
_make_mock_response(201, {"sha": "new-commit"}),
|
|
]
|
|
)
|
|
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
|
|
|
await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
first_get_url = client.get.call_args_list[0].args[0]
|
|
assert "git.example.com/api/v1" in first_get_url
|
|
assert "api.github.com" not in first_get_url
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_unchanged_files(self):
|
|
"""Files whose blob SHA matches the existing tree entry are excluded from the commit."""
|
|
content = {"name": "my-printer"}
|
|
sha = _blob_sha(content)
|
|
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
|
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
|
_make_mock_response(200, {"tree": [{"type": "blob", "path": "config/printers.json", "sha": sha}]}),
|
|
]
|
|
)
|
|
|
|
result = await self.backend.push_files(
|
|
self.repo_url, self.token, self.branch, {"config/printers.json": content}, client
|
|
)
|
|
|
|
assert result["status"] == "skipped"
|
|
client.post.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_creates_missing_branch_via_git_refs_api(self):
|
|
"""A missing backup branch is created via the Git Data API refs endpoint."""
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
# branch ref missing
|
|
_make_mock_response(404, {}),
|
|
# repo info for default branch
|
|
_make_mock_response(200, {"default_branch": "main"}),
|
|
# default branch ref
|
|
_make_mock_response(200, {"object": {"sha": "base-sha"}}),
|
|
# second push_files call: branch now exists
|
|
_make_mock_response(200, {"object": {"sha": "base-sha"}}),
|
|
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {}), # create ref
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(201, {"sha": "new-tree"}),
|
|
_make_mock_response(201, {"sha": "new-commit"}),
|
|
]
|
|
)
|
|
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
assert result["status"] == "success"
|
|
ref_create_call = client.post.call_args_list[0]
|
|
assert "/git/refs" in ref_create_call.args[0]
|
|
assert ref_create_call.kwargs["json"]["ref"] == f"refs/heads/{self.branch}"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_truncates_upstream_error_body_in_failure_message(self):
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"object": {"sha": "base-commit"}}),
|
|
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(500, {}, text="x" * 500),
|
|
]
|
|
)
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
assert result["status"] == "failed"
|
|
assert result["message"] == f"Failed to create tree: {'x' * 197}..."
|
|
|
|
|
|
class TestGiteaBackendListShapeRefResponse:
|
|
"""#1224, #1225 regression: Gitea/Forgejo return refs as a *list*, not a dict.
|
|
|
|
GitHub: ``GET /git/refs/heads/{branch}`` -> ``{"ref": ..., "object": {...}}``.
|
|
Gitea/Forgejo: same endpoint -> ``[{"ref": ..., "object": {...}}]``.
|
|
|
|
The pre-fix code did ``response.json()["object"]["sha"]`` on the Gitea path
|
|
and crashed with ``list indices must be integers or slices, not str``.
|
|
"""
|
|
|
|
def setup_method(self):
|
|
self.backend = GiteaBackend()
|
|
self.repo_url = "https://git.example.com/owner/repo"
|
|
self.token = "gitea-token"
|
|
self.branch = "bambuddy-backup"
|
|
|
|
def test_ref_sha_extracts_from_list(self):
|
|
assert self.backend._ref_sha([{"object": {"sha": "abc"}}]) == "abc"
|
|
|
|
def test_ref_sha_still_accepts_dict_shape(self):
|
|
# Defensive — if Gitea ever returns a dict (older versions, future change),
|
|
# we don't want to break.
|
|
assert self.backend._ref_sha({"object": {"sha": "abc"}}) == "abc"
|
|
|
|
def test_ref_sha_raises_on_empty_list(self):
|
|
with pytest.raises(ValueError):
|
|
self.backend._ref_sha([])
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_files_handles_list_shape_branch_ref(self):
|
|
"""The configured backup branch already exists — ref endpoint returns a list."""
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, [{"object": {"sha": "base-commit"}}]), # list shape
|
|
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(201, {"sha": "new-tree"}),
|
|
_make_mock_response(201, {"sha": "new-commit"}),
|
|
]
|
|
)
|
|
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
assert result["status"] == "success"
|
|
assert result["commit_sha"] == "new-commit"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_branch_handles_list_shape_default_branch_ref(self):
|
|
"""Backup branch missing — must read default branch's ref, also list-shaped."""
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(404, {}), # missing backup branch
|
|
_make_mock_response(200, {"default_branch": "main"}), # repo info
|
|
_make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref (list)
|
|
# second push_files() call — branch now exists
|
|
_make_mock_response(200, [{"object": {"sha": "main-sha"}}]),
|
|
_make_mock_response(200, {"tree": {"sha": "main-tree"}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {}), # create branch ref
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(201, {"sha": "new-tree"}),
|
|
_make_mock_response(201, {"sha": "new-commit"}),
|
|
]
|
|
)
|
|
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
assert result["status"] == "success"
|
|
|
|
|
|
class TestGiteaBackendWrappedCommitResponse:
|
|
"""#1224 regression: Gitea wraps the GitCommit fields under ``commit``.
|
|
|
|
GitHub's ``GET /git/commits/{sha}`` returns the unwrapped GitCommit schema
|
|
(``tree`` at top level). Gitea's same-named endpoint returns the wrapped
|
|
Commit schema where ``tree`` lives at ``commit.tree`` (Gitea 1.24+).
|
|
|
|
Pre-fix code did ``commit_response.json()["tree"]["sha"]`` and raised
|
|
``KeyError: 'tree'`` on every backup *after* the initial one — surfaced to
|
|
the user as the opaque ``Backup failed: 'tree'`` message.
|
|
"""
|
|
|
|
def setup_method(self):
|
|
self.backend = GiteaBackend()
|
|
self.repo_url = "https://git.example.com/owner/repo"
|
|
self.token = "gitea-token"
|
|
self.branch = "bambuddy-backup"
|
|
|
|
def test_commit_tree_sha_reads_flat_shape(self):
|
|
"""GitHub-compatible / older Gitea: ``tree`` at top level."""
|
|
assert self.backend._commit_tree_sha({"tree": {"sha": "abc"}}) == "abc"
|
|
|
|
def test_commit_tree_sha_reads_wrapped_shape(self):
|
|
"""Gitea 1.24+ / Forgejo: ``tree`` nested under ``commit``."""
|
|
assert self.backend._commit_tree_sha({"sha": "c1", "commit": {"tree": {"sha": "abc"}}}) == "abc"
|
|
|
|
def test_commit_tree_sha_returns_none_on_missing(self):
|
|
assert self.backend._commit_tree_sha({"sha": "c1", "commit": {}}) is None
|
|
assert self.backend._commit_tree_sha({}) is None
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_files_handles_wrapped_commit_response(self):
|
|
"""Subsequent backup against Gitea 1.24+ — commit endpoint returns wrapped shape."""
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
|
|
# Wrapped Gitea commit response — tree under "commit", not top level
|
|
_make_mock_response(200, {"sha": "base-commit", "commit": {"tree": {"sha": "base-tree"}}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(201, {"sha": "new-tree"}),
|
|
_make_mock_response(201, {"sha": "new-commit"}),
|
|
]
|
|
)
|
|
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
assert result["status"] == "success"
|
|
assert result["commit_sha"] == "new-commit"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_files_fails_cleanly_when_tree_sha_missing(self):
|
|
"""Defensive: malformed/unexpected commit response surfaces a clear error, not KeyError."""
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
|
|
_make_mock_response(200, {"sha": "base-commit"}), # no tree at all
|
|
]
|
|
)
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
assert result["status"] == "failed"
|
|
assert "tree SHA" in result["message"]
|
|
|
|
|
|
class TestGiteaBackendEmptyRepoInitialCommit:
|
|
"""#1224 regression: Git Data API refuses writes against empty Gitea repos.
|
|
|
|
GitHub accepts ``POST /git/blobs`` against an empty repo and creates the
|
|
initial commit + branch. Gitea returns 404 on every blob/tree/commit POST
|
|
until the repo has at least one commit. The fix is to use the Contents
|
|
API (``POST /repos/.../contents``) which seeds the branch + initial
|
|
commit in a single transaction.
|
|
"""
|
|
|
|
def setup_method(self):
|
|
self.backend = GiteaBackend()
|
|
self.repo_url = "https://git.example.com/owner/repo"
|
|
self.token = "gitea-token"
|
|
self.branch = "main"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_repo_uses_contents_api_not_git_data_api(self):
|
|
files = {"config/printers.json": {"name": "p1"}, "config/spools.json": {"id": 1}}
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(404, {}), # backup branch missing
|
|
_make_mock_response(200, {"default_branch": "main"}), # repo info
|
|
_make_mock_response(404, {}), # default branch missing too -> empty repo
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "initial-sha"}}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
|
|
|
|
assert result["status"] == "success"
|
|
assert result["files_changed"] == 2
|
|
assert result["commit_sha"] == "initial-sha"
|
|
|
|
contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
|
|
blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
|
|
tree_calls = [c for c in client.post.call_args_list if "/git/trees" in c.args[0]]
|
|
commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
|
|
ref_calls = [c for c in client.post.call_args_list if "/git/refs" in c.args[0]]
|
|
# Exactly one Contents API call, no Git Data API writes
|
|
assert len(contents_calls) == 1
|
|
assert len(blob_calls) == 0
|
|
assert len(tree_calls) == 0
|
|
assert len(commit_calls) == 0
|
|
assert len(ref_calls) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_contents_api_payload_shape(self):
|
|
"""The Contents API call must carry branch+new_branch+files in the documented shape."""
|
|
files = {"a.json": {"k": "v"}, "nested/b.json": {"x": 1}}
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(404, {}),
|
|
_make_mock_response(200, {"default_branch": "main"}),
|
|
_make_mock_response(404, {}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "abc"}}))
|
|
|
|
await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
|
|
|
|
body = client.post.call_args.kwargs["json"]
|
|
assert body["branch"] == "main"
|
|
assert body["new_branch"] == "main"
|
|
assert body["message"].startswith("Initial Bambuddy backup")
|
|
assert len(body["files"]) == 2
|
|
paths = {f["path"] for f in body["files"]}
|
|
assert paths == {"a.json", "nested/b.json"}
|
|
for f in body["files"]:
|
|
assert f["operation"] == "create"
|
|
# Content is base64-encoded JSON of the original dict
|
|
decoded = base64.b64decode(f["content"]).decode("utf-8")
|
|
assert json.loads(decoded) == files[f["path"]]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_contents_api_failure_truncates_error_body(self):
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(404, {}),
|
|
_make_mock_response(200, {"default_branch": "main"}),
|
|
_make_mock_response(404, {}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
|
|
|
|
assert result["status"] == "failed"
|
|
assert result["message"] == f"Failed to create initial commit: {'x' * 197}..."
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_empty_files_skips_contents_api_call(self):
|
|
# Edge: nothing to commit -> don't make a useless Contents API call.
|
|
client = AsyncMock()
|
|
client.post = AsyncMock()
|
|
|
|
result = await self.backend._create_initial_commit(
|
|
client, {}, "https://git.example.com/api/v1", "owner", "repo", "main", {}
|
|
)
|
|
|
|
assert result["status"] == "skipped"
|
|
assert result["files_changed"] == 0
|
|
client.post.assert_not_called()
|
|
|
|
|
|
class TestForgejoInheritsGiteaFixes:
|
|
"""ForgejoBackend extends GiteaBackend with no overrides — must inherit both fixes."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forgejo_handles_list_shape_ref_response(self):
|
|
backend = ForgejoBackend()
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
|
|
_make_mock_response(200, {"tree": {"sha": "base-tree"}}),
|
|
_make_mock_response(200, {"tree": []}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(201, {"sha": "blob1"}),
|
|
_make_mock_response(201, {"sha": "new-tree"}),
|
|
_make_mock_response(201, {"sha": "new-commit"}),
|
|
]
|
|
)
|
|
client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
|
|
|
|
result = await backend.push_files(
|
|
"https://forgejo.example.com/owner/repo",
|
|
"token",
|
|
"bambuddy-backup",
|
|
{"a.json": {"k": "v"}},
|
|
client,
|
|
)
|
|
assert result["status"] == "success"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_forgejo_empty_repo_uses_contents_api(self):
|
|
backend = ForgejoBackend()
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(404, {}),
|
|
_make_mock_response(200, {"default_branch": "main"}),
|
|
_make_mock_response(404, {}),
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "fj-sha"}}))
|
|
|
|
result = await backend.push_files(
|
|
"https://forgejo.example.com/owner/repo",
|
|
"token",
|
|
"main",
|
|
{"a.json": {"k": "v"}},
|
|
client,
|
|
)
|
|
assert result["status"] == "success"
|
|
contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
|
|
blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
|
|
assert len(contents_calls) == 1
|
|
assert len(blob_calls) == 0
|
|
|
|
|
|
class TestForgejoBackendApiBase:
|
|
def setup_method(self):
|
|
self.backend = ForgejoBackend()
|
|
|
|
def test_derives_api_base_from_repo_url(self):
|
|
result = self.backend.get_api_base("https://forgejo.example.com/owner/repo")
|
|
assert result == "https://forgejo.example.com/api/v1"
|
|
|
|
def test_derives_api_base_with_port(self):
|
|
result = self.backend.get_api_base("https://forgejo.example.com:3000/owner/repo")
|
|
assert result == "https://forgejo.example.com:3000/api/v1"
|
|
|
|
def test_invalid_url_raises_value_error(self):
|
|
with pytest.raises(ValueError, match="Cannot derive API base"):
|
|
self.backend.get_api_base("not-a-url")
|
|
|
|
def test_parse_url_uses_instance_host(self):
|
|
owner, repo = self.backend.parse_repo_url("https://forgejo.example.com/owner/repo")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
|
|
class TestGitLabBackend:
|
|
def setup_method(self):
|
|
self.backend = GitLabBackend()
|
|
|
|
def test_parse_url_https(self):
|
|
owner, repo = self.backend.parse_repo_url("https://gitlab.com/owner/repo")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_parse_url_ssh(self):
|
|
owner, repo = self.backend.parse_repo_url("git@gitlab.com:owner/repo.git")
|
|
assert owner == "owner"
|
|
assert repo == "repo"
|
|
|
|
def test_parse_url_invalid_raises(self):
|
|
with pytest.raises(ValueError):
|
|
self.backend.parse_repo_url("not-a-url")
|
|
|
|
def test_get_api_base_derives_from_repo_url(self):
|
|
result = self.backend.get_api_base("https://gitlab.com/owner/repo")
|
|
assert result == "https://gitlab.com/api/v4"
|
|
|
|
def test_get_api_base_derives_from_self_hosted_url(self):
|
|
result = self.backend.get_api_base("https://my-gitlab.example.com/owner/repo")
|
|
assert result == "https://my-gitlab.example.com/api/v4"
|
|
|
|
def test_get_api_base_invalid_url_raises(self):
|
|
with pytest.raises(ValueError, match="Cannot derive API base"):
|
|
self.backend.get_api_base("not-a-url")
|
|
|
|
def test_get_headers_uses_bearer_token(self):
|
|
headers = self.backend.get_headers("mytoken")
|
|
assert headers["Authorization"] == "Bearer mytoken"
|
|
assert "Content-Type" in headers
|
|
|
|
def test_parse_url_subgroup_https(self):
|
|
namespace, repo = self.backend.parse_repo_url("https://gitlab.com/group/subgroup/project")
|
|
assert namespace == "group/subgroup"
|
|
assert repo == "project"
|
|
|
|
def test_parse_url_deep_namespace_https(self):
|
|
namespace, repo = self.backend.parse_repo_url("https://gitlab.com/myorg/team/api/backend")
|
|
assert namespace == "myorg/team/api"
|
|
assert repo == "backend"
|
|
|
|
def test_parse_url_subgroup_ssh(self):
|
|
namespace, repo = self.backend.parse_repo_url("git@gitlab.com:group/subgroup/project.git")
|
|
assert namespace == "group/subgroup"
|
|
assert repo == "project"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_push_files_encodes_subgroup_namespace_in_api_url(self):
|
|
backend = GitLabBackend()
|
|
repo_url = "https://gitlab.com/group/subgroup/project"
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"name": "bambuddy-backup"}),
|
|
_make_mock_response(200, []),
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
|
|
|
|
await backend.push_files(repo_url, "token", "bambuddy-backup", {"f.json": {}}, client)
|
|
|
|
called_url = client.get.call_args_list[0].args[0]
|
|
assert "group%2Fsubgroup%2Fproject" in called_url
|
|
|
|
|
|
def _blob_sha(content: dict) -> str:
|
|
content_bytes = json.dumps(content, indent=2, default=str).encode("utf-8")
|
|
return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
|
|
|
|
|
|
def _make_mock_response(status_code: int, body=None, text: str = ""):
|
|
resp = MagicMock()
|
|
resp.status_code = status_code
|
|
resp.text = text
|
|
resp.json = MagicMock(return_value=body or {})
|
|
return resp
|
|
|
|
|
|
class TestGitLabBackendPushFiles:
|
|
def setup_method(self):
|
|
self.backend = GitLabBackend()
|
|
self.repo_url = "https://gitlab.com/owner/repo"
|
|
self.token = "glpat-test"
|
|
self.branch = "bambuddy-backup"
|
|
self.files = {"config/printers.json": {"name": "my-printer"}}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_skips_commit_when_content_unchanged(self):
|
|
sha = _blob_sha(self.files["config/printers.json"])
|
|
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
# branch check → branch exists
|
|
_make_mock_response(200, {"name": self.branch}),
|
|
# tree page 1 → one blob whose sha matches current content
|
|
_make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": sha}]),
|
|
# tree page 2 → empty, stop pagination
|
|
_make_mock_response(200, []),
|
|
]
|
|
)
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
|
|
|
assert result["status"] == "skipped"
|
|
assert result["files_changed"] == 0
|
|
client.post.assert_not_called()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_commits_when_content_changed(self):
|
|
stale_sha = "0000000000000000000000000000000000000000"
|
|
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"name": self.branch}),
|
|
_make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": stale_sha}]),
|
|
_make_mock_response(200, []), # page 2 empty, stop pagination
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
|
|
|
assert result["status"] == "success"
|
|
assert result["files_changed"] == 1
|
|
client.post.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_truncates_upstream_error_body_in_failure_message(self):
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"name": self.branch}),
|
|
_make_mock_response(200, []),
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
|
|
|
assert result["status"] == "failed"
|
|
assert result["message"] == f"Failed to create commit: {'x' * 197}..."
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_creates_new_file_not_in_existing_tree(self):
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"name": self.branch}),
|
|
# tree is empty
|
|
_make_mock_response(200, []),
|
|
]
|
|
)
|
|
client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "def456"}))
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
|
|
|
assert result["status"] == "success"
|
|
call_kwargs = client.post.call_args.kwargs["json"]
|
|
assert call_kwargs["actions"][0]["action"] == "create"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_paginates_tree_to_find_unchanged_file_on_page_2(self):
|
|
"""Files beyond the first 100 are fetched; a file on page 2 is correctly skipped if unchanged."""
|
|
sha = _blob_sha(self.files["config/printers.json"])
|
|
page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)]
|
|
page2_items = [{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)] + [
|
|
{"type": "blob", "path": "config/printers.json", "id": sha}
|
|
] # 120 total blobs across two pages
|
|
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(
|
|
side_effect=[
|
|
_make_mock_response(200, {"name": self.branch}), # branch check
|
|
_make_mock_response(200, page1_items), # tree page 1
|
|
_make_mock_response(200, page2_items), # tree page 2
|
|
_make_mock_response(200, []), # tree page 3 empty, stop
|
|
]
|
|
)
|
|
|
|
result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
|
|
|
|
assert result["status"] == "skipped"
|
|
client.post.assert_not_called()
|