test_git_backup_runs.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. """A Git backup keeps each print of an archive (archive redesign P3).
  2. Statistics and the print log count prints, not archives: a backup with
  3. archives only restored them empty. The backup now also writes each print and
  4. which archive went into which, and the restore attaches the prints to their
  5. restored archives, recognises an archive printed again since the backup, and
  6. follows a merged archive to the one it went into.
  7. """
  8. from __future__ import annotations
  9. import json
  10. from datetime import datetime
  11. import pytest
  12. from sqlalchemy import delete, func, select
  13. from backend.app.models.archive import PrintArchive
  14. from backend.app.models.archive_alias import ArchiveAlias
  15. from backend.app.models.print_log import INCLUDE_OPEN_RUNS, RUN_OPEN, PrintLogEntry
  16. from backend.app.models.user import User
  17. from backend.app.schemas.github_backup import RestoreCategory
  18. from backend.app.services.github_backup import github_backup_service
  19. from backend.app.services.github_restore import (
  20. ALIASES_PATH,
  21. ARCHIVES_PATH,
  22. RUNS_PATH,
  23. GitHubRestoreService,
  24. _CategoryTally,
  25. )
  26. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  27. T1 = datetime(2026, 10, 1, 9)
  28. T2 = datetime(2026, 10, 5, 9)
  29. T3 = datetime(2026, 10, 9, 9)
  30. async def _backup(db) -> dict:
  31. files: dict = {}
  32. await github_backup_service._collect_archives(db, files)
  33. # As read back out of the repository.
  34. return json.loads(json.dumps(files))
  35. async def _restore(db, files: dict, overwrite: bool = False) -> _CategoryTally:
  36. results = await GitHubRestoreService()._apply(db, files, [RestoreCategory.ARCHIVES], overwrite)
  37. return results[RestoreCategory.ARCHIVES.value]
  38. async def _all_runs(db) -> list[PrintLogEntry]:
  39. return list(
  40. (
  41. await db.execute(
  42. select(PrintLogEntry).order_by(PrintLogEntry.started_at).execution_options(**{INCLUDE_OPEN_RUNS: True})
  43. )
  44. )
  45. .scalars()
  46. .all()
  47. )
  48. async def _wipe(db) -> None:
  49. """A rebuilt instance: no archives, prints or merges."""
  50. for model in (PrintLogEntry, ArchiveAlias, PrintArchive):
  51. await db.execute(delete(model))
  52. await db.commit()
  53. @pytest.fixture
  54. async def printed(db_session, printer_factory):
  55. """A file printed twice on two plates, with a merged duplicate."""
  56. a, b = await printer_factory(name="A"), await printer_factory(name="B")
  57. planter = PrintArchive(
  58. printer_id=b.id,
  59. filename="planter.3mf",
  60. file_path="archives/t/planter.3mf",
  61. file_size=1,
  62. content_hash="hash-planter",
  63. print_name="Planter",
  64. status="failed",
  65. started_at=T2,
  66. created_at=T1,
  67. )
  68. merged = PrintArchive(
  69. printer_id=a.id,
  70. filename="planter.3mf",
  71. file_path="",
  72. file_size=1,
  73. content_hash="hash-planter",
  74. print_name="Planter",
  75. status="completed",
  76. started_at=T1,
  77. created_at=T2,
  78. deleted_at=T2,
  79. )
  80. db_session.add_all([planter, merged])
  81. await db_session.flush()
  82. db_session.add(ArchiveAlias(merged_archive_id=merged.id, archive_id=planter.id))
  83. db_session.add_all(
  84. [
  85. PrintLogEntry(
  86. archive_id=planter.id,
  87. printer_id=a.id,
  88. printer_name="A",
  89. plate_id=1,
  90. status="completed",
  91. started_at=T1,
  92. filament_used_grams=12.0,
  93. cost=0.5,
  94. user_verdict="good",
  95. user_verdict_source="dialog",
  96. confirm_token="secret-token",
  97. created_at=T1,
  98. ),
  99. PrintLogEntry(
  100. archive_id=planter.id,
  101. printer_id=b.id,
  102. printer_name="B",
  103. plate_id=2,
  104. status="failed",
  105. started_at=T2,
  106. failure_reason="spaghetti",
  107. created_at=T2,
  108. ),
  109. ]
  110. )
  111. await db_session.commit()
  112. return {"a": a.id, "b": b.id, "planter": planter.id, "merged": merged.id}
  113. async def test_the_backup_keeps_each_print_and_merge(db_session, printed):
  114. files = await _backup(db_session)
  115. runs = files[RUNS_PATH]["runs"]
  116. assert sorted((r["archive_id"], r["plate_id"], r["status"]) for r in runs) == [
  117. (printed["planter"], 1, "completed"),
  118. (printed["planter"], 2, "failed"),
  119. ]
  120. assert files[ALIASES_PATH]["aliases"] == [
  121. {"merged_archive_id": printed["merged"], "archive_id": printed["planter"]}
  122. ]
  123. # The one-tap confirm token is a credential.
  124. assert "secret-token" not in json.dumps(files)
  125. async def test_a_rebuilt_instance_gets_its_prints_back(db_session, printed):
  126. files = await _backup(db_session)
  127. await _wipe(db_session)
  128. tally = await _restore(db_session, files)
  129. archives = (await db_session.execute(select(PrintArchive))).scalars().all()
  130. # The merged one is not a row of its own.
  131. assert [a.print_name for a in archives] == ["Planter"]
  132. runs = await _all_runs(db_session)
  133. assert [(r.archive_id, r.plate_id, r.printer_name, r.status, r.user_verdict) for r in runs] == [
  134. (archives[0].id, 1, "A", "completed", "good"),
  135. (archives[0].id, 2, "B", "failed", None),
  136. ]
  137. assert (runs[0].filament_used_grams, runs[0].cost, runs[0].created_at) == (12.0, 0.5, T1)
  138. assert runs[0].confirm_token is None
  139. # 2 archives (1 followed) and 2 prints.
  140. assert (tally.restored, tally.skipped, tally.failed) == (3, 1, 0)
  141. async def test_the_archive_takes_its_latest_prints_outcome(db_session, printed):
  142. files = await _backup(db_session)
  143. await _wipe(db_session)
  144. # The backup's archive claims the older print's outcome.
  145. files[ARCHIVES_PATH]["archives"][0].update(status="completed", started_at=str(T1))
  146. await _restore(db_session, files)
  147. [archive] = (await db_session.execute(select(PrintArchive))).scalars().all()
  148. assert (archive.status, archive.started_at, archive.failure_reason) == ("failed", T2, "spaghetti")
  149. async def test_restoring_twice_adds_nothing(db_session, printed):
  150. files = await _backup(db_session)
  151. await _wipe(db_session)
  152. await _restore(db_session, files)
  153. tally = await _restore(db_session, files)
  154. assert await db_session.scalar(select(func.count(PrintArchive.id))) == 1
  155. assert len(await _all_runs(db_session)) == 2
  156. assert (tally.restored, tally.skipped) == (0, 4)
  157. async def test_an_archive_printed_again_since_the_backup_is_recognised(db_session, printed):
  158. """Its own start is the newer print's now, so it no longer matches it."""
  159. files = await _backup(db_session)
  160. archive = await db_session.get(PrintArchive, printed["planter"])
  161. archive.started_at = T3
  162. db_session.add(PrintLogEntry(archive_id=archive.id, printer_id=printed["a"], status="completed", started_at=T3))
  163. await db_session.commit()
  164. await _restore(db_session, files)
  165. assert await db_session.scalar(select(func.count(PrintArchive.id))) == 2 # the planter and its merged one
  166. assert len(await _all_runs(db_session)) == 3
  167. async def test_what_pointed_at_a_merged_archive_follows_it(db_session, printed):
  168. files = await _backup(db_session)
  169. await _wipe(db_session)
  170. id_map: dict[int, int] = {}
  171. await GitHubRestoreService()._restore_archives(
  172. db_session,
  173. files[ARCHIVES_PATH],
  174. False,
  175. _CategoryTally(),
  176. id_map,
  177. runs_payload=files[RUNS_PATH],
  178. aliases_payload=files[ALIASES_PATH],
  179. )
  180. assert id_map[printed["merged"]] == id_map[printed["planter"]]
  181. async def test_a_print_running_at_the_backup_comes_back_cancelled(db_session, printed):
  182. db_session.add(
  183. PrintLogEntry(archive_id=printed["planter"], printer_id=printed["a"], status=RUN_OPEN, started_at=T3)
  184. )
  185. await db_session.commit()
  186. files = await _backup(db_session)
  187. await _wipe(db_session)
  188. await _restore(db_session, files)
  189. last = (await _all_runs(db_session))[-1]
  190. assert (last.started_at, last.status, last.failure_reason) == (T3, "cancelled", "noStatusUpdate")
  191. async def test_overwrite_makes_a_print_match_the_backup(db_session, printed):
  192. files = await _backup(db_session)
  193. run = (await _all_runs(db_session))[0]
  194. run.cost, run.user_verdict = 9.0, "reject"
  195. await db_session.commit()
  196. await _restore(db_session, files, overwrite=True)
  197. await db_session.refresh(run)
  198. assert (run.cost, run.user_verdict) == (0.5, "good")
  199. async def test_overwrite_leaves_a_print_running_now_to_its_print(db_session, printed):
  200. run = PrintLogEntry(archive_id=printed["planter"], printer_id=printed["a"], status=RUN_OPEN, started_at=T3)
  201. db_session.add(run)
  202. await db_session.commit()
  203. files = await _backup(db_session)
  204. # Even a backup that says it ended (another instance's, say).
  205. for entry in files[RUNS_PATH]["runs"]:
  206. entry["status"] = "completed"
  207. await _restore(db_session, files, overwrite=True)
  208. await db_session.refresh(run)
  209. assert run.status == RUN_OPEN
  210. async def test_an_owner_this_instance_lacks_keeps_the_local_one(db_session, printed):
  211. owner = User(username="alice", role="operator")
  212. db_session.add(owner)
  213. await db_session.flush()
  214. run = (await _all_runs(db_session))[0]
  215. run.created_by_id = owner.id
  216. await db_session.commit()
  217. files = await _backup(db_session)
  218. for entry in files[RUNS_PATH]["runs"]:
  219. entry["owner_username"] = "bob"
  220. await _restore(db_session, files, overwrite=True)
  221. await db_session.refresh(run)
  222. assert run.created_by_id == owner.id
  223. async def test_the_preview_counts_the_prints(db_session, printed):
  224. files = await _backup(db_session)
  225. count, detail = await GitHubRestoreService()._count_items(db_session, RestoreCategory.ARCHIVES, files)
  226. assert (count, detail.code, detail.params) == (4, "archivesRunsCount", {"count": 2})
  227. async def test_the_restore_reads_the_new_files():
  228. paths = GitHubRestoreService()._category_paths(
  229. RestoreCategory.ARCHIVES, [ARCHIVES_PATH, RUNS_PATH, ALIASES_PATH, "settings/app_settings.json"]
  230. )
  231. assert paths == [ARCHIVES_PATH, RUNS_PATH, ALIASES_PATH]
  232. async def test_a_backup_from_before_a_merge_finds_the_archive_it_went_into(db_session, printer_factory):
  233. """Not the merged row, which kept its own start but none of its prints."""
  234. printer = await printer_factory(name="A")
  235. first, second = (
  236. PrintArchive(
  237. printer_id=printer.id,
  238. filename="hook.3mf",
  239. file_path="archives/t/hook.3mf",
  240. file_size=1,
  241. content_hash="hash-hook",
  242. status="completed",
  243. started_at=start,
  244. )
  245. for start in (T1, T2)
  246. )
  247. db_session.add_all([first, second])
  248. await db_session.flush()
  249. db_session.add_all(
  250. [
  251. PrintLogEntry(archive_id=first.id, printer_id=printer.id, status="completed", started_at=T1),
  252. PrintLogEntry(archive_id=second.id, printer_id=printer.id, status="completed", started_at=T2),
  253. ]
  254. )
  255. await db_session.commit()
  256. files = await _backup(db_session)
  257. assert files[ALIASES_PATH]["aliases"] == []
  258. # Merged since: the prints went into the second, the first (found
  259. # first) is an alias.
  260. for run in await _all_runs(db_session):
  261. run.archive_id = second.id
  262. first.deleted_at = T3
  263. db_session.add(ArchiveAlias(merged_archive_id=first.id, archive_id=second.id))
  264. await db_session.commit()
  265. await _restore(db_session, files)
  266. runs = await _all_runs(db_session)
  267. assert [(r.archive_id, r.started_at) for r in runs] == [(second.id, T1), (second.id, T2)]
  268. assert await db_session.scalar(select(func.count(PrintArchive.id))) == 2
  269. async def _two_of_one_file(db, printer_id, *, second_printed: bool = True):
  270. first, second = (
  271. PrintArchive(
  272. printer_id=printer_id,
  273. filename="hook.3mf",
  274. file_path="archives/t/hook.3mf",
  275. file_size=1,
  276. content_hash="hash-hook",
  277. status="completed" if start else "archived",
  278. started_at=start,
  279. notes=notes,
  280. created_at=created,
  281. )
  282. for start, notes, created in ((T1, "first", T1), (T2 if second_printed else None, "second", T2))
  283. )
  284. db.add_all([first, second])
  285. await db.flush()
  286. db.add(PrintLogEntry(archive_id=first.id, printer_id=printer_id, status="completed", started_at=T1))
  287. if second_printed:
  288. db.add(PrintLogEntry(archive_id=second.id, printer_id=printer_id, status="completed", started_at=T2))
  289. await db.commit()
  290. return first, second
  291. async def _merge_into(db, survivor, merged, notes=None) -> None:
  292. """As the merge leaves them: prints moved, the merged row an alias."""
  293. for run in await _all_runs(db):
  294. run.archive_id = survivor.id
  295. merged.deleted_at = T3
  296. if notes:
  297. survivor.notes = notes
  298. db.add(ArchiveAlias(merged_archive_id=merged.id, archive_id=survivor.id))
  299. await db.commit()
  300. async def test_a_never_printed_archive_merged_since_stays_merged(db_session, printer_factory):
  301. printer = await printer_factory(name="A")
  302. printed, upload = await _two_of_one_file(db_session, printer.id, second_printed=False)
  303. files = await _backup(db_session)
  304. await _merge_into(db_session, printed, upload)
  305. await _restore(db_session, files)
  306. live = (await db_session.execute(select(PrintArchive).where(PrintArchive.deleted_at.is_(None)))).scalars().all()
  307. assert [a.id for a in live] == [printed.id]
  308. async def test_overwrite_leaves_what_a_merge_joined(db_session, printer_factory):
  309. """Both archives of the backup are the one here now."""
  310. printer = await printer_factory(name="A")
  311. first, second = await _two_of_one_file(db_session, printer.id)
  312. files = await _backup(db_session)
  313. await _merge_into(db_session, first, second, notes="first\nsecond")
  314. await _restore(db_session, files, overwrite=True)
  315. await db_session.refresh(first)
  316. # The first's own entry is restored over it; the second's goes to it,
  317. # without writing its fields over the first's.
  318. assert first.notes == "first"
  319. assert len(await _all_runs(db_session)) == 2
  320. async def test_an_archive_without_a_start_printed_since_is_recognised(db_session, printer_factory):
  321. printer = await printer_factory(name="A")
  322. _, upload = await _two_of_one_file(db_session, printer.id, second_printed=False)
  323. files = await _backup(db_session)
  324. upload.started_at = T3
  325. db_session.add(PrintLogEntry(archive_id=upload.id, printer_id=printer.id, status="completed", started_at=T3))
  326. await db_session.commit()
  327. await _restore(db_session, files)
  328. assert await db_session.scalar(select(func.count(PrintArchive.id))) == 2
  329. async def test_a_print_running_at_the_backup_keeps_its_figures_from_its_end(db_session, printed):
  330. run = PrintLogEntry(archive_id=printed["planter"], printer_id=printed["a"], status=RUN_OPEN, started_at=T3)
  331. db_session.add(run)
  332. await db_session.commit()
  333. files = await _backup(db_session)
  334. run.status, run.cost, run.duration_seconds = "completed", 1.2, 3600
  335. await db_session.commit()
  336. await _restore(db_session, files, overwrite=True)
  337. await db_session.refresh(run)
  338. assert (run.status, run.cost, run.duration_seconds) == ("completed", 1.2, 3600)
  339. async def test_a_deleted_users_prints_go_to_no_one_with_their_name(db_session, printed):
  340. """The print log keeps the name of who started it; the owner went."""
  341. run = (await _all_runs(db_session))[0]
  342. run.created_by_id, run.created_by_username = None, "alice"
  343. await db_session.commit()
  344. files = await _backup(db_session)
  345. await _wipe(db_session)
  346. db_session.add(User(username="alice", role="operator"))
  347. await db_session.commit()
  348. await _restore(db_session, files)
  349. restored = (await _all_runs(db_session))[0]
  350. assert (restored.created_by_id, restored.created_by_username) == (None, "alice")
  351. async def test_a_merged_archive_purged_since_is_found_by_its_prints(db_session, printer_factory):
  352. """Its row is gone, its alias and prints are in the one it went into."""
  353. printer = await printer_factory(name="A")
  354. first, second = await _two_of_one_file(db_session, printer.id)
  355. files = await _backup(db_session)
  356. await _merge_into(db_session, first, second, notes="first\nsecond")
  357. await db_session.delete(second)
  358. await db_session.commit()
  359. await _restore(db_session, files, overwrite=True)
  360. await db_session.refresh(first)
  361. assert await db_session.scalar(select(func.count(PrintArchive.id))) == 1
  362. assert first.notes == "first"
  363. async def test_a_backup_from_before_a_merge_into_the_newer_overwrites_only_it(db_session, printer_factory):
  364. printer = await printer_factory(name="A")
  365. first, second = await _two_of_one_file(db_session, printer.id)
  366. files = await _backup(db_session)
  367. await _merge_into(db_session, second, first, notes="second\nfirst")
  368. await _restore(db_session, files, overwrite=True)
  369. await db_session.refresh(second)
  370. assert second.notes == "second"