test_git_providers.py 70 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673
  1. """Unit tests for the git_providers abstraction package."""
  2. import base64
  3. import hashlib
  4. import json
  5. from unittest.mock import AsyncMock, MagicMock
  6. import pytest
  7. from backend.app.services.git_providers.factory import get_provider_backend
  8. from backend.app.services.git_providers.forgejo import ForgejoBackend
  9. from backend.app.services.git_providers.gitea import GiteaBackend
  10. from backend.app.services.git_providers.github import GitHubBackend
  11. from backend.app.services.git_providers.gitlab import GitLabBackend
  12. class TestFactory:
  13. def test_known_providers_return_correct_class(self):
  14. assert isinstance(get_provider_backend("github"), GitHubBackend)
  15. assert isinstance(get_provider_backend("gitea"), GiteaBackend)
  16. assert isinstance(get_provider_backend("forgejo"), ForgejoBackend)
  17. assert isinstance(get_provider_backend("gitlab"), GitLabBackend)
  18. def test_unknown_provider_raises_value_error(self):
  19. with pytest.raises(ValueError, match="Unknown Git provider"):
  20. get_provider_backend("bitbucket")
  21. class TestGitHubBackendParseUrl:
  22. def setup_method(self):
  23. self.backend = GitHubBackend()
  24. def test_https_url(self):
  25. owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo")
  26. assert owner == "owner"
  27. assert repo == "repo"
  28. def test_https_url_with_git_suffix(self):
  29. owner, repo = self.backend.parse_repo_url("https://github.com/owner/repo.git")
  30. assert owner == "owner"
  31. assert repo == "repo"
  32. def test_ssh_url(self):
  33. owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo")
  34. assert owner == "owner"
  35. assert repo == "repo"
  36. def test_ssh_url_with_git_suffix(self):
  37. owner, repo = self.backend.parse_repo_url("git@github.com:owner/repo.git")
  38. assert owner == "owner"
  39. assert repo == "repo"
  40. def test_invalid_url_raises_value_error(self):
  41. with pytest.raises(ValueError, match="Cannot parse repository URL"):
  42. self.backend.parse_repo_url("https://example.com/not-a-repo")
  43. def test_empty_url_raises_value_error(self):
  44. with pytest.raises(ValueError):
  45. self.backend.parse_repo_url("")
  46. class TestGitHubBackendApiBase:
  47. def setup_method(self):
  48. self.backend = GitHubBackend()
  49. def test_github_com_returns_api_github_com(self):
  50. assert self.backend.get_api_base("https://github.com/owner/repo") == "https://api.github.com"
  51. def test_ghe_host_returns_v3_endpoint(self):
  52. assert self.backend.get_api_base("https://github.example.com/owner/repo") == "https://github.example.com/api/v3"
  53. def test_ghe_host_with_port(self):
  54. assert (
  55. self.backend.get_api_base("https://github.example.com:8443/owner/repo")
  56. == "https://github.example.com:8443/api/v3"
  57. )
  58. def test_ssh_github_com_returns_api_github_com(self):
  59. assert self.backend.get_api_base("git@github.com:owner/repo.git") == "https://api.github.com"
  60. def test_ssh_ghe_host_returns_v3_endpoint(self):
  61. assert self.backend.get_api_base("git@github.example.com:owner/repo.git") == "https://github.example.com/api/v3"
  62. class TestGitHubBackendPushFiles:
  63. def setup_method(self):
  64. self.backend = GitHubBackend()
  65. self.repo_url = "https://github.com/owner/repo"
  66. self.token = "ghp_token"
  67. self.branch = "bambuddy-backup"
  68. @pytest.mark.asyncio
  69. async def test_successful_push(self):
  70. """Happy path: changed file goes through blob→tree→commit→ref-update."""
  71. client = AsyncMock()
  72. client.get = AsyncMock(
  73. side_effect=[
  74. _make_mock_response(200, {"object": {"sha": "c1"}}),
  75. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  76. _make_mock_response(200, {"tree": []}),
  77. ]
  78. )
  79. client.post = AsyncMock(
  80. side_effect=[
  81. _make_mock_response(201, {"sha": "blob1"}),
  82. _make_mock_response(201, {"sha": "new-tree"}),
  83. _make_mock_response(201, {"sha": "new-commit"}),
  84. ]
  85. )
  86. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  87. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  88. assert result["status"] == "success"
  89. assert result["files_changed"] == 1
  90. @pytest.mark.asyncio
  91. async def test_skips_unchanged_files(self):
  92. """File whose blob SHA matches the existing tree entry is excluded from the commit."""
  93. content = {"name": "my-printer"}
  94. sha = _blob_sha(content)
  95. client = AsyncMock()
  96. client.get = AsyncMock(
  97. side_effect=[
  98. _make_mock_response(200, {"object": {"sha": "c1"}}),
  99. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  100. _make_mock_response(200, {"tree": [{"type": "blob", "path": "config.json", "sha": sha}]}),
  101. ]
  102. )
  103. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"config.json": content}, client)
  104. assert result["status"] == "skipped"
  105. client.post.assert_not_called()
  106. @pytest.mark.asyncio
  107. async def test_blob_failure_returns_failed_not_skipped(self):
  108. """A non-201 blob response must return 'failed', not silently fall through to 'skipped'."""
  109. client = AsyncMock()
  110. client.get = AsyncMock(
  111. side_effect=[
  112. _make_mock_response(200, {"object": {"sha": "c1"}}),
  113. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  114. _make_mock_response(200, {"tree": []}),
  115. ]
  116. )
  117. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="Internal Server Error"))
  118. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  119. assert result["status"] == "failed"
  120. assert "failed" in result["message"].lower()
  121. @pytest.mark.asyncio
  122. async def test_blob_404_surfaces_token_scope_hint(self):
  123. """A 404 on POST /git/blobs surfaces a token scope/visibility hint, not 'skipped'."""
  124. client = AsyncMock()
  125. client.get = AsyncMock(
  126. side_effect=[
  127. _make_mock_response(200, {"object": {"sha": "c1"}}),
  128. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  129. _make_mock_response(200, {"tree": []}),
  130. ]
  131. )
  132. client.post = AsyncMock(return_value=_make_mock_response(404, {}))
  133. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  134. assert result["status"] == "failed"
  135. assert "404" in result["message"]
  136. assert "token scope" in result["message"].lower()
  137. @pytest.mark.asyncio
  138. async def test_initial_commit_blob_404_surfaces_token_scope_hint(self):
  139. """Empty repo path: 404 on POST /git/blobs surfaces token scope hint."""
  140. client = AsyncMock()
  141. client.get = AsyncMock(
  142. side_effect=[
  143. _make_mock_response(404, {}), # backup branch missing
  144. _make_mock_response(200, {"default_branch": "main"}), # repo info
  145. _make_mock_response(404, {}), # default branch missing -> empty repo
  146. ]
  147. )
  148. client.post = AsyncMock(return_value=_make_mock_response(404, {}))
  149. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  150. assert result["status"] == "failed"
  151. assert "404" in result["message"]
  152. assert "token scope" in result["message"].lower()
  153. @pytest.mark.asyncio
  154. async def test_initial_commit_blob_non_201_returns_path_in_message(self):
  155. """Empty repo path: non-201 on POST /git/blobs includes the file path in the failure message."""
  156. client = AsyncMock()
  157. client.get = AsyncMock(
  158. side_effect=[
  159. _make_mock_response(404, {}), # backup branch missing
  160. _make_mock_response(200, {"default_branch": "main"}), # repo info
  161. _make_mock_response(404, {}), # default branch missing -> empty repo
  162. ]
  163. )
  164. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="Internal Server Error"))
  165. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  166. assert result["status"] == "failed"
  167. assert "a.json" in result["message"]
  168. class TestGitHubBackendRobustness:
  169. """Coverage for the B18-B26 PR feedback round: GitHub backend.
  170. Targets failure paths that previously silent-failed or surfaced cryptic
  171. one-word strings to operators (KeyError on missing JSON keys, etc.).
  172. """
  173. def setup_method(self):
  174. self.backend = GitHubBackend()
  175. self.repo_url = "https://github.com/owner/repo"
  176. self.token = "ghp_token"
  177. self.branch = "bambuddy-backup"
  178. @pytest.mark.asyncio
  179. async def test_tree_fetch_failure_returns_failed_not_silent_skip(self):
  180. """B18: A non-200 tree GET must surface a clear failure with status code, not let
  181. the downstream blob POSTs fire with an empty existing_files map."""
  182. client = AsyncMock()
  183. client.get = AsyncMock(
  184. side_effect=[
  185. _make_mock_response(200, {"object": {"sha": "c1"}}),
  186. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  187. _make_mock_response(500, {}, text="Internal Server Error"),
  188. ]
  189. )
  190. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  191. assert result["status"] == "failed"
  192. assert "existing tree" in result["message"]
  193. assert "500" in result["message"]
  194. client.post.assert_not_called()
  195. @pytest.mark.asyncio
  196. async def test_truncated_tree_response_returns_failed(self):
  197. """B24: GitHub's tree API truncates >7MB / >100k entries. A truncated map would
  198. miss SHAs and re-upload every file as new on each backup — fail loudly instead."""
  199. client = AsyncMock()
  200. client.get = AsyncMock(
  201. side_effect=[
  202. _make_mock_response(200, {"object": {"sha": "c1"}}),
  203. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  204. _make_mock_response(
  205. 200, {"tree": [{"type": "blob", "path": "a.json", "sha": "old"}], "truncated": True}
  206. ),
  207. ]
  208. )
  209. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  210. assert result["status"] == "failed"
  211. assert "truncated" in result["message"].lower()
  212. assert "rotate the backup repository" in result["message"].lower()
  213. client.post.assert_not_called()
  214. @pytest.mark.asyncio
  215. async def test_malformed_ref_response_returns_clear_message(self):
  216. """B20: An unexpected ref body (no object.sha) surfaces a clear shape-error,
  217. not 'object' as the user-facing message via the catch-all."""
  218. client = AsyncMock()
  219. client.get = AsyncMock(return_value=_make_mock_response(200, {"unexpected": "shape"}))
  220. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  221. assert result["status"] == "failed"
  222. assert "ref response" in result["message"].lower()
  223. assert "missing key 'object'" in result["message"]
  224. @pytest.mark.asyncio
  225. async def test_malformed_commit_response_returns_clear_message(self):
  226. """B20: An unexpected commit body (no tree.sha) surfaces a clear shape-error,
  227. not 'tree' as the user-facing message via the catch-all."""
  228. client = AsyncMock()
  229. client.get = AsyncMock(
  230. side_effect=[
  231. _make_mock_response(200, {"object": {"sha": "c1"}}),
  232. _make_mock_response(200, {"sha": "c1"}), # no top-level tree
  233. ]
  234. )
  235. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  236. assert result["status"] == "failed"
  237. assert "commit response" in result["message"].lower()
  238. assert "missing key 'tree'" in result["message"]
  239. @pytest.mark.asyncio
  240. async def test_malformed_blob_response_returns_clear_message(self):
  241. """B20: A 201 blob response with no sha field surfaces shape-error, not KeyError."""
  242. client = AsyncMock()
  243. client.get = AsyncMock(
  244. side_effect=[
  245. _make_mock_response(200, {"object": {"sha": "c1"}}),
  246. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  247. _make_mock_response(200, {"tree": []}),
  248. ]
  249. )
  250. client.post = AsyncMock(return_value=_make_mock_response(201, {"unexpected": "shape"}))
  251. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  252. assert result["status"] == "failed"
  253. assert "blob response" in result["message"].lower()
  254. assert "a.json" in result["message"]
  255. @pytest.mark.asyncio
  256. async def test_create_branch_403_includes_status_code(self):
  257. """B19: A 403 on POST /git/refs surfaces the HTTP status code so the operator can
  258. tell 'no write scope' apart from generic upstream errors."""
  259. client = AsyncMock()
  260. client.get = AsyncMock(
  261. side_effect=[
  262. _make_mock_response(404, {}), # backup branch missing
  263. _make_mock_response(200, {"default_branch": "main"}), # repo info
  264. _make_mock_response(200, {"object": {"sha": "main-sha"}}), # default branch ref
  265. ]
  266. )
  267. client.post = AsyncMock(return_value=_make_mock_response(403, {"message": "Forbidden"}))
  268. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  269. assert result["status"] == "failed"
  270. assert "403" in result["message"]
  271. assert "branch" in result["message"].lower()
  272. @pytest.mark.asyncio
  273. async def test_create_branch_422_includes_status_code(self):
  274. """B19: 422 with empty body must still produce a diagnostic message — the previous
  275. assertion accepted either the status code OR a body substring, masking this gap."""
  276. client = AsyncMock()
  277. client.get = AsyncMock(
  278. side_effect=[
  279. _make_mock_response(404, {}), # backup branch missing
  280. _make_mock_response(200, {"default_branch": "main"}), # repo info
  281. _make_mock_response(200, {"object": {"sha": "main-sha"}}), # default branch ref
  282. ]
  283. )
  284. # 422 with empty body — the assertion must rely on the status code, not the body
  285. client.post = AsyncMock(return_value=_make_mock_response(422, {}, text=""))
  286. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  287. assert result["status"] == "failed"
  288. assert "422" in result["message"]
  289. @pytest.mark.asyncio
  290. async def test_test_connection_failure_includes_exception_message(self):
  291. """B23: A network exception during test_connection surfaces both the class name and
  292. the message (truncated), so the user clicking Test Connection sees actionable text
  293. like 'certificate verify failed', not just 'ConnectError'."""
  294. import httpx
  295. client = AsyncMock()
  296. client.get = AsyncMock(
  297. side_effect=httpx.ConnectError("certificate verify failed: unable to get local issuer certificate")
  298. )
  299. result = await self.backend.test_connection(self.repo_url, self.token, client)
  300. assert result["success"] is False
  301. assert "ConnectError" in result["message"]
  302. assert "certificate verify failed" in result["message"]
  303. @pytest.mark.asyncio
  304. async def test_test_connection_truncates_long_exception_message(self):
  305. """B23: The user-facing exception detail is bounded to 200 chars."""
  306. import httpx
  307. long_message = "x" * 500
  308. client = AsyncMock()
  309. client.get = AsyncMock(side_effect=httpx.ConnectError(long_message))
  310. result = await self.backend.test_connection(self.repo_url, self.token, client)
  311. assert result["success"] is False
  312. # Total message ≈ "Connection failed: ConnectError: " (33) + 200 char detail
  313. assert len(result["message"]) < 300
  314. @pytest.mark.asyncio
  315. async def test_initial_commit_malformed_blob_response(self):
  316. """B20: _create_initial_commit: 201 blob response with no sha surfaces shape-error."""
  317. client = AsyncMock()
  318. client.get = AsyncMock(
  319. side_effect=[
  320. _make_mock_response(404, {}), # backup branch missing
  321. _make_mock_response(200, {"default_branch": "main"}),
  322. _make_mock_response(404, {}), # default branch missing -> empty repo
  323. ]
  324. )
  325. client.post = AsyncMock(return_value=_make_mock_response(201, {"unexpected": "shape"}))
  326. result = await self.backend.push_files(
  327. self.repo_url, self.token, self.branch, {"seed.json": {"k": "v"}}, client
  328. )
  329. assert result["status"] == "failed"
  330. assert "blob response" in result["message"].lower()
  331. assert "seed.json" in result["message"]
  332. @pytest.mark.asyncio
  333. async def test_recursive_push_files_log_marker_on_branch_create(self, caplog):
  334. """B26: After a successful branch create, the re-entry into push_files emits an
  335. info-level marker so operators can correlate the second pass with the first."""
  336. import logging
  337. client = AsyncMock()
  338. client.get = AsyncMock(
  339. side_effect=[
  340. _make_mock_response(404, {}), # first push: branch missing
  341. _make_mock_response(200, {"default_branch": "main"}), # repo info
  342. _make_mock_response(200, {"object": {"sha": "main-sha"}}), # default branch ref
  343. _make_mock_response(200, {"object": {"sha": "c1"}}), # second push: branch ref
  344. _make_mock_response(200, {"tree": {"sha": "t1"}}), # commit
  345. _make_mock_response(200, {"tree": []}), # tree listing
  346. ]
  347. )
  348. client.post = AsyncMock(
  349. side_effect=[
  350. _make_mock_response(201, {}), # POST /git/refs (create branch)
  351. _make_mock_response(201, {"sha": "blob1"}),
  352. _make_mock_response(201, {"sha": "new-tree"}),
  353. _make_mock_response(201, {"sha": "new-commit"}),
  354. ]
  355. )
  356. client.patch = AsyncMock(return_value=_make_mock_response(200, {}))
  357. with caplog.at_level(logging.INFO, logger="backend.app.services.git_providers.github"):
  358. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  359. assert result["status"] == "success"
  360. assert any("Re-entering push_files" in r.message for r in caplog.records)
  361. class TestGiteaBackendApiBase:
  362. def setup_method(self):
  363. self.backend = GiteaBackend()
  364. def test_derives_api_base_from_repo_url(self):
  365. result = self.backend.get_api_base("https://git.example.com/owner/repo")
  366. assert result == "https://git.example.com/api/v1"
  367. def test_derives_api_base_with_port(self):
  368. result = self.backend.get_api_base("https://git.example.com:3000/owner/repo")
  369. assert result == "https://git.example.com:3000/api/v1"
  370. def test_invalid_url_raises_value_error(self):
  371. with pytest.raises(ValueError, match="Cannot derive API base"):
  372. self.backend.get_api_base("not-a-url")
  373. def test_parse_url_uses_instance_host(self):
  374. owner, repo = self.backend.parse_repo_url("https://git.example.com/owner/repo")
  375. assert owner == "owner"
  376. assert repo == "repo"
  377. class TestGiteaBackendPushFiles:
  378. def setup_method(self):
  379. self.backend = GiteaBackend()
  380. self.repo_url = "https://git.example.com/owner/repo"
  381. self.token = "gitea-token"
  382. self.branch = "bambuddy-backup"
  383. @pytest.mark.asyncio
  384. async def test_n_files_produce_single_commit(self):
  385. """All changed files are bundled into one Contents API call."""
  386. files = {"a.json": {"k": "v1"}, "b.json": {"k": "v2"}}
  387. client = AsyncMock()
  388. client.get = AsyncMock(
  389. side_effect=[
  390. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  391. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  392. _make_mock_response(200, {"tree": []}),
  393. ]
  394. )
  395. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-commit"}}))
  396. result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  397. assert result["status"] == "success"
  398. assert result["files_changed"] == 2
  399. contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
  400. assert len(contents_calls) == 1
  401. @pytest.mark.asyncio
  402. async def test_uses_gitea_api_v1_base_not_github(self):
  403. """Contents API calls target the instance's /api/v1, not api.github.com."""
  404. client = AsyncMock()
  405. client.get = AsyncMock(
  406. side_effect=[
  407. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  408. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  409. _make_mock_response(200, {"tree": []}),
  410. ]
  411. )
  412. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-commit"}}))
  413. await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  414. first_get_url = client.get.call_args_list[0].args[0]
  415. assert "git.example.com/api/v1" in first_get_url
  416. assert "api.github.com" not in first_get_url
  417. @pytest.mark.asyncio
  418. async def test_skips_unchanged_files(self):
  419. """Files whose blob SHA matches the existing tree entry are excluded from the commit."""
  420. content = {"name": "my-printer"}
  421. sha = _blob_sha(content)
  422. client = AsyncMock()
  423. client.get = AsyncMock(
  424. side_effect=[
  425. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  426. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  427. _make_mock_response(200, {"tree": [{"type": "blob", "path": "config/printers.json", "sha": sha}]}),
  428. ]
  429. )
  430. result = await self.backend.push_files(
  431. self.repo_url, self.token, self.branch, {"config/printers.json": content}, client
  432. )
  433. assert result["status"] == "skipped"
  434. client.post.assert_not_called()
  435. @pytest.mark.asyncio
  436. async def test_changed_file_sent_as_update_with_sha(self):
  437. """A file whose content changed is sent with operation='update' and the current blob SHA."""
  438. old_content = {"version": "1.0", "archives": []}
  439. new_content = {"version": "1.0", "archives": [{"id": 1}]}
  440. old_sha = _blob_sha(old_content)
  441. client = AsyncMock()
  442. client.get = AsyncMock(
  443. side_effect=[
  444. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  445. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  446. _make_mock_response(
  447. 200, {"tree": [{"type": "blob", "path": "archives/print_history.json", "sha": old_sha}]}
  448. ),
  449. ]
  450. )
  451. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-sha"}}))
  452. result = await self.backend.push_files(
  453. self.repo_url,
  454. self.token,
  455. self.branch,
  456. {"archives/print_history.json": new_content},
  457. client,
  458. )
  459. assert result["status"] == "success"
  460. assert result["files_changed"] == 1
  461. body = client.post.call_args.kwargs["json"]
  462. assert body["files"][0]["operation"] == "update"
  463. assert body["files"][0]["sha"] == old_sha
  464. @pytest.mark.asyncio
  465. async def test_new_file_sent_as_create_without_sha(self):
  466. """A file not yet in the repo is sent with operation='create' and no sha field."""
  467. client = AsyncMock()
  468. client.get = AsyncMock(
  469. side_effect=[
  470. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  471. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  472. _make_mock_response(200, {"tree": []}),
  473. ]
  474. )
  475. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-sha"}}))
  476. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"new.json": {"k": "v"}}, client)
  477. assert result["status"] == "success"
  478. body = client.post.call_args.kwargs["json"]
  479. assert body["files"][0]["operation"] == "create"
  480. assert "sha" not in body["files"][0]
  481. @pytest.mark.asyncio
  482. async def test_unchanged_file_excluded_from_contents_call(self):
  483. """A file whose blob SHA matches the existing tree entry is not included in the Contents API call."""
  484. content = {"name": "printer-1"}
  485. sha = _blob_sha(content)
  486. client = AsyncMock()
  487. client.get = AsyncMock(
  488. side_effect=[
  489. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  490. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  491. _make_mock_response(200, {"tree": [{"type": "blob", "path": "config.json", "sha": sha}]}),
  492. ]
  493. )
  494. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"config.json": content}, client)
  495. assert result["status"] == "skipped"
  496. client.post.assert_not_called()
  497. @pytest.mark.asyncio
  498. async def test_mixed_batch_create_update_unchanged_in_single_call(self):
  499. """A batch with one new file, one changed file, and one unchanged file produces
  500. exactly one create + one update in the Contents API payload, files_changed=2."""
  501. unchanged_content = {"version": 1}
  502. unchanged_sha = _blob_sha(unchanged_content)
  503. old_content = {"version": 1}
  504. old_sha = _blob_sha(old_content)
  505. client = AsyncMock()
  506. client.get = AsyncMock(
  507. side_effect=[
  508. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  509. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  510. _make_mock_response(
  511. 200,
  512. {
  513. "tree": [
  514. {"type": "blob", "path": "unchanged.json", "sha": unchanged_sha},
  515. {"type": "blob", "path": "updated.json", "sha": old_sha},
  516. ]
  517. },
  518. ),
  519. ]
  520. )
  521. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-sha"}}))
  522. result = await self.backend.push_files(
  523. self.repo_url,
  524. self.token,
  525. self.branch,
  526. {
  527. "unchanged.json": unchanged_content, # same SHA — should be skipped
  528. "updated.json": {"version": 2}, # changed — should be update
  529. "new.json": {"created": True}, # new — should be create
  530. },
  531. client,
  532. )
  533. assert result["status"] == "success"
  534. assert result["files_changed"] == 2
  535. body = client.post.call_args.kwargs["json"]
  536. ops = {f["path"]: f for f in body["files"]}
  537. assert set(ops.keys()) == {"updated.json", "new.json"}
  538. assert ops["updated.json"]["operation"] == "update"
  539. assert ops["updated.json"]["sha"] == old_sha
  540. assert ops["new.json"]["operation"] == "create"
  541. assert "sha" not in ops["new.json"]
  542. @pytest.mark.asyncio
  543. async def test_tree_fetch_failure_returns_failed(self):
  544. """A non-200 tree GET surfaces a clear failure, not a downstream 422 from the Contents API."""
  545. client = AsyncMock()
  546. client.get = AsyncMock(
  547. side_effect=[
  548. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  549. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  550. _make_mock_response(500, {}, text="Internal Server Error"),
  551. ]
  552. )
  553. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  554. assert result["status"] == "failed"
  555. assert "existing tree" in result["message"]
  556. assert "500" in result["message"]
  557. client.post.assert_not_called()
  558. @pytest.mark.asyncio
  559. async def test_contents_api_failure_returns_failed(self):
  560. """A non-2xx response from the Contents API returns status='failed', not 'skipped'."""
  561. client = AsyncMock()
  562. client.get = AsyncMock(
  563. side_effect=[
  564. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  565. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  566. _make_mock_response(200, {"tree": []}),
  567. ]
  568. )
  569. client.post = AsyncMock(return_value=_make_mock_response(403, {}, text="Forbidden"))
  570. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  571. assert result["status"] == "failed"
  572. assert "failed" in result["message"].lower()
  573. @pytest.mark.asyncio
  574. async def test_contents_api_404_surfaces_version_hint(self):
  575. """A 404 from POST /contents surfaces a Gitea version hint, not a generic 'Not Found'."""
  576. client = AsyncMock()
  577. client.get = AsyncMock(
  578. side_effect=[
  579. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  580. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  581. _make_mock_response(200, {"tree": []}),
  582. ]
  583. )
  584. client.post = AsyncMock(return_value=_make_mock_response(404, {}))
  585. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  586. assert result["status"] == "failed"
  587. assert "v1.18" in result["message"]
  588. assert "404" in result["message"]
  589. @pytest.mark.asyncio
  590. async def test_contents_api_409_surfaces_conflict_hint(self):
  591. """409 on POST /contents surfaces a conflict hint (covers web-UI edit, concurrent backup, path collision)."""
  592. client = AsyncMock()
  593. client.get = AsyncMock(
  594. side_effect=[
  595. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  596. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  597. _make_mock_response(200, {"tree": []}),
  598. ]
  599. )
  600. client.post = AsyncMock(return_value=_make_mock_response(409, {}))
  601. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  602. assert result["status"] == "failed"
  603. assert "conflict" in result["message"].lower()
  604. assert "advanced concurrently" in result["message"]
  605. assert "next scheduled backup" in result["message"]
  606. @pytest.mark.asyncio
  607. async def test_replication_lag_guard_fires_on_second_404(self):
  608. """Branch 404 after successful creation (replication lag) returns a clear message, not infinite recursion."""
  609. client = AsyncMock()
  610. client.get = AsyncMock(
  611. side_effect=[
  612. _make_mock_response(404, {}), # first push_files: branch missing
  613. _make_mock_response(200, {"default_branch": "main"}), # repo info
  614. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref
  615. _make_mock_response(404, {}), # second push_files: branch still missing (lag)
  616. ]
  617. )
  618. client.post = AsyncMock(return_value=_make_mock_response(201, {})) # POST /branches succeeds
  619. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  620. assert result["status"] == "failed"
  621. assert "replication lag" in result["message"]
  622. assert "next scheduled backup" in result["message"]
  623. @pytest.mark.asyncio
  624. async def test_malformed_tree_entry_skipped_valid_entry_retained(self):
  625. """A tree entry missing 'sha' is skipped; the valid entry is still compared for deduplication."""
  626. client = AsyncMock()
  627. client.get = AsyncMock(
  628. side_effect=[
  629. _make_mock_response(200, [{"object": {"sha": "abc"}}]), # branch ref
  630. _make_mock_response(200, {"tree": {"sha": "tree-sha"}}), # commit
  631. _make_mock_response(
  632. 200,
  633. {
  634. "tree": [ # tree listing
  635. {"type": "blob", "path": "valid.json", "sha": "old-sha"},
  636. {"type": "blob", "path": "broken.json"}, # missing sha
  637. ]
  638. },
  639. ),
  640. ]
  641. )
  642. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-commit"}}))
  643. result = await self.backend.push_files(
  644. self.repo_url,
  645. self.token,
  646. self.branch,
  647. {"valid.json": {"changed": True}, "broken.json": {"x": 1}},
  648. client,
  649. )
  650. assert result["status"] == "success"
  651. assert result["files_changed"] == 2 # both files pushed (broken.json treated as new)
  652. @pytest.mark.asyncio
  653. async def test_truncated_tree_response_returns_failed(self):
  654. """B24: A truncated tree listing makes SHA-equality dedup miss; surface a failure
  655. asking the user to rotate the repo rather than silently re-uploading every file."""
  656. client = AsyncMock()
  657. client.get = AsyncMock(
  658. side_effect=[
  659. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  660. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  661. _make_mock_response(
  662. 200, {"tree": [{"type": "blob", "path": "a.json", "sha": "old"}], "truncated": True}
  663. ),
  664. ]
  665. )
  666. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  667. assert result["status"] == "failed"
  668. assert "truncated" in result["message"].lower()
  669. assert "rotate the backup repository" in result["message"].lower()
  670. client.post.assert_not_called()
  671. @pytest.mark.asyncio
  672. async def test_get_current_commit_failure_includes_status_and_body(self):
  673. """B22: A 5xx on GET /git/commits surfaces both the status code and the body,
  674. not the bare 'Failed to get current commit' string."""
  675. client = AsyncMock()
  676. client.get = AsyncMock(
  677. side_effect=[
  678. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  679. _make_mock_response(503, {}, text="Service Unavailable"),
  680. ]
  681. )
  682. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  683. assert result["status"] == "failed"
  684. assert "503" in result["message"]
  685. assert "current commit" in result["message"].lower()
  686. @pytest.mark.asyncio
  687. async def test_missing_tree_sha_surfaces_body(self):
  688. """B22: 'Failed to extract tree SHA' now includes the (truncated) response body so
  689. a future Gitea shape-shift is debuggable from the failure message alone."""
  690. client = AsyncMock()
  691. client.get = AsyncMock(
  692. side_effect=[
  693. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  694. # Neither flat .tree nor wrapped .commit.tree present
  695. _make_mock_response(
  696. 200,
  697. {"sha": "c1", "url": "https://gitea.example.com/api/v1/.../c1"},
  698. text='{"sha":"c1","url":"https://gitea.example.com/api/v1/.../c1"}',
  699. ),
  700. ]
  701. )
  702. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  703. assert result["status"] == "failed"
  704. assert "tree SHA" in result["message"]
  705. assert "gitea.example.com" in result["message"] # body context included
  706. @pytest.mark.asyncio
  707. async def test_repo_info_failure_includes_status_and_body(self):
  708. """B22: 'Failed to get repo info' inside _create_branch_and_push now includes
  709. the status code and response body."""
  710. client = AsyncMock()
  711. client.get = AsyncMock(
  712. side_effect=[
  713. _make_mock_response(404, {}), # backup branch missing -> branch-and-push path
  714. _make_mock_response(500, {}, text="Internal Server Error"), # repo info
  715. ]
  716. )
  717. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  718. assert result["status"] == "failed"
  719. assert "repo info" in result["message"].lower()
  720. assert "500" in result["message"]
  721. assert "Internal Server Error" in result["message"]
  722. @pytest.mark.asyncio
  723. async def test_recursive_push_files_log_marker_on_branch_create(self, caplog):
  724. """B26: After POST /branches succeeds, the re-entry into push_files emits an
  725. info-level marker so operators can debug second-pass failures (e.g. replication lag)."""
  726. import logging
  727. client = AsyncMock()
  728. client.get = AsyncMock(
  729. side_effect=[
  730. _make_mock_response(404, {}), # first push: branch missing
  731. _make_mock_response(200, {"default_branch": "main"}), # repo info
  732. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref
  733. # second push pass:
  734. _make_mock_response(200, [{"object": {"sha": "c1"}}]),
  735. _make_mock_response(200, {"tree": {"sha": "t1"}}),
  736. _make_mock_response(200, {"tree": []}),
  737. ]
  738. )
  739. client.post = AsyncMock(
  740. side_effect=[
  741. _make_mock_response(201, {}), # POST /branches
  742. _make_mock_response(201, {"commit": {"sha": "new-commit"}}), # POST /contents
  743. ]
  744. )
  745. with caplog.at_level(logging.INFO, logger="backend.app.services.git_providers.gitea"):
  746. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  747. assert result["status"] == "success"
  748. assert any("Re-entering push_files" in r.message for r in caplog.records)
  749. @pytest.mark.asyncio
  750. async def test_creates_missing_branch_via_branches_api(self):
  751. """A missing backup branch is created via POST /branches, not /git/refs."""
  752. client = AsyncMock()
  753. client.get = AsyncMock(
  754. side_effect=[
  755. # branch ref missing
  756. _make_mock_response(404, {}),
  757. # repo info for default branch
  758. _make_mock_response(200, {"default_branch": "main"}),
  759. # default branch ref
  760. _make_mock_response(200, {"object": {"sha": "base-sha"}}),
  761. # second push_files call: branch now exists
  762. _make_mock_response(200, {"object": {"sha": "base-sha"}}),
  763. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  764. _make_mock_response(200, {"tree": []}),
  765. ]
  766. )
  767. client.post = AsyncMock(
  768. side_effect=[
  769. _make_mock_response(201, {}), # POST /branches
  770. _make_mock_response(201, {"commit": {"sha": "new-commit"}}), # POST /contents
  771. ]
  772. )
  773. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  774. assert result["status"] == "success"
  775. branch_call = client.post.call_args_list[0]
  776. assert "/branches" in branch_call.args[0]
  777. assert "/git/refs" not in branch_call.args[0]
  778. assert branch_call.kwargs["json"]["new_branch_name"] == self.branch
  779. @pytest.mark.asyncio
  780. async def test_truncates_upstream_error_body_in_failure_message(self):
  781. client = AsyncMock()
  782. client.get = AsyncMock(
  783. side_effect=[
  784. _make_mock_response(200, {"object": {"sha": "base-commit"}}),
  785. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  786. _make_mock_response(200, {"tree": []}),
  787. ]
  788. )
  789. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
  790. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  791. assert result["status"] == "failed"
  792. assert result["message"] == f"Backup commit failed: {'x' * 197}..."
  793. class TestGiteaBackendListShapeRefResponse:
  794. """#1224, #1225 regression: Gitea/Forgejo return refs as a *list*, not a dict.
  795. GitHub: ``GET /git/refs/heads/{branch}`` -> ``{"ref": ..., "object": {...}}``.
  796. Gitea/Forgejo: same endpoint -> ``[{"ref": ..., "object": {...}}]``.
  797. The pre-fix code did ``response.json()["object"]["sha"]`` on the Gitea path
  798. and crashed with ``list indices must be integers or slices, not str``.
  799. """
  800. def setup_method(self):
  801. self.backend = GiteaBackend()
  802. self.repo_url = "https://git.example.com/owner/repo"
  803. self.token = "gitea-token"
  804. self.branch = "bambuddy-backup"
  805. def test_ref_sha_extracts_from_list(self):
  806. assert self.backend._ref_sha([{"object": {"sha": "abc"}}]) == "abc"
  807. def test_ref_sha_still_accepts_dict_shape(self):
  808. # Defensive — if Gitea ever returns a dict (older versions, future change),
  809. # we don't want to break.
  810. assert self.backend._ref_sha({"object": {"sha": "abc"}}) == "abc"
  811. def test_ref_sha_raises_on_empty_list(self):
  812. with pytest.raises(ValueError):
  813. self.backend._ref_sha([])
  814. @pytest.mark.asyncio
  815. async def test_push_files_handles_list_shape_branch_ref(self):
  816. """The configured backup branch already exists — ref endpoint returns a list."""
  817. client = AsyncMock()
  818. client.get = AsyncMock(
  819. side_effect=[
  820. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]), # list shape
  821. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  822. _make_mock_response(200, {"tree": []}),
  823. ]
  824. )
  825. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-commit"}}))
  826. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  827. assert result["status"] == "success"
  828. assert result["commit_sha"] == "new-commit"
  829. @pytest.mark.asyncio
  830. async def test_create_branch_handles_list_shape_default_branch_ref(self):
  831. """Backup branch missing — must read default branch's ref, also list-shaped."""
  832. client = AsyncMock()
  833. client.get = AsyncMock(
  834. side_effect=[
  835. _make_mock_response(404, {}), # missing backup branch
  836. _make_mock_response(200, {"default_branch": "main"}), # repo info
  837. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref (list)
  838. # second push_files() call — branch now exists
  839. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]),
  840. _make_mock_response(200, {"tree": {"sha": "main-tree"}}),
  841. _make_mock_response(200, {"tree": []}),
  842. ]
  843. )
  844. client.post = AsyncMock(
  845. side_effect=[
  846. _make_mock_response(201, {}), # POST /branches
  847. _make_mock_response(201, {"commit": {"sha": "new-commit"}}), # POST /contents
  848. ]
  849. )
  850. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  851. assert result["status"] == "success"
  852. @pytest.mark.asyncio
  853. async def test_create_branch_403_returns_permission_message(self):
  854. client = AsyncMock()
  855. client.get = AsyncMock(
  856. side_effect=[
  857. _make_mock_response(404, {}), # missing backup branch
  858. _make_mock_response(200, {"default_branch": "main"}), # repo info
  859. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref
  860. ]
  861. )
  862. client.post = AsyncMock(return_value=_make_mock_response(403, {"message": "Forbidden"}))
  863. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  864. assert result["status"] == "failed"
  865. assert "Permission denied" in result["message"]
  866. assert "write access" in result["message"]
  867. @pytest.mark.asyncio
  868. async def test_create_branch_409_returns_race_condition_message(self):
  869. client = AsyncMock()
  870. client.get = AsyncMock(
  871. side_effect=[
  872. _make_mock_response(404, {}), # missing backup branch
  873. _make_mock_response(200, {"default_branch": "main"}), # repo info
  874. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref
  875. ]
  876. )
  877. client.post = AsyncMock(return_value=_make_mock_response(409, {"message": "Conflict"}))
  878. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  879. assert result["status"] == "failed"
  880. assert "already exists" in result["message"]
  881. assert "race" in result["message"]
  882. @pytest.mark.asyncio
  883. async def test_create_branch_unexpected_status_includes_code_in_message(self):
  884. client = AsyncMock()
  885. client.get = AsyncMock(
  886. side_effect=[
  887. _make_mock_response(404, {}), # missing backup branch
  888. _make_mock_response(200, {"default_branch": "main"}), # repo info
  889. _make_mock_response(200, [{"object": {"sha": "main-sha"}}]), # default branch ref
  890. ]
  891. )
  892. client.post = AsyncMock(return_value=_make_mock_response(422, {"message": "Unprocessable"}))
  893. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  894. assert result["status"] == "failed"
  895. assert "422" in result["message"]
  896. class TestGiteaBackendWrappedCommitResponse:
  897. """#1224 regression: Gitea wraps the GitCommit fields under ``commit``.
  898. GitHub's ``GET /git/commits/{sha}`` returns the unwrapped GitCommit schema
  899. (``tree`` at top level). Gitea's same-named endpoint returns the wrapped
  900. Commit schema where ``tree`` lives at ``commit.tree`` (Gitea 1.24+).
  901. Pre-fix code did ``commit_response.json()["tree"]["sha"]`` and raised
  902. ``KeyError: 'tree'`` on every backup *after* the initial one — surfaced to
  903. the user as the opaque ``Backup failed: 'tree'`` message.
  904. """
  905. def setup_method(self):
  906. self.backend = GiteaBackend()
  907. self.repo_url = "https://git.example.com/owner/repo"
  908. self.token = "gitea-token"
  909. self.branch = "bambuddy-backup"
  910. def test_commit_tree_sha_reads_flat_shape(self):
  911. """GitHub-compatible / older Gitea: ``tree`` at top level."""
  912. assert self.backend._commit_tree_sha({"tree": {"sha": "abc"}}) == "abc"
  913. def test_commit_tree_sha_reads_wrapped_shape(self):
  914. """Gitea 1.24+ / Forgejo: ``tree`` nested under ``commit``."""
  915. assert self.backend._commit_tree_sha({"sha": "c1", "commit": {"tree": {"sha": "abc"}}}) == "abc"
  916. def test_commit_tree_sha_returns_none_on_missing(self):
  917. assert self.backend._commit_tree_sha({"sha": "c1", "commit": {}}) is None
  918. assert self.backend._commit_tree_sha({}) is None
  919. @pytest.mark.asyncio
  920. async def test_push_files_handles_wrapped_commit_response(self):
  921. """Subsequent backup against Gitea 1.24+ — commit endpoint returns wrapped shape."""
  922. client = AsyncMock()
  923. client.get = AsyncMock(
  924. side_effect=[
  925. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  926. # Wrapped Gitea commit response — tree under "commit", not top level
  927. _make_mock_response(200, {"sha": "base-commit", "commit": {"tree": {"sha": "base-tree"}}}),
  928. _make_mock_response(200, {"tree": []}),
  929. ]
  930. )
  931. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-commit"}}))
  932. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  933. assert result["status"] == "success"
  934. assert result["commit_sha"] == "new-commit"
  935. @pytest.mark.asyncio
  936. async def test_missing_commit_sha_in_push_response_surfaces_warning(self):
  937. """200/201 with no commit.sha -> success with a human-readable note, not silent None."""
  938. client = AsyncMock()
  939. client.get = AsyncMock(
  940. side_effect=[
  941. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  942. _make_mock_response(200, {"sha": "base-commit", "commit": {"tree": {"sha": "base-tree"}}}),
  943. _make_mock_response(200, {"tree": []}),
  944. ]
  945. )
  946. client.post = AsyncMock(return_value=_make_mock_response(201, {})) # no commit key
  947. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  948. assert result["status"] == "success"
  949. assert result["commit_sha"] is None
  950. assert "not reported" in result["message"]
  951. @pytest.mark.asyncio
  952. async def test_push_files_fails_cleanly_when_tree_sha_missing(self):
  953. """Defensive: malformed/unexpected commit response surfaces a clear error, not KeyError."""
  954. client = AsyncMock()
  955. client.get = AsyncMock(
  956. side_effect=[
  957. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  958. _make_mock_response(200, {"sha": "base-commit"}), # no tree at all
  959. ]
  960. )
  961. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  962. assert result["status"] == "failed"
  963. assert "tree SHA" in result["message"]
  964. class TestGiteaBackendEmptyRepoInitialCommit:
  965. """#1224 regression: Git Data API refuses writes against empty Gitea repos.
  966. GitHub accepts ``POST /git/blobs`` against an empty repo and creates the
  967. initial commit + branch. Gitea returns 404 on every blob/tree/commit POST
  968. until the repo has at least one commit. The fix is to use the Contents
  969. API (``POST /repos/.../contents``) which seeds the branch + initial
  970. commit in a single transaction.
  971. """
  972. def setup_method(self):
  973. self.backend = GiteaBackend()
  974. self.repo_url = "https://git.example.com/owner/repo"
  975. self.token = "gitea-token"
  976. self.branch = "main"
  977. @pytest.mark.asyncio
  978. async def test_empty_repo_uses_contents_api_not_git_data_api(self):
  979. files = {"config/printers.json": {"name": "p1"}, "config/spools.json": {"id": 1}}
  980. client = AsyncMock()
  981. client.get = AsyncMock(
  982. side_effect=[
  983. _make_mock_response(404, {}), # backup branch missing
  984. _make_mock_response(200, {"default_branch": "main"}), # repo info
  985. _make_mock_response(404, {}), # default branch missing too -> empty repo
  986. ]
  987. )
  988. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "initial-sha"}}))
  989. result = await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  990. assert result["status"] == "success"
  991. assert result["files_changed"] == 2
  992. assert result["commit_sha"] == "initial-sha"
  993. contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
  994. blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
  995. tree_calls = [c for c in client.post.call_args_list if "/git/trees" in c.args[0]]
  996. commit_calls = [c for c in client.post.call_args_list if "/git/commits" in c.args[0]]
  997. ref_calls = [c for c in client.post.call_args_list if "/git/refs" in c.args[0]]
  998. # Exactly one Contents API call, no Git Data API writes
  999. assert len(contents_calls) == 1
  1000. assert len(blob_calls) == 0
  1001. assert len(tree_calls) == 0
  1002. assert len(commit_calls) == 0
  1003. assert len(ref_calls) == 0
  1004. @pytest.mark.asyncio
  1005. async def test_contents_api_payload_shape(self):
  1006. """The Contents API call must carry branch+new_branch+files in the documented shape."""
  1007. files = {"a.json": {"k": "v"}, "nested/b.json": {"x": 1}}
  1008. client = AsyncMock()
  1009. client.get = AsyncMock(
  1010. side_effect=[
  1011. _make_mock_response(404, {}),
  1012. _make_mock_response(200, {"default_branch": "main"}),
  1013. _make_mock_response(404, {}),
  1014. ]
  1015. )
  1016. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "abc"}}))
  1017. await self.backend.push_files(self.repo_url, self.token, self.branch, files, client)
  1018. body = client.post.call_args.kwargs["json"]
  1019. assert body["branch"] == "main"
  1020. assert body["new_branch"] == "main"
  1021. assert body["message"].startswith("Initial Bambuddy backup")
  1022. assert len(body["files"]) == 2
  1023. paths = {f["path"] for f in body["files"]}
  1024. assert paths == {"a.json", "nested/b.json"}
  1025. for f in body["files"]:
  1026. assert f["operation"] == "create"
  1027. # Content is base64-encoded JSON of the original dict
  1028. decoded = base64.b64decode(f["content"]).decode("utf-8")
  1029. assert json.loads(decoded) == files[f["path"]]
  1030. @pytest.mark.asyncio
  1031. async def test_contents_api_failure_truncates_error_body(self):
  1032. client = AsyncMock()
  1033. client.get = AsyncMock(
  1034. side_effect=[
  1035. _make_mock_response(404, {}),
  1036. _make_mock_response(200, {"default_branch": "main"}),
  1037. _make_mock_response(404, {}),
  1038. ]
  1039. )
  1040. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
  1041. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {"k": "v"}}, client)
  1042. assert result["status"] == "failed"
  1043. assert result["message"] == f"Failed to create initial commit: {'x' * 197}..."
  1044. @pytest.mark.asyncio
  1045. async def test_empty_files_skips_contents_api_call(self):
  1046. # Edge: nothing to commit -> don't make a useless Contents API call.
  1047. client = AsyncMock()
  1048. client.post = AsyncMock()
  1049. result = await self.backend._create_initial_commit(
  1050. client, {}, "https://git.example.com/api/v1", "owner", "repo", "main", {}
  1051. )
  1052. assert result["status"] == "skipped"
  1053. assert result["files_changed"] == 0
  1054. client.post.assert_not_called()
  1055. @pytest.mark.asyncio
  1056. async def test_missing_commit_sha_in_initial_commit_response_surfaces_warning(self):
  1057. """200/201 with no commit.sha -> success with a human-readable note, not silent None."""
  1058. client = AsyncMock()
  1059. client.get = AsyncMock(
  1060. side_effect=[
  1061. _make_mock_response(404, {}),
  1062. _make_mock_response(200, {"default_branch": "main"}),
  1063. _make_mock_response(404, {}),
  1064. ]
  1065. )
  1066. client.post = AsyncMock(return_value=_make_mock_response(201, {})) # no commit key
  1067. result = await self.backend.push_files(self.repo_url, self.token, self.branch, {"a.json": {}}, client)
  1068. assert result["status"] == "success"
  1069. assert result["commit_sha"] is None
  1070. assert "not reported" in result["message"]
  1071. class TestForgejoInheritsGiteaFixes:
  1072. """ForgejoBackend extends GiteaBackend with no overrides — must inherit both fixes."""
  1073. @pytest.mark.asyncio
  1074. async def test_forgejo_handles_list_shape_ref_response(self):
  1075. backend = ForgejoBackend()
  1076. client = AsyncMock()
  1077. client.get = AsyncMock(
  1078. side_effect=[
  1079. _make_mock_response(200, [{"object": {"sha": "base-commit"}}]),
  1080. _make_mock_response(200, {"tree": {"sha": "base-tree"}}),
  1081. _make_mock_response(200, {"tree": []}),
  1082. ]
  1083. )
  1084. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "new-commit"}}))
  1085. result = await backend.push_files(
  1086. "https://forgejo.example.com/owner/repo",
  1087. "token",
  1088. "bambuddy-backup",
  1089. {"a.json": {"k": "v"}},
  1090. client,
  1091. )
  1092. assert result["status"] == "success"
  1093. @pytest.mark.asyncio
  1094. async def test_forgejo_empty_repo_uses_contents_api(self):
  1095. backend = ForgejoBackend()
  1096. client = AsyncMock()
  1097. client.get = AsyncMock(
  1098. side_effect=[
  1099. _make_mock_response(404, {}),
  1100. _make_mock_response(200, {"default_branch": "main"}),
  1101. _make_mock_response(404, {}),
  1102. ]
  1103. )
  1104. client.post = AsyncMock(return_value=_make_mock_response(201, {"commit": {"sha": "fj-sha"}}))
  1105. result = await backend.push_files(
  1106. "https://forgejo.example.com/owner/repo",
  1107. "token",
  1108. "main",
  1109. {"a.json": {"k": "v"}},
  1110. client,
  1111. )
  1112. assert result["status"] == "success"
  1113. contents_calls = [c for c in client.post.call_args_list if "/contents" in c.args[0]]
  1114. blob_calls = [c for c in client.post.call_args_list if "/git/blobs" in c.args[0]]
  1115. assert len(contents_calls) == 1
  1116. assert len(blob_calls) == 0
  1117. class TestForgejoBackendApiBase:
  1118. def setup_method(self):
  1119. self.backend = ForgejoBackend()
  1120. def test_derives_api_base_from_repo_url(self):
  1121. result = self.backend.get_api_base("https://forgejo.example.com/owner/repo")
  1122. assert result == "https://forgejo.example.com/api/v1"
  1123. def test_derives_api_base_with_port(self):
  1124. result = self.backend.get_api_base("https://forgejo.example.com:3000/owner/repo")
  1125. assert result == "https://forgejo.example.com:3000/api/v1"
  1126. def test_invalid_url_raises_value_error(self):
  1127. with pytest.raises(ValueError, match="Cannot derive API base"):
  1128. self.backend.get_api_base("not-a-url")
  1129. def test_parse_url_uses_instance_host(self):
  1130. owner, repo = self.backend.parse_repo_url("https://forgejo.example.com/owner/repo")
  1131. assert owner == "owner"
  1132. assert repo == "repo"
  1133. class TestForgejoTestConnection:
  1134. """ForgejoBackend overrides test_connection to handle Forgejo v15+ 404-not-403 behaviour."""
  1135. def setup_method(self):
  1136. self.backend = ForgejoBackend()
  1137. self.repo_url = "https://forgejo.example.com/owner/repo"
  1138. self.token = "fj-token"
  1139. @pytest.mark.asyncio
  1140. async def test_valid_token_and_push_permission_returns_success(self):
  1141. client = AsyncMock()
  1142. client.get = AsyncMock(
  1143. side_effect=[
  1144. _make_mock_response(200, {"login": "user"}),
  1145. _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": True, "pull": True}}),
  1146. ]
  1147. )
  1148. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1149. assert result["success"] is True
  1150. assert result["repo_name"] == "owner/repo"
  1151. @pytest.mark.asyncio
  1152. async def test_invalid_token_returns_clear_message_without_repo_call(self):
  1153. client = AsyncMock()
  1154. client.get = AsyncMock(return_value=_make_mock_response(401, {}))
  1155. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1156. assert result["success"] is False
  1157. assert result["message"] == "Invalid access token"
  1158. assert client.get.call_count == 1 # only /user was called
  1159. @pytest.mark.asyncio
  1160. async def test_zero_scope_token_403_on_user_returns_scope_hint(self):
  1161. """A 403 from /user (v15+ zero-scope token) returns a clear message without hitting the repo."""
  1162. client = AsyncMock()
  1163. client.get = AsyncMock(return_value=_make_mock_response(403, {}))
  1164. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1165. assert result["success"] is False
  1166. assert "read:user scope" in result["message"]
  1167. assert client.get.call_count == 1
  1168. @pytest.mark.asyncio
  1169. async def test_unexpected_user_status_returns_status_code(self):
  1170. """A non-200/401/403 response from /user (e.g. 429, 5xx) surfaces the status code."""
  1171. client = AsyncMock()
  1172. client.get = AsyncMock(return_value=_make_mock_response(429, {}))
  1173. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1174. assert result["success"] is False
  1175. assert "429" in result["message"]
  1176. assert client.get.call_count == 1
  1177. @pytest.mark.asyncio
  1178. async def test_repo_404_after_valid_token_surfaces_v15_scope_hint(self):
  1179. client = AsyncMock()
  1180. client.get = AsyncMock(
  1181. side_effect=[
  1182. _make_mock_response(200, {"login": "user"}),
  1183. _make_mock_response(404, {}),
  1184. ]
  1185. )
  1186. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1187. assert result["success"] is False
  1188. assert "v15" in result["message"]
  1189. assert "scope" in result["message"]
  1190. @pytest.mark.asyncio
  1191. async def test_token_lacks_push_permission_returns_failed(self):
  1192. client = AsyncMock()
  1193. client.get = AsyncMock(
  1194. side_effect=[
  1195. _make_mock_response(200, {"login": "user"}),
  1196. _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": False, "pull": True}}),
  1197. ]
  1198. )
  1199. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1200. assert result["success"] is False
  1201. assert "push permission" in result["message"]
  1202. assert result["repo_name"] == "owner/repo"
  1203. @pytest.mark.asyncio
  1204. async def test_non_404_api_error_returns_status_code(self):
  1205. client = AsyncMock()
  1206. client.get = AsyncMock(
  1207. side_effect=[
  1208. _make_mock_response(200, {"login": "user"}),
  1209. _make_mock_response(500, {}),
  1210. ]
  1211. )
  1212. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1213. assert result["success"] is False
  1214. assert "API error: 500" in result["message"]
  1215. @pytest.mark.asyncio
  1216. async def test_connection_exception_includes_detail_not_just_classname(self):
  1217. """B23: A connection exception surfaces both the exception class and its message,
  1218. so 'Test Connection' in the UI shows actionable detail (e.g. cert verify failure)."""
  1219. import httpx
  1220. client = AsyncMock()
  1221. client.get = AsyncMock(
  1222. side_effect=httpx.ConnectError("certificate verify failed: hostname mismatch"),
  1223. )
  1224. result = await self.backend.test_connection(self.repo_url, self.token, client)
  1225. assert result["success"] is False
  1226. assert "ConnectError" in result["message"]
  1227. assert "certificate verify failed" in result["message"]
  1228. class TestGitLabBackend:
  1229. def setup_method(self):
  1230. self.backend = GitLabBackend()
  1231. def test_parse_url_https(self):
  1232. owner, repo = self.backend.parse_repo_url("https://gitlab.com/owner/repo")
  1233. assert owner == "owner"
  1234. assert repo == "repo"
  1235. def test_parse_url_ssh(self):
  1236. owner, repo = self.backend.parse_repo_url("git@gitlab.com:owner/repo.git")
  1237. assert owner == "owner"
  1238. assert repo == "repo"
  1239. def test_parse_url_invalid_raises(self):
  1240. with pytest.raises(ValueError):
  1241. self.backend.parse_repo_url("not-a-url")
  1242. def test_get_api_base_derives_from_repo_url(self):
  1243. result = self.backend.get_api_base("https://gitlab.com/owner/repo")
  1244. assert result == "https://gitlab.com/api/v4"
  1245. def test_get_api_base_derives_from_self_hosted_url(self):
  1246. result = self.backend.get_api_base("https://my-gitlab.example.com/owner/repo")
  1247. assert result == "https://my-gitlab.example.com/api/v4"
  1248. def test_get_api_base_invalid_url_raises(self):
  1249. with pytest.raises(ValueError, match="Cannot derive API base"):
  1250. self.backend.get_api_base("not-a-url")
  1251. def test_get_headers_uses_bearer_token(self):
  1252. headers = self.backend.get_headers("mytoken")
  1253. assert headers["Authorization"] == "Bearer mytoken"
  1254. assert "Content-Type" in headers
  1255. def test_parse_url_subgroup_https(self):
  1256. namespace, repo = self.backend.parse_repo_url("https://gitlab.com/group/subgroup/project")
  1257. assert namespace == "group/subgroup"
  1258. assert repo == "project"
  1259. def test_parse_url_deep_namespace_https(self):
  1260. namespace, repo = self.backend.parse_repo_url("https://gitlab.com/myorg/team/api/backend")
  1261. assert namespace == "myorg/team/api"
  1262. assert repo == "backend"
  1263. def test_parse_url_subgroup_ssh(self):
  1264. namespace, repo = self.backend.parse_repo_url("git@gitlab.com:group/subgroup/project.git")
  1265. assert namespace == "group/subgroup"
  1266. assert repo == "project"
  1267. @pytest.mark.asyncio
  1268. async def test_push_files_encodes_subgroup_namespace_in_api_url(self):
  1269. backend = GitLabBackend()
  1270. repo_url = "https://gitlab.com/group/subgroup/project"
  1271. client = AsyncMock()
  1272. client.get = AsyncMock(
  1273. side_effect=[
  1274. _make_mock_response(200, {"name": "bambuddy-backup"}),
  1275. _make_mock_response(200, []),
  1276. ]
  1277. )
  1278. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
  1279. await backend.push_files(repo_url, "token", "bambuddy-backup", {"f.json": {}}, client)
  1280. called_url = client.get.call_args_list[0].args[0]
  1281. assert "group%2Fsubgroup%2Fproject" in called_url
  1282. def _blob_sha(content: dict) -> str:
  1283. content_bytes = json.dumps(content, indent=2, default=str).encode("utf-8")
  1284. return hashlib.sha1(f"blob {len(content_bytes)}\0".encode() + content_bytes, usedforsecurity=False).hexdigest()
  1285. def _make_mock_response(status_code: int, body=None, text: str = ""):
  1286. resp = MagicMock()
  1287. resp.status_code = status_code
  1288. resp.text = text
  1289. resp.json = MagicMock(return_value=body or {})
  1290. return resp
  1291. class TestGitLabBackendPushFiles:
  1292. def setup_method(self):
  1293. self.backend = GitLabBackend()
  1294. self.repo_url = "https://gitlab.com/owner/repo"
  1295. self.token = "glpat-test"
  1296. self.branch = "bambuddy-backup"
  1297. self.files = {"config/printers.json": {"name": "my-printer"}}
  1298. @pytest.mark.asyncio
  1299. async def test_skips_commit_when_content_unchanged(self):
  1300. sha = _blob_sha(self.files["config/printers.json"])
  1301. client = AsyncMock()
  1302. client.get = AsyncMock(
  1303. side_effect=[
  1304. # branch check → branch exists
  1305. _make_mock_response(200, {"name": self.branch}),
  1306. # tree page 1 → one blob whose sha matches current content
  1307. _make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": sha}]),
  1308. # tree page 2 → empty, stop pagination
  1309. _make_mock_response(200, []),
  1310. ]
  1311. )
  1312. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  1313. assert result["status"] == "skipped"
  1314. assert result["files_changed"] == 0
  1315. client.post.assert_not_called()
  1316. @pytest.mark.asyncio
  1317. async def test_commits_when_content_changed(self):
  1318. stale_sha = "0000000000000000000000000000000000000000"
  1319. client = AsyncMock()
  1320. client.get = AsyncMock(
  1321. side_effect=[
  1322. _make_mock_response(200, {"name": self.branch}),
  1323. _make_mock_response(200, [{"type": "blob", "path": "config/printers.json", "id": stale_sha}]),
  1324. _make_mock_response(200, []), # page 2 empty, stop pagination
  1325. ]
  1326. )
  1327. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "abc123"}))
  1328. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  1329. assert result["status"] == "success"
  1330. assert result["files_changed"] == 1
  1331. client.post.assert_called_once()
  1332. @pytest.mark.asyncio
  1333. async def test_truncates_upstream_error_body_in_failure_message(self):
  1334. client = AsyncMock()
  1335. client.get = AsyncMock(
  1336. side_effect=[
  1337. _make_mock_response(200, {"name": self.branch}),
  1338. _make_mock_response(200, []),
  1339. ]
  1340. )
  1341. client.post = AsyncMock(return_value=_make_mock_response(500, {}, text="x" * 500))
  1342. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  1343. assert result["status"] == "failed"
  1344. assert result["message"] == f"Failed to create commit: {'x' * 197}..."
  1345. @pytest.mark.asyncio
  1346. async def test_creates_new_file_not_in_existing_tree(self):
  1347. client = AsyncMock()
  1348. client.get = AsyncMock(
  1349. side_effect=[
  1350. _make_mock_response(200, {"name": self.branch}),
  1351. # tree is empty
  1352. _make_mock_response(200, []),
  1353. ]
  1354. )
  1355. client.post = AsyncMock(return_value=_make_mock_response(201, {"id": "def456"}))
  1356. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  1357. assert result["status"] == "success"
  1358. call_kwargs = client.post.call_args.kwargs["json"]
  1359. assert call_kwargs["actions"][0]["action"] == "create"
  1360. @pytest.mark.asyncio
  1361. async def test_paginates_tree_to_find_unchanged_file_on_page_2(self):
  1362. """Files beyond the first 100 are fetched; a file on page 2 is correctly skipped if unchanged."""
  1363. sha = _blob_sha(self.files["config/printers.json"])
  1364. page1_items = [{"type": "blob", "path": f"other{i}.json", "id": "aaa"} for i in range(100)]
  1365. page2_items = [{"type": "blob", "path": f"more{i}.json", "id": "bbb"} for i in range(19)] + [
  1366. {"type": "blob", "path": "config/printers.json", "id": sha}
  1367. ] # 120 total blobs across two pages
  1368. client = AsyncMock()
  1369. client.get = AsyncMock(
  1370. side_effect=[
  1371. _make_mock_response(200, {"name": self.branch}), # branch check
  1372. _make_mock_response(200, page1_items), # tree page 1
  1373. _make_mock_response(200, page2_items), # tree page 2
  1374. _make_mock_response(200, []), # tree page 3 empty, stop
  1375. ]
  1376. )
  1377. result = await self.backend.push_files(self.repo_url, self.token, self.branch, self.files, client)
  1378. assert result["status"] == "skipped"
  1379. client.post.assert_not_called()