test_github_restore.py 37 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946
  1. """Unit tests for the Git backup restore service (#2656).
  2. Focus is on the per-category appliers: natural-key matching, the deliberate
  3. refusal to reuse the backup's primary keys, old_id -> new_id remapping for
  4. dependent rows, overwrite-vs-skip, the settings credential blocklist, and the
  5. K-profile paths that depend on live printers.
  6. """
  7. from datetime import datetime
  8. from unittest.mock import AsyncMock, MagicMock, patch
  9. import pytest
  10. from sqlalchemy import select
  11. from backend.app.models.archive import PrintArchive
  12. from backend.app.models.settings import Settings
  13. from backend.app.models.spool import Spool
  14. from backend.app.models.spool_usage_history import SpoolUsageHistory
  15. from backend.app.schemas.github_backup import GitHubRestoreRequest, RestoreCategory
  16. from backend.app.services.github_restore import (
  17. ARCHIVES_PATH,
  18. SETTINGS_PATH,
  19. SPOOL_USAGE_PATH,
  20. SPOOLS_PATH,
  21. GitHubRestoreService,
  22. _CategoryTally,
  23. _is_blocked_setting_key,
  24. _parse_dt,
  25. )
  26. def _service() -> GitHubRestoreService:
  27. return GitHubRestoreService()
  28. class TestParseDt:
  29. def test_parses_str_datetime_the_backup_writes(self):
  30. assert _parse_dt("2026-07-27 06:02:05.123456") == datetime(2026, 7, 27, 6, 2, 5, 123456)
  31. def test_parses_iso_with_t_separator(self):
  32. assert _parse_dt("2026-07-27T06:02:05") == datetime(2026, 7, 27, 6, 2, 5)
  33. @pytest.mark.parametrize("value", ["", None, "not a date", 12345, {}])
  34. def test_returns_none_for_junk(self, value):
  35. assert _parse_dt(value) is None
  36. class TestSettingKeyBlocklist:
  37. @pytest.mark.parametrize(
  38. "key",
  39. [
  40. "bambu_cloud_token",
  41. "auth_secret_key",
  42. "ha_token",
  43. "prometheus_token",
  44. "printer_access_code",
  45. "smtp_password",
  46. "some_api_key",
  47. "ftp_passphrase",
  48. "MQTT_SECRET",
  49. ],
  50. )
  51. def test_credential_like_keys_are_blocked(self, key):
  52. assert _is_blocked_setting_key(key) is True
  53. @pytest.mark.parametrize(
  54. "key",
  55. ["low_stock_threshold", "currency", "theme", "local_backup_enabled", "timezone"],
  56. )
  57. def test_ordinary_keys_are_allowed(self, key):
  58. assert _is_blocked_setting_key(key) is False
  59. class TestCategoryTally:
  60. def test_notes_are_deduplicated(self):
  61. tally = _CategoryTally()
  62. tally.note("same")
  63. tally.note("same")
  64. assert tally.notes == ["same"]
  65. def test_notes_are_bounded(self):
  66. tally = _CategoryTally()
  67. for i in range(50):
  68. tally.note(f"note {i}")
  69. assert len(tally.notes) == 20
  70. class TestRestoreRequestSchema:
  71. def test_rejects_empty_category_list(self):
  72. with pytest.raises(ValueError):
  73. GitHubRestoreRequest(categories=[])
  74. def test_deduplicates_categories(self):
  75. request = GitHubRestoreRequest(
  76. categories=[RestoreCategory.SPOOLS, RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
  77. )
  78. assert request.categories == [RestoreCategory.SPOOLS, RestoreCategory.SETTINGS]
  79. def test_defaults_to_head(self):
  80. assert GitHubRestoreRequest(categories=[RestoreCategory.SPOOLS]).ref == "HEAD"
  81. @pytest.mark.parametrize("ref", ["HEAD", "abc1234", "a" * 40])
  82. def test_accepts_valid_refs(self, ref):
  83. assert GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS]).ref == ref
  84. @pytest.mark.parametrize("ref", ["abc", "main", "../etc/passwd", "a" * 41, "zzzzzzz", "abc 123"])
  85. def test_rejects_refs_that_are_not_object_names(self, ref):
  86. with pytest.raises(ValueError):
  87. GitHubRestoreRequest(ref=ref, categories=[RestoreCategory.SPOOLS])
  88. class TestRestoreSettings:
  89. @pytest.mark.asyncio
  90. async def test_inserts_missing_keys(self, db_session):
  91. tally = _CategoryTally()
  92. payload = {"version": "1.0", "settings": {"currency": "EUR", "theme": "dark"}}
  93. await _service()._restore_settings(db_session, payload, overwrite=False, tally=tally)
  94. await db_session.commit()
  95. rows = {s.key: s.value for s in (await db_session.execute(select(Settings))).scalars().all()}
  96. assert rows == {"currency": "EUR", "theme": "dark"}
  97. assert tally.restored == 2
  98. @pytest.mark.asyncio
  99. async def test_skips_existing_key_when_overwrite_off(self, db_session):
  100. db_session.add(Settings(key="currency", value="USD"))
  101. await db_session.commit()
  102. tally = _CategoryTally()
  103. await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=False, tally=tally)
  104. await db_session.commit()
  105. row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
  106. assert row.value == "USD"
  107. assert tally.skipped == 1
  108. assert tally.restored == 0
  109. @pytest.mark.asyncio
  110. async def test_overwrites_existing_key_when_enabled(self, db_session):
  111. db_session.add(Settings(key="currency", value="USD"))
  112. await db_session.commit()
  113. tally = _CategoryTally()
  114. await _service()._restore_settings(db_session, {"settings": {"currency": "EUR"}}, overwrite=True, tally=tally)
  115. await db_session.commit()
  116. row = (await db_session.execute(select(Settings).where(Settings.key == "currency"))).scalar_one()
  117. assert row.value == "EUR"
  118. assert tally.restored == 1
  119. @pytest.mark.asyncio
  120. async def test_credential_keys_are_never_restored(self, db_session):
  121. """A backup predating the collector's denylist can still contain secrets."""
  122. tally = _CategoryTally()
  123. payload = {"settings": {"currency": "EUR", "bambu_cloud_token": "leaked", "ha_token": "leaked"}}
  124. await _service()._restore_settings(db_session, payload, overwrite=True, tally=tally)
  125. await db_session.commit()
  126. keys = {s.key for s in (await db_session.execute(select(Settings))).scalars().all()}
  127. assert keys == {"currency"}
  128. assert tally.skipped == 2
  129. assert any("credential-like" in note for note in tally.notes)
  130. @pytest.mark.asyncio
  131. async def test_missing_payload_is_noted_not_fatal(self, db_session):
  132. tally = _CategoryTally()
  133. await _service()._restore_settings(db_session, None, overwrite=True, tally=tally)
  134. assert tally.restored == 0
  135. assert tally.notes
  136. class TestRestoreSpools:
  137. def _spool_entry(self, **overrides):
  138. entry = {
  139. "id": 41,
  140. "material": "PLA",
  141. "subtype": "Basic",
  142. "color_name": "Jade White",
  143. "brand": "Bambu Lab",
  144. "tag_uid": "AABBCCDD",
  145. "created_at": "2026-01-05 12:00:00",
  146. "weight_used": 120.5,
  147. }
  148. entry.update(overrides)
  149. return entry
  150. @pytest.mark.asyncio
  151. async def test_inserts_without_reusing_backup_id(self, db_session):
  152. """The backup's spool.id belongs to an unrelated row today."""
  153. db_session.add(Spool(material="PETG")) # occupies id 1
  154. await db_session.commit()
  155. tally = _CategoryTally()
  156. payload = {"spools": [self._spool_entry(id=1)]}
  157. await _service()._restore_spools(db_session, payload, None, False, tally, {})
  158. await db_session.commit()
  159. spools = (await db_session.execute(select(Spool))).scalars().all()
  160. assert len(spools) == 2
  161. restored = next(s for s in spools if s.tag_uid == "AABBCCDD")
  162. assert restored.id != 1
  163. assert restored.material == "PLA"
  164. @pytest.mark.asyncio
  165. async def test_matches_existing_spool_by_tag_uid(self, db_session):
  166. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
  167. await db_session.commit()
  168. tally = _CategoryTally()
  169. await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, False, tally, {})
  170. await db_session.commit()
  171. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  172. assert tally.skipped == 1
  173. @pytest.mark.asyncio
  174. async def test_matches_existing_spool_by_tray_uuid(self, db_session):
  175. db_session.add(Spool(material="PLA", tray_uuid="1234" * 8))
  176. await db_session.commit()
  177. tally = _CategoryTally()
  178. entry = self._spool_entry(tag_uid=None, tray_uuid="1234" * 8)
  179. await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
  180. await db_session.commit()
  181. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  182. assert tally.skipped == 1
  183. @pytest.mark.asyncio
  184. async def test_matches_tagless_spool_by_descriptive_composite(self, db_session):
  185. """Manually added spools have no tag, so fall back to created_at + description."""
  186. db_session.add(
  187. Spool(
  188. material="PLA",
  189. subtype="Basic",
  190. color_name="Jade White",
  191. brand="Bambu Lab",
  192. created_at=datetime(2026, 1, 5, 12, 0, 0),
  193. )
  194. )
  195. await db_session.commit()
  196. tally = _CategoryTally()
  197. entry = self._spool_entry(tag_uid=None)
  198. await _service()._restore_spools(db_session, {"spools": [entry]}, None, False, tally, {})
  199. await db_session.commit()
  200. assert len((await db_session.execute(select(Spool))).scalars().all()) == 1
  201. assert tally.skipped == 1
  202. @pytest.mark.asyncio
  203. async def test_overwrite_updates_matched_spool(self, db_session):
  204. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old", weight_used=0))
  205. await db_session.commit()
  206. tally = _CategoryTally()
  207. await _service()._restore_spools(db_session, {"spools": [self._spool_entry()]}, None, True, tally, {})
  208. await db_session.commit()
  209. row = (await db_session.execute(select(Spool))).scalar_one()
  210. assert row.color_name == "Jade White"
  211. assert row.weight_used == 120.5
  212. assert tally.restored == 1
  213. @pytest.mark.asyncio
  214. async def test_insert_preserves_created_at_so_repeat_restore_is_idempotent(self, db_session):
  215. """Second restore of the same backup must match, not duplicate."""
  216. service = _service()
  217. payload = {"spools": [self._spool_entry(tag_uid=None)]}
  218. await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
  219. await db_session.commit()
  220. await service._restore_spools(db_session, payload, None, False, _CategoryTally(), {})
  221. await db_session.commit()
  222. spools = (await db_session.execute(select(Spool))).scalars().all()
  223. assert len(spools) == 1
  224. assert spools[0].created_at == datetime(2026, 1, 5, 12, 0, 0)
  225. @pytest.mark.asyncio
  226. async def test_usage_history_spool_id_is_remapped(self, db_session):
  227. """Usage rows must point at the new local spool id, not the backup's."""
  228. tally = _CategoryTally()
  229. inventory = {"spools": [self._spool_entry(id=41)]}
  230. usage = {
  231. "usage_history": [
  232. {
  233. "id": 900,
  234. "spool_id": 41,
  235. "printer_id": None,
  236. "print_name": "benchy.3mf",
  237. "archive_id": None,
  238. "weight_used": 12.0,
  239. "percent_used": 5,
  240. "status": "completed",
  241. "created_at": "2026-02-01 09:00:00",
  242. }
  243. ]
  244. }
  245. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  246. await db_session.commit()
  247. spool = (await db_session.execute(select(Spool))).scalar_one()
  248. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  249. assert row.spool_id == spool.id
  250. assert row.print_name == "benchy.3mf"
  251. @pytest.mark.asyncio
  252. async def test_usage_history_archive_id_is_remapped(self, db_session):
  253. tally = _CategoryTally()
  254. inventory = {"spools": [self._spool_entry(id=41)]}
  255. usage = {
  256. "usage_history": [
  257. {
  258. "spool_id": 41,
  259. "archive_id": 77,
  260. "weight_used": 1.0,
  261. "created_at": "2026-02-01 09:00:00",
  262. }
  263. ]
  264. }
  265. archive = PrintArchive(filename="a.3mf", file_path="", file_size=1)
  266. db_session.add(archive)
  267. await db_session.flush()
  268. await _service()._restore_spools(db_session, inventory, usage, False, tally, {77: archive.id})
  269. await db_session.commit()
  270. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  271. assert row.archive_id == archive.id
  272. @pytest.mark.asyncio
  273. async def test_usage_row_with_unresolvable_spool_is_skipped_and_explained(self, db_session):
  274. tally = _CategoryTally()
  275. usage = {"usage_history": [{"spool_id": 999, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}]}
  276. await _service()._restore_spools(db_session, {"spools": []}, usage, False, tally, {})
  277. await db_session.commit()
  278. assert (await db_session.execute(select(SpoolUsageHistory))).scalars().first() is None
  279. assert tally.skipped == 1
  280. assert any("their spool is not in this backup's spool list" in note for note in tally.notes)
  281. # No remedy is offered, because none exists: overwrite does not change
  282. # which spools land in the map (a skipped spool is mapped anyway), and
  283. # usage history is always restored alongside the spools category.
  284. assert not any("overwrite" in note.lower() for note in tally.notes)
  285. @pytest.mark.asyncio
  286. async def test_usage_resolves_against_a_spool_skipped_because_overwrite_is_off(self, db_session):
  287. """A skipped spool is still mapped, so its usage rows are not "unresolved".
  288. This is why the note above offers no remedy: turning overwrite on would
  289. not rescue anything, and saying so misdescribed which records are lost.
  290. """
  291. db_session.add(Spool(material="PLA", tag_uid="AABBCCDD", color_name="Old"))
  292. await db_session.commit()
  293. tally = _CategoryTally()
  294. inventory = {"spools": [self._spool_entry(id=41)]}
  295. usage = {
  296. "usage_history": [
  297. {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
  298. ]
  299. }
  300. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  301. await db_session.commit()
  302. spool = (await db_session.execute(select(Spool))).scalar_one()
  303. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  304. assert row.spool_id == spool.id
  305. assert not any("spool list" in note for note in tally.notes)
  306. @pytest.mark.asyncio
  307. async def test_usage_history_is_not_duplicated_on_repeat_restore(self, db_session):
  308. service = _service()
  309. inventory = {"spools": [self._spool_entry(id=41)]}
  310. usage = {
  311. "usage_history": [
  312. {"spool_id": 41, "print_name": "b.3mf", "weight_used": 5.0, "created_at": "2026-02-01 09:00:00"}
  313. ]
  314. }
  315. await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
  316. await db_session.commit()
  317. await service._restore_spools(db_session, inventory, usage, False, _CategoryTally(), {})
  318. await db_session.commit()
  319. rows = (await db_session.execute(select(SpoolUsageHistory))).scalars().all()
  320. assert len(rows) == 1
  321. @pytest.mark.asyncio
  322. async def test_dangling_printer_id_is_cleared(self, db_session):
  323. tally = _CategoryTally()
  324. inventory = {"spools": [self._spool_entry(id=41)]}
  325. usage = {
  326. "usage_history": [
  327. {"spool_id": 41, "printer_id": 4242, "weight_used": 1.0, "created_at": "2026-02-01 09:00:00"}
  328. ]
  329. }
  330. await _service()._restore_spools(db_session, inventory, usage, False, tally, {})
  331. await db_session.commit()
  332. row = (await db_session.execute(select(SpoolUsageHistory))).scalar_one()
  333. assert row.printer_id is None
  334. class TestRestoreArchives:
  335. def _archive_entry(self, **overrides):
  336. entry = {
  337. "id": 77,
  338. "filename": "benchy.3mf",
  339. "file_size": 2048,
  340. "content_hash": "abc123",
  341. "print_name": "Benchy",
  342. "status": "completed",
  343. "started_at": "2026-03-01 10:00:00",
  344. "completed_at": "2026-03-01 11:00:00",
  345. "created_at": "2026-03-01 10:00:00",
  346. "quantity": 1,
  347. "is_favorite": False,
  348. }
  349. entry.update(overrides)
  350. return entry
  351. @pytest.mark.asyncio
  352. async def test_inserts_metadata_only_row_with_empty_file_path(self, db_session):
  353. """print_archives.file_path is NOT NULL but is not in the backup."""
  354. tally = _CategoryTally()
  355. id_map: dict[int, int] = {}
  356. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, id_map)
  357. await db_session.commit()
  358. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  359. assert row.file_path == ""
  360. assert row.filename == "benchy.3mf"
  361. assert row.id != 77
  362. assert id_map == {77: row.id}
  363. assert any("metadata only" in note for note in tally.notes)
  364. @pytest.mark.asyncio
  365. async def test_matches_existing_archive_by_hash_and_start(self, db_session):
  366. db_session.add(
  367. PrintArchive(
  368. filename="benchy.3mf",
  369. file_path="/data/benchy.3mf",
  370. file_size=2048,
  371. content_hash="abc123",
  372. started_at=datetime(2026, 3, 1, 10, 0, 0),
  373. )
  374. )
  375. await db_session.commit()
  376. tally = _CategoryTally()
  377. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
  378. await db_session.commit()
  379. rows = (await db_session.execute(select(PrintArchive))).scalars().all()
  380. assert len(rows) == 1
  381. assert rows[0].file_path == "/data/benchy.3mf"
  382. assert tally.skipped == 1
  383. @pytest.mark.asyncio
  384. async def test_falls_back_to_filename_and_start_without_hash(self, db_session):
  385. db_session.add(
  386. PrintArchive(
  387. filename="benchy.3mf",
  388. file_path="/data/benchy.3mf",
  389. file_size=2048,
  390. started_at=datetime(2026, 3, 1, 10, 0, 0),
  391. )
  392. )
  393. await db_session.commit()
  394. tally = _CategoryTally()
  395. entry = self._archive_entry(content_hash=None)
  396. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  397. await db_session.commit()
  398. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
  399. assert tally.skipped == 1
  400. @pytest.mark.asyncio
  401. async def test_matches_archive_with_no_started_at_by_hash(self, db_session):
  402. """started_at is NULL for re-sliced archives, so it cannot be required.
  403. Gating both match branches on it meant these rows never matched: every
  404. restore re-inserted them and overwrite mode could never update them.
  405. """
  406. db_session.add(
  407. PrintArchive(
  408. filename="benchy.3mf",
  409. file_path="/data/benchy.3mf",
  410. file_size=2048,
  411. content_hash="abc123",
  412. started_at=None,
  413. )
  414. )
  415. await db_session.commit()
  416. tally = _CategoryTally()
  417. entry = self._archive_entry(started_at=None)
  418. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  419. await db_session.commit()
  420. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 1
  421. assert tally.skipped == 1
  422. @pytest.mark.asyncio
  423. async def test_started_at_still_discriminates_when_present(self, db_session):
  424. """A NULL-tolerant match must not collapse rows that do differ."""
  425. db_session.add(
  426. PrintArchive(
  427. filename="benchy.3mf",
  428. file_path="/data/benchy.3mf",
  429. file_size=2048,
  430. content_hash="abc123",
  431. started_at=datetime(2026, 3, 1, 10, 0, 0),
  432. )
  433. )
  434. await db_session.commit()
  435. tally = _CategoryTally()
  436. # Same file, no start time recorded — a different row, not that one.
  437. entry = self._archive_entry(started_at=None)
  438. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  439. await db_session.commit()
  440. assert len((await db_session.execute(select(PrintArchive))).scalars().all()) == 2
  441. assert tally.restored == 1
  442. @pytest.mark.asyncio
  443. async def test_soft_deleted_archive_is_not_restored_as_visible(self, db_session):
  444. """A backup keeps soft-deleted rows, so the flag has to survive.
  445. Their row is retained on purpose (stats keep counting the filament and
  446. energy), so without carrying deleted_at a restore turns an archive the
  447. user deleted back into a visible one.
  448. """
  449. tally = _CategoryTally()
  450. entry = self._archive_entry(deleted_at="2026-03-02 08:00:00")
  451. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  452. await db_session.commit()
  453. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  454. assert row.deleted_at == datetime(2026, 3, 2, 8, 0, 0)
  455. assert tally.restored == 1
  456. @pytest.mark.asyncio
  457. async def test_locally_deleted_archive_stays_deleted_without_overwrite(self, db_session):
  458. db_session.add(
  459. PrintArchive(
  460. filename="benchy.3mf",
  461. file_path="",
  462. file_size=2048,
  463. content_hash="abc123",
  464. started_at=datetime(2026, 3, 1, 10, 0, 0),
  465. deleted_at=datetime(2026, 3, 5, 9, 0, 0),
  466. )
  467. )
  468. await db_session.commit()
  469. tally = _CategoryTally()
  470. # The backup predates the deletion, so its copy is live.
  471. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, False, tally, {})
  472. await db_session.commit()
  473. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  474. assert row.deleted_at == datetime(2026, 3, 5, 9, 0, 0)
  475. assert tally.skipped == 1
  476. @pytest.mark.asyncio
  477. async def test_overwrite_undeletes_a_locally_deleted_archive_and_says_so(self, db_session):
  478. db_session.add(
  479. PrintArchive(
  480. filename="benchy.3mf",
  481. file_path="",
  482. file_size=2048,
  483. content_hash="abc123",
  484. started_at=datetime(2026, 3, 1, 10, 0, 0),
  485. deleted_at=datetime(2026, 3, 5, 9, 0, 0),
  486. )
  487. )
  488. await db_session.commit()
  489. tally = _CategoryTally()
  490. await _service()._restore_archives(db_session, {"archives": [self._archive_entry()]}, True, tally, {})
  491. await db_session.commit()
  492. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  493. assert row.deleted_at is None
  494. assert tally.restored == 1
  495. assert any("visible again" in note for note in tally.notes)
  496. @pytest.mark.asyncio
  497. async def test_overwrite_updates_metadata_but_keeps_local_file_path(self, db_session):
  498. db_session.add(
  499. PrintArchive(
  500. filename="benchy.3mf",
  501. file_path="/data/benchy.3mf",
  502. file_size=2048,
  503. content_hash="abc123",
  504. started_at=datetime(2026, 3, 1, 10, 0, 0),
  505. notes="old",
  506. )
  507. )
  508. await db_session.commit()
  509. tally = _CategoryTally()
  510. entry = self._archive_entry(notes="restored note")
  511. await _service()._restore_archives(db_session, {"archives": [entry]}, True, tally, {})
  512. await db_session.commit()
  513. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  514. assert row.notes == "restored note"
  515. # The 3MF on disk must not be orphaned by a metadata restore.
  516. assert row.file_path == "/data/benchy.3mf"
  517. assert tally.restored == 1
  518. @pytest.mark.asyncio
  519. async def test_dangling_printer_and_project_links_are_cleared(self, db_session):
  520. tally = _CategoryTally()
  521. entry = self._archive_entry(printer_id=4242, project_id=4343)
  522. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  523. await db_session.commit()
  524. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  525. assert row.printer_id is None
  526. assert row.project_id is None
  527. assert any("no longer exist" in note for note in tally.notes)
  528. @pytest.mark.asyncio
  529. async def test_valid_printer_link_is_preserved(self, db_session, printer_factory):
  530. printer = await printer_factory()
  531. tally = _CategoryTally()
  532. entry = self._archive_entry(printer_id=printer.id)
  533. await _service()._restore_archives(db_session, {"archives": [entry]}, False, tally, {})
  534. await db_session.commit()
  535. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  536. assert row.printer_id == printer.id
  537. @pytest.mark.asyncio
  538. async def test_non_dict_entry_counts_as_failed(self, db_session):
  539. tally = _CategoryTally()
  540. await _service()._restore_archives(db_session, {"archives": ["nonsense"]}, False, tally, {})
  541. assert tally.failed == 1
  542. class TestRestoreKprofiles:
  543. def _payload(self, serial="00M09A123456789", nozzle="0.4"):
  544. return {
  545. f"kprofiles/{serial}/{nozzle}.json": {
  546. "version": "1.0",
  547. "printer_serial": serial,
  548. "nozzle_diameter": nozzle,
  549. "profiles": [
  550. {
  551. "slot_id": 0,
  552. "name": "Bambu PLA",
  553. "k_value": "0.020000",
  554. "filament_id": "GFA00",
  555. "nozzle_id": "HS00-0.4",
  556. "extruder_id": 0,
  557. "setting_id": "PFUS123",
  558. }
  559. ],
  560. }
  561. }
  562. @pytest.mark.asyncio
  563. async def test_sends_batch_to_connected_printer(self, db_session, printer_factory):
  564. printer = await printer_factory(serial_number="00M09A123456789")
  565. client = MagicMock()
  566. client.state.connected = True
  567. client.set_kprofiles_batch = MagicMock(return_value=True)
  568. tally = _CategoryTally()
  569. with patch("backend.app.services.github_restore.printer_manager") as manager:
  570. manager.get_client = MagicMock(return_value=client)
  571. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  572. client.set_kprofiles_batch.assert_called_once()
  573. profiles, nozzle = client.set_kprofiles_batch.call_args.args
  574. assert nozzle == "0.4"
  575. assert profiles[0]["name"] == "Bambu PLA"
  576. assert profiles[0]["filament_id"] == "GFA00"
  577. assert tally.restored == 1
  578. assert manager.get_client.call_args.args == (printer.id,)
  579. @pytest.mark.asyncio
  580. async def test_always_warns_that_mqtt_is_unacknowledged(self, db_session, printer_factory):
  581. await printer_factory(serial_number="00M09A123456789")
  582. client = MagicMock()
  583. client.state.connected = True
  584. client.set_kprofiles_batch = MagicMock(return_value=True)
  585. tally = _CategoryTally()
  586. with patch("backend.app.services.github_restore.printer_manager") as manager:
  587. manager.get_client = MagicMock(return_value=client)
  588. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  589. assert any("without acknowledgement" in note for note in tally.notes)
  590. assert any("always overwrite" in note for note in tally.notes)
  591. @pytest.mark.asyncio
  592. async def test_unknown_serial_is_skipped_with_reason(self, db_session):
  593. tally = _CategoryTally()
  594. with patch("backend.app.services.github_restore.printer_manager"):
  595. await _service()._restore_kprofiles(db_session, self._payload(serial="NOSUCH"), tally)
  596. assert tally.restored == 0
  597. assert tally.skipped == 1
  598. assert any("No printer with serial NOSUCH" in note for note in tally.notes)
  599. @pytest.mark.asyncio
  600. async def test_offline_printer_is_skipped_not_failed(self, db_session, printer_factory):
  601. await printer_factory(serial_number="00M09A123456789", name="Shelf Printer")
  602. client = MagicMock()
  603. client.state.connected = False
  604. tally = _CategoryTally()
  605. with patch("backend.app.services.github_restore.printer_manager") as manager:
  606. manager.get_client = MagicMock(return_value=client)
  607. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  608. assert tally.skipped == 1
  609. assert tally.failed == 0
  610. assert any("not connected" in note for note in tally.notes)
  611. @pytest.mark.asyncio
  612. async def test_no_client_at_all_is_skipped(self, db_session, printer_factory):
  613. await printer_factory(serial_number="00M09A123456789")
  614. tally = _CategoryTally()
  615. with patch("backend.app.services.github_restore.printer_manager") as manager:
  616. manager.get_client = MagicMock(return_value=None)
  617. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  618. assert tally.skipped == 1
  619. @pytest.mark.asyncio
  620. async def test_publish_failure_counts_as_failed(self, db_session, printer_factory):
  621. await printer_factory(serial_number="00M09A123456789")
  622. client = MagicMock()
  623. client.state.connected = True
  624. client.set_kprofiles_batch = MagicMock(return_value=False)
  625. tally = _CategoryTally()
  626. with patch("backend.app.services.github_restore.printer_manager") as manager:
  627. manager.get_client = MagicMock(return_value=client)
  628. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  629. assert tally.failed == 1
  630. assert tally.restored == 0
  631. @pytest.mark.asyncio
  632. async def test_publish_exception_is_contained(self, db_session, printer_factory):
  633. await printer_factory(serial_number="00M09A123456789")
  634. client = MagicMock()
  635. client.state.connected = True
  636. client.set_kprofiles_batch = MagicMock(side_effect=RuntimeError("mqtt down"))
  637. tally = _CategoryTally()
  638. with patch("backend.app.services.github_restore.printer_manager") as manager:
  639. manager.get_client = MagicMock(return_value=client)
  640. await _service()._restore_kprofiles(db_session, self._payload(), tally)
  641. assert tally.failed == 1
  642. @pytest.mark.asyncio
  643. async def test_each_nozzle_is_sent_separately(self, db_session, printer_factory):
  644. await printer_factory(serial_number="00M09A123456789")
  645. payload = {**self._payload(nozzle="0.4"), **self._payload(nozzle="0.8")}
  646. client = MagicMock()
  647. client.state.connected = True
  648. client.set_kprofiles_batch = MagicMock(return_value=True)
  649. tally = _CategoryTally()
  650. with patch("backend.app.services.github_restore.printer_manager") as manager:
  651. manager.get_client = MagicMock(return_value=client)
  652. await _service()._restore_kprofiles(db_session, payload, tally)
  653. assert client.set_kprofiles_batch.call_count == 2
  654. assert {c.args[1] for c in client.set_kprofiles_batch.call_args_list} == {"0.4", "0.8"}
  655. assert tally.restored == 2
  656. @pytest.mark.asyncio
  657. async def test_empty_payload_is_noted(self, db_session):
  658. tally = _CategoryTally()
  659. await _service()._restore_kprofiles(db_session, {}, tally)
  660. assert any("No K-profile data" in note for note in tally.notes)
  661. class TestSoftDeletedArchiveRoundTrip:
  662. """The two halves of the soft-delete fix only work together.
  663. The collector keeps soft-deleted rows on purpose (their stats still count),
  664. so if it doesn't write ``deleted_at`` there is nothing for the restore to
  665. carry across and a deleted archive comes back visible. Covered end to end
  666. because each half looks harmless on its own.
  667. """
  668. @pytest.mark.asyncio
  669. async def test_deleted_at_survives_collect_then_restore(self, db_session):
  670. from backend.app.services.github_backup import github_backup_service
  671. deleted_at = datetime(2026, 3, 5, 9, 0, 0)
  672. db_session.add(
  673. PrintArchive(
  674. filename="trashed.3mf",
  675. file_path="",
  676. file_size=1024,
  677. content_hash="hash-trashed",
  678. started_at=datetime(2026, 3, 1, 10, 0, 0),
  679. deleted_at=deleted_at,
  680. )
  681. )
  682. await db_session.commit()
  683. files: dict = {}
  684. await github_backup_service._collect_archives(db_session, files)
  685. payload = files[ARCHIVES_PATH]
  686. assert payload["archives"][0]["deleted_at"] == str(deleted_at)
  687. # Restore that payload into an instance where the row is gone entirely.
  688. await db_session.execute(PrintArchive.__table__.delete())
  689. await db_session.commit()
  690. tally = _CategoryTally()
  691. await _service()._restore_archives(db_session, payload, False, tally, {})
  692. await db_session.commit()
  693. row = (await db_session.execute(select(PrintArchive))).scalar_one()
  694. assert row.deleted_at == deleted_at, "a deleted archive must not come back visible"
  695. class TestCategoryPathMapping:
  696. def setup_method(self):
  697. self.service = _service()
  698. self.available = [
  699. "backup_metadata.json",
  700. SETTINGS_PATH,
  701. SPOOLS_PATH,
  702. SPOOL_USAGE_PATH,
  703. ARCHIVES_PATH,
  704. "kprofiles/SERIAL1/0.4.json",
  705. "kprofiles/SERIAL1/0.8.json",
  706. "cloud_profiles/filament.json",
  707. ]
  708. def test_spools_includes_usage_history(self):
  709. paths = self.service._category_paths(RestoreCategory.SPOOLS, self.available)
  710. assert paths == [SPOOLS_PATH, SPOOL_USAGE_PATH]
  711. def test_kprofiles_globs_all_serials_and_nozzles(self):
  712. paths = self.service._category_paths(RestoreCategory.KPROFILES, self.available)
  713. assert paths == ["kprofiles/SERIAL1/0.4.json", "kprofiles/SERIAL1/0.8.json"]
  714. def test_absent_paths_are_omitted(self):
  715. paths = self.service._category_paths(RestoreCategory.SETTINGS, ["backup_metadata.json"])
  716. assert paths == []
  717. def test_cloud_profiles_are_not_a_restore_category(self):
  718. assert "cloud_profiles" not in {c.value for c in RestoreCategory}
  719. class TestMutex:
  720. @pytest.mark.asyncio
  721. async def test_restore_refuses_while_a_backup_is_running(self):
  722. service = _service()
  723. with patch("backend.app.services.github_backup.github_backup_service") as backup:
  724. backup.is_running = True
  725. result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
  726. assert result["success"] is False
  727. assert "backup is currently running" in result["message"]
  728. @pytest.mark.asyncio
  729. async def test_restore_refuses_while_another_restore_is_running(self):
  730. service = _service()
  731. service._running_restore = True
  732. result = await service.run_restore(1, "HEAD", [RestoreCategory.SPOOLS])
  733. assert result["success"] is False
  734. assert "restore is already running" in result["message"]
  735. @pytest.mark.asyncio
  736. async def test_backup_refuses_while_a_restore_is_running(self):
  737. from backend.app.services.github_backup import GitHubBackupService
  738. backup_service = GitHubBackupService()
  739. with patch("backend.app.services.github_restore.github_restore_service") as restore:
  740. restore.is_running = True
  741. result = await backup_service.run_backup(1, trigger="manual")
  742. assert result["success"] is False
  743. assert "restore is currently running" in result["message"]
  744. class TestResolveRef:
  745. @pytest.mark.asyncio
  746. async def test_concrete_sha_passes_through_without_an_api_call(self):
  747. service = _service()
  748. service.list_commits = AsyncMock()
  749. config = MagicMock(branch="main")
  750. resolved, error = await service._resolve_ref(config, "abc1234")
  751. assert resolved == "abc1234"
  752. assert error == ""
  753. service.list_commits.assert_not_awaited()
  754. @pytest.mark.asyncio
  755. async def test_head_resolves_to_the_tip_sha(self):
  756. service = _service()
  757. service.list_commits = AsyncMock(
  758. return_value={"success": True, "commits": [{"sha": "tipsha1"}, {"sha": "older"}]}
  759. )
  760. config = MagicMock(branch="main")
  761. resolved, error = await service._resolve_ref(config, "HEAD")
  762. assert resolved == "tipsha1"
  763. assert error == ""
  764. @pytest.mark.asyncio
  765. async def test_empty_history_is_an_error(self):
  766. service = _service()
  767. service.list_commits = AsyncMock(return_value={"success": True, "commits": []})
  768. config = MagicMock(branch="main")
  769. resolved, error = await service._resolve_ref(config, "HEAD")
  770. assert resolved is None
  771. assert "no commits" in error