Skip to content

Commit c49ffef

Browse files
committed
Add tests
1 parent 7eaf39c commit c49ffef

2 files changed

Lines changed: 229 additions & 6 deletions

File tree

github_backup/github_backup.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2107,9 +2107,7 @@ def gist_backup_is_current(repository, repo_cwd, repo_dir):
21072107
if not updated_at:
21082108
return False
21092109
try:
2110-
with codecs.open(
2111-
os.path.join(repo_cwd, "gist.json"), encoding="utf-8"
2112-
) as f:
2110+
with codecs.open(os.path.join(repo_cwd, "gist.json"), encoding="utf-8") as f:
21132111
stored = json.load(f)
21142112
except (OSError, ValueError):
21152113
return False
@@ -2250,9 +2248,7 @@ def backup_repositories(args, output_directory, repositories):
22502248
continue
22512249

22522250
if skipped_unchanged_gists:
2253-
logger.info(
2254-
"Skipped {0} unchanged gists".format(skipped_unchanged_gists)
2255-
)
2251+
logger.info("Skipped {0} unchanged gists".format(skipped_unchanged_gists))
22562252

22572253
if incremental_resource_work_attempted:
22582254
remove_legacy_last_update_if_migrated(

tests/test_gists.py

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""Tests for gist backup behavior: skipping the repository listing for
2+
gists-only backups, skipping fetches for unchanged gists, and writing
3+
gist.json even when the clone was skipped (e.g. DMCA-blocked gists)."""
4+
5+
import json
6+
import os
7+
8+
import pytest
9+
from unittest.mock import patch
10+
11+
from github_backup.github_backup import (
12+
backup_repositories,
13+
gist_backup_is_current,
14+
repository_list_needed,
15+
retrieve_repositories,
16+
)
17+
18+
19+
class TestRepositoryListNeeded:
20+
"""The base /user/repos listing should only be fetched when a
21+
per-repository resource was requested."""
22+
23+
def test_gists_only_does_not_need_listing(self, create_args):
24+
args = create_args(include_gists=True, include_starred_gists=True)
25+
assert not repository_list_needed(args)
26+
27+
def test_account_level_flags_do_not_need_listing(self, create_args):
28+
args = create_args(
29+
include_starred=True,
30+
include_watched=True,
31+
include_followers=True,
32+
include_following=True,
33+
)
34+
assert not repository_list_needed(args)
35+
36+
def test_all_starred_does_not_need_listing(self, create_args):
37+
# starred repos come from /users/{user}/starred, not /user/repos
38+
args = create_args(all_starred=True)
39+
assert not repository_list_needed(args)
40+
41+
@pytest.mark.parametrize(
42+
"flag",
43+
[
44+
"include_repository",
45+
"include_everything",
46+
"include_wiki",
47+
"include_issues",
48+
"include_pulls",
49+
"include_releases",
50+
"include_attachments",
51+
],
52+
)
53+
def test_repository_scoped_flags_need_listing(self, create_args, flag):
54+
args = create_args(**{flag: True})
55+
assert repository_list_needed(args)
56+
57+
def test_single_repository_needs_listing(self, create_args):
58+
args = create_args(repository="some-repo")
59+
assert repository_list_needed(args)
60+
61+
def test_unknown_include_flag_defaults_to_needing_listing(self, create_args):
62+
# Fail-safe: a future include_* flag that nobody registered in
63+
# NON_REPOSITORY_RESOURCES must not silently skip the listing.
64+
args = create_args(include_some_future_resource=True)
65+
assert repository_list_needed(args)
66+
67+
68+
class TestRetrieveRepositoriesSkipsListing:
69+
"""retrieve_repositories should not hit the repository endpoints for a
70+
gists-only backup, but must still fetch gists and starred gists."""
71+
72+
@patch("github_backup.github_backup.retrieve_data")
73+
def test_gists_only_skips_repo_listing(self, mock_retrieve, create_args):
74+
args = create_args(include_gists=True, include_starred_gists=True)
75+
mock_retrieve.return_value = [{"id": "abc123"}]
76+
77+
repos = retrieve_repositories(args, {"login": "testuser"})
78+
79+
requested = [call.args[1] for call in mock_retrieve.call_args_list]
80+
assert not any("/repos" in url for url in requested), requested
81+
assert any(url.endswith("/users/testuser/gists") for url in requested)
82+
assert any(url.endswith("/gists/starred") for url in requested)
83+
assert all(r["is_gist"] for r in repos)
84+
85+
@patch("github_backup.github_backup.retrieve_data")
86+
def test_repository_backup_still_fetches_listing(self, mock_retrieve, create_args):
87+
args = create_args(include_repository=True)
88+
mock_retrieve.return_value = []
89+
90+
retrieve_repositories(args, {"login": "testuser"})
91+
92+
requested = [call.args[1] for call in mock_retrieve.call_args_list]
93+
assert any(url.endswith("/user/repos") for url in requested), requested
94+
95+
96+
class TestGistBackupIsCurrent:
97+
"""A gist fetch is skipped only when a clone exists and the stored
98+
gist.json updated_at matches the listing."""
99+
100+
GIST = {"id": "abc123", "is_gist": True, "updated_at": "2026-07-17T10:00:00Z"}
101+
102+
def _write_backup(self, repo_cwd, updated_at="2026-07-17T10:00:00Z", clone=True):
103+
if clone:
104+
os.makedirs(os.path.join(repo_cwd, "repository"))
105+
else:
106+
os.makedirs(repo_cwd)
107+
with open(os.path.join(repo_cwd, "gist.json"), "w") as f:
108+
json.dump({"id": "abc123", "updated_at": updated_at}, f)
109+
110+
def test_current_when_unchanged(self, tmp_path):
111+
repo_cwd = str(tmp_path / "gists" / "abc123")
112+
self._write_backup(repo_cwd)
113+
assert gist_backup_is_current(
114+
self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
115+
)
116+
117+
def test_not_current_when_updated(self, tmp_path):
118+
repo_cwd = str(tmp_path / "gists" / "abc123")
119+
self._write_backup(repo_cwd, updated_at="2020-01-01T00:00:00Z")
120+
assert not gist_backup_is_current(
121+
self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
122+
)
123+
124+
def test_not_current_without_clone(self, tmp_path):
125+
# gist.json alone is not enough; e.g. the clone was skipped because
126+
# the gist was inaccessible, so keep retrying the fetch
127+
repo_cwd = str(tmp_path / "gists" / "abc123")
128+
self._write_backup(repo_cwd, clone=False)
129+
assert not gist_backup_is_current(
130+
self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
131+
)
132+
133+
def test_not_current_with_corrupt_gist_json(self, tmp_path):
134+
repo_cwd = str(tmp_path / "gists" / "abc123")
135+
os.makedirs(os.path.join(repo_cwd, "repository"))
136+
with open(os.path.join(repo_cwd, "gist.json"), "w") as f:
137+
f.write("{not json")
138+
assert not gist_backup_is_current(
139+
self.GIST, repo_cwd, os.path.join(repo_cwd, "repository")
140+
)
141+
142+
def test_not_current_without_updated_at(self, tmp_path):
143+
repo_cwd = str(tmp_path / "gists" / "abc123")
144+
self._write_backup(repo_cwd)
145+
assert not gist_backup_is_current(
146+
{"id": "abc123", "is_gist": True},
147+
repo_cwd,
148+
os.path.join(repo_cwd, "repository"),
149+
)
150+
151+
152+
class TestGistBackup:
153+
"""End-to-end behavior of backup_repositories for gists."""
154+
155+
def _gist(self, **extra):
156+
gist = {
157+
"id": "abc123",
158+
"is_gist": True,
159+
"updated_at": "2026-07-17T10:00:00Z",
160+
"git_pull_url": "https://gist.github.com/abc123.git",
161+
}
162+
gist.update(extra)
163+
return gist
164+
165+
@patch("github_backup.github_backup.fetch_repository")
166+
def test_new_gist_is_fetched_and_metadata_written(
167+
self, mock_fetch, create_args, tmp_path
168+
):
169+
args = create_args(include_gists=True)
170+
171+
backup_repositories(args, str(tmp_path), [self._gist()])
172+
173+
mock_fetch.assert_called_once()
174+
gist_json = tmp_path / "gists" / "abc123" / "gist.json"
175+
assert json.loads(gist_json.read_text())["id"] == "abc123"
176+
177+
@patch("github_backup.github_backup.fetch_repository")
178+
def test_unchanged_gist_skips_fetch_but_refreshes_metadata(
179+
self, mock_fetch, create_args, tmp_path
180+
):
181+
args = create_args(include_gists=True)
182+
repo_cwd = tmp_path / "gists" / "abc123"
183+
(repo_cwd / "repository").mkdir(parents=True)
184+
(repo_cwd / "gist.json").write_text(
185+
json.dumps({"id": "abc123", "updated_at": "2026-07-17T10:00:00Z"})
186+
)
187+
188+
backup_repositories(args, str(tmp_path), [self._gist(forks=5)])
189+
190+
assert not mock_fetch.called, "unchanged gist should not be fetched"
191+
# gist.json is rewritten from the listing so metadata that changes
192+
# without bumping updated_at (fork counts etc.) stays fresh
193+
assert json.loads((repo_cwd / "gist.json").read_text())["forks"] == 5
194+
195+
@patch("github_backup.github_backup.fetch_repository")
196+
def test_updated_gist_is_fetched(self, mock_fetch, create_args, tmp_path):
197+
args = create_args(include_gists=True)
198+
repo_cwd = tmp_path / "gists" / "abc123"
199+
(repo_cwd / "repository").mkdir(parents=True)
200+
(repo_cwd / "gist.json").write_text(
201+
json.dumps({"id": "abc123", "updated_at": "2020-01-01T00:00:00Z"})
202+
)
203+
204+
backup_repositories(args, str(tmp_path), [self._gist()])
205+
206+
mock_fetch.assert_called_once()
207+
208+
@patch("github_backup.github_backup.fetch_repository")
209+
def test_inaccessible_gist_does_not_crash_backup(
210+
self, mock_fetch, create_args, tmp_path
211+
):
212+
"""Regression test: fetch_repository skips inaccessible gists (e.g.
213+
DMCA-blocked) without creating the clone directory; writing gist.json
214+
must not crash and the next gist must still be processed."""
215+
args = create_args(include_gists=True)
216+
# mock fetch never creates the directory, like a skipped clone
217+
blocked = self._gist(id="deadbeef")
218+
second = self._gist()
219+
220+
backup_repositories(args, str(tmp_path), [blocked, second])
221+
222+
assert (tmp_path / "gists" / "deadbeef" / "gist.json").is_file()
223+
assert (tmp_path / "gists" / "abc123" / "gist.json").is_file()
224+
225+
226+
if __name__ == "__main__":
227+
pytest.main([__file__, "-v"])

0 commit comments

Comments
 (0)