test_archive_merge.py 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604
  1. """Duplicate archives of one file merge into one (archive redesign P2).
  2. Same file, owner and project: the oldest archive stays and takes the others'
  3. runs, references, photos and videos; the others stay soft-deleted with an
  4. alias, their money untouched.
  5. """
  6. from __future__ import annotations
  7. from datetime import datetime, timedelta
  8. from pathlib import Path
  9. from types import SimpleNamespace
  10. from unittest.mock import patch
  11. import pytest
  12. from sqlalchemy import select
  13. from backend.app.core.config import settings as app_settings
  14. from backend.app.models.archive import PrintArchive
  15. from backend.app.models.archive_alias import ArchiveAlias
  16. from backend.app.models.finance import WalletTransaction
  17. from backend.app.models.print_log import RUN_OPEN, PrintLogEntry
  18. from backend.app.models.print_queue import PrintQueueItem
  19. from backend.app.models.project import Project
  20. from backend.app.models.project_bom import ProjectBOMItem
  21. pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
  22. HASH = "a" * 64
  23. T1 = datetime(2026, 9, 1, 9)
  24. @pytest.fixture
  25. def files(tmp_path, monkeypatch):
  26. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  27. monkeypatch.setattr(app_settings, "archive_dir", tmp_path / "archive")
  28. return tmp_path
  29. def _dir(files, name):
  30. path = files / "archive" / "1" / name
  31. (path / "photos").mkdir(parents=True, exist_ok=True)
  32. (path / "file.3mf").write_bytes(b"3mf")
  33. return path
  34. @pytest.fixture
  35. async def dupes(db_session, printer_factory, files):
  36. """X and Y printed once each, Z printed before runs were logged; W is the
  37. same file in another project, V the same file deleted."""
  38. printer = await printer_factory(name="A")
  39. project = Project(name="Other")
  40. db_session.add(project)
  41. await db_session.flush()
  42. def archive(name, **fields):
  43. folder = _dir(files, name)
  44. row = PrintArchive(
  45. printer_id=printer.id,
  46. filename="part.3mf",
  47. print_name="part",
  48. file_path=str((folder / "file.3mf").relative_to(files)),
  49. file_size=3,
  50. content_hash=HASH,
  51. thumbnail_path=f"archive/1/{name}/thumb.png",
  52. **({"status": "completed"} | fields),
  53. )
  54. db_session.add(row)
  55. return row, folder
  56. x, x_dir = archive("x", notes="first", tags="red", started_at=T1, completed_at=T1 + timedelta(hours=1))
  57. y, y_dir = archive(
  58. "y",
  59. notes="second",
  60. tags="blue, red",
  61. is_favorite=True,
  62. started_at=T1 + timedelta(days=1),
  63. completed_at=T1 + timedelta(days=1, hours=1),
  64. confirm_requested=True,
  65. confirm_token="tok-y",
  66. photos=["finish_y.jpg"],
  67. timelapse_path="archive/1/y/y.mp4",
  68. )
  69. z, z_dir = archive(
  70. "z",
  71. status="failed",
  72. failure_reason="spaghetti",
  73. started_at=T1 + timedelta(days=2),
  74. completed_at=T1 + timedelta(days=2, hours=1),
  75. photos=["finish_z.jpg"],
  76. timelapse_path="archive/1/z/z.mp4",
  77. filament_used_grams=12.0,
  78. )
  79. w, _ = archive("w", project_id=project.id)
  80. v, _ = archive("v", deleted_at=datetime(2026, 10, 1))
  81. await db_session.flush()
  82. (y_dir / "photos" / "finish_y.jpg").write_bytes(b"y")
  83. (y_dir / "y.mp4").write_bytes(b"yy")
  84. (z_dir / "photos" / "finish_z.jpg").write_bytes(b"z")
  85. (z_dir / "z.mp4").write_bytes(b"zz")
  86. r1 = PrintLogEntry(
  87. archive_id=x.id, printer_id=printer.id, status="completed", started_at=T1, completed_at=T1 + timedelta(hours=1)
  88. )
  89. r2 = PrintLogEntry(
  90. archive_id=y.id,
  91. printer_id=printer.id,
  92. status="completed",
  93. started_at=T1 + timedelta(days=1),
  94. completed_at=T1 + timedelta(days=1, hours=1),
  95. photos=["finish_y.jpg"],
  96. timelapse_path="archive/1/y/y.mp4",
  97. thumbnail_path="archive/1/y/thumb.png",
  98. )
  99. item = PrintQueueItem(printer_id=printer.id, archive_id=y.id, status="completed", position=1)
  100. bom = ProjectBOMItem(project_id=project.id, name="part", archive_id=y.id)
  101. charge = WalletTransaction(transaction_type="print_charge", amount=-1.0, print_archive_id=y.id)
  102. db_session.add_all([r1, r2, item, bom, charge])
  103. await db_session.commit()
  104. return SimpleNamespace(
  105. x=x.id,
  106. y=y.id,
  107. z=z.id,
  108. w=w.id,
  109. v=v.id,
  110. r1=r1.id,
  111. r2=r2.id,
  112. item=item.id,
  113. bom=bom.id,
  114. charge=charge.id,
  115. x_dir=x_dir,
  116. y_dir=y_dir,
  117. z_dir=z_dir,
  118. printer=printer.id,
  119. )
  120. async def _row(db_session, model, row_id):
  121. db_session.expire_all()
  122. return await db_session.get(model, row_id)
  123. async def _runs(db_session, archive_id):
  124. db_session.expire_all()
  125. return list(
  126. await db_session.scalars(
  127. select(PrintLogEntry).where(PrintLogEntry.archive_id == archive_id).order_by(PrintLogEntry.started_at)
  128. )
  129. )
  130. async def test_the_preview_shows_the_group(async_client, dupes):
  131. response = await async_client.get("/api/v1/archives/merge-preview")
  132. assert response.status_code == 200, response.text
  133. [group] = response.json()["groups"]
  134. assert group["survivor"]["id"] == dupes.x
  135. assert [(m["id"], m["run_count"]) for m in group["merged"]] == [(dupes.y, 1), (dupes.z, 0)]
  136. assert group["skipped"] is None
  137. async def test_merging_keeps_the_oldest_with_every_print(async_client, db_session, dupes):
  138. response = await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  139. assert response.status_code == 200, response.text
  140. assert response.json() == {"merged": [{"survivor_id": dupes.x, "merged_ids": [dupes.y, dupes.z]}], "skipped": []}
  141. runs = await _runs(db_session, dupes.x)
  142. assert [r.status for r in runs] == ["completed", "completed", "failed"]
  143. from_z = runs[2]
  144. assert (from_z.failure_reason, from_z.filament_used_grams, from_z.photos) == ("spaghetti", 12.0, ["finish_z.jpg"])
  145. archive = await _row(db_session, PrintArchive, dupes.x)
  146. # The file's description, and its latest print.
  147. assert (archive.notes, archive.tags, archive.is_favorite) == ("first\n\nsecond", "red, blue", True)
  148. assert (archive.status, archive.failure_reason) == ("failed", "spaghetti")
  149. assert archive.photos == ["finish_y.jpg", "finish_z.jpg"]
  150. async def test_the_others_stay_deleted_with_their_money(async_client, db_session, dupes):
  151. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  152. for merged in (dupes.y, dupes.z):
  153. assert (await _row(db_session, PrintArchive, merged)).deleted_at is not None
  154. assert (await db_session.get(ArchiveAlias, merged)).archive_id == dupes.x
  155. assert (await _row(db_session, WalletTransaction, dupes.charge)).print_archive_id == dupes.y
  156. assert (await _row(db_session, PrintArchive, dupes.w)).deleted_at is None
  157. async def test_their_references_move(async_client, db_session, dupes):
  158. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  159. assert (await _row(db_session, PrintQueueItem, dupes.item)).archive_id == dupes.x
  160. assert (await _row(db_session, ProjectBOMItem, dupes.bom)).archive_id == dupes.x
  161. async def test_their_photos_and_videos_move_into_its_folder(async_client, db_session, dupes):
  162. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  163. assert (dupes.x_dir / "photos" / "finish_y.jpg").read_bytes() == b"y"
  164. assert (dupes.x_dir / "y.mp4").read_bytes() == b"yy"
  165. r2 = await _row(db_session, PrintLogEntry, dupes.r2)
  166. assert (r2.timelapse_path, r2.thumbnail_path) == ("archive/1/x/y.mp4", "archive/1/x/thumb.png")
  167. runs = await _runs(db_session, dupes.x)
  168. assert runs[2].timelapse_path == "archive/1/x/z.mp4"
  169. assert (await _row(db_session, PrintArchive, dupes.x)).timelapse_path == "archive/1/x/z.mp4"
  170. assert not dupes.y_dir.exists() and not dupes.z_dir.exists()
  171. async def test_a_merged_id_finds_the_archive(async_client, dupes):
  172. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  173. response = await async_client.get(f"/api/v1/archives/{dupes.y}")
  174. assert response.status_code == 200, response.text
  175. assert response.json()["id"] == dupes.x
  176. async def test_its_one_tap_link_still_answers_its_print(async_client, db_session, dupes):
  177. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  178. response = await async_client.post("/api/v1/archives/confirm/tok-y/good")
  179. assert response.status_code == 200, response.text
  180. assert (await _row(db_session, PrintLogEntry, dupes.r2)).user_verdict == "good"
  181. async def test_a_group_at_work_waits(async_client, db_session, dupes):
  182. db_session.add(PrintLogEntry(archive_id=dupes.y, printer_id=dupes.printer, status=RUN_OPEN, started_at=T1))
  183. await db_session.commit()
  184. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  185. assert group["skipped"] == "busy"
  186. response = await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  187. assert response.json() == {"merged": [], "skipped": [{"survivor_id": dupes.x, "reason": "busy"}]}
  188. assert (await _row(db_session, PrintArchive, dupes.y)).deleted_at is None
  189. async def test_a_group_just_printed_waits(async_client, db_session, dupes):
  190. """Its timelapse and finish photo may still be on their way."""
  191. from backend.app.utils.local_time import utcnow_naive
  192. run = await _row(db_session, PrintLogEntry, dupes.r2)
  193. run.completed_at = utcnow_naive() - timedelta(minutes=5)
  194. await db_session.commit()
  195. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  196. assert group["skipped"] == "busy"
  197. async def test_a_queued_group_waits(async_client, db_session, dupes):
  198. db_session.add(PrintQueueItem(printer_id=dupes.printer, archive_id=dupes.z, status="pending", position=2))
  199. await db_session.commit()
  200. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  201. assert group["skipped"] == "busy"
  202. async def test_a_survivor_printed_before_runs_keeps_its_print(async_client, db_session, dupes):
  203. await db_session.delete(await _row(db_session, PrintLogEntry, dupes.r1))
  204. await db_session.commit()
  205. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  206. runs = await _runs(db_session, dupes.x)
  207. assert [r.started_at for r in runs] == [T1, T1 + timedelta(days=1), T1 + timedelta(days=2)]
  208. async def test_a_failed_merge_leaves_everything(async_client, db_session, dupes):
  209. with patch("backend.app.services.print_log.mirror_latest_run", side_effect=RuntimeError("boom")):
  210. response = await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  211. assert response.json() == {"merged": [], "skipped": [{"survivor_id": dupes.x, "reason": "failed"}]}
  212. assert (await _row(db_session, PrintArchive, dupes.y)).deleted_at is None
  213. assert (await _row(db_session, PrintLogEntry, dupes.r2)).archive_id == dupes.y
  214. assert not (dupes.x_dir / "y.mp4").exists()
  215. assert (dupes.y_dir / "y.mp4").exists()
  216. async def test_merging_needs_delete_all(async_client, db_session, dupes):
  217. from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
  218. admin = await _admin_token(async_client)
  219. jwt, _ = await _user(async_client, admin, "editor", ["archives:read_all", "archives:update_all"])
  220. response = await async_client.post("/api/v1/archives/merge", headers=_auth(jwt), json={"survivor_ids": [dupes.x]})
  221. assert response.status_code == 403
  222. async def test_a_scoped_user_merges_only_archives_wholly_theirs(async_client, db_session, printer_factory, dupes):
  223. from backend.tests.integration.test_archives_last_per_printer import _admin_token, _auth, _user
  224. other = await printer_factory(name="B")
  225. db_session.add(PrintLogEntry(archive_id=dupes.z, printer_id=other.id, status="completed", started_at=T1))
  226. await db_session.commit()
  227. admin = await _admin_token(async_client)
  228. jwt, _ = await _user(
  229. async_client,
  230. admin,
  231. "scoped",
  232. ["archives:read_all", "archives:update_all", "archives:delete_all"],
  233. printer_ids=[dupes.printer],
  234. )
  235. [group] = (await async_client.get("/api/v1/archives/merge-preview", headers=_auth(jwt))).json()["groups"]
  236. assert [m["id"] for m in group["merged"]] == [dupes.y]
  237. async def test_a_group_tracked_by_spoolman_waits(async_client, db_session, dupes):
  238. from backend.app.models.active_print_spoolman import ActivePrintSpoolman
  239. db_session.add(ActivePrintSpoolman(printer_id=dupes.printer, archive_id=dupes.y, ams_trays={}))
  240. await db_session.commit()
  241. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  242. assert group["skipped"] == "busy"
  243. async def test_a_group_printing_waits(async_client, db_session, dupes):
  244. (await _row(db_session, PrintArchive, dupes.z)).status = "printing"
  245. await db_session.commit()
  246. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  247. assert group["skipped"] == "busy"
  248. async def test_an_archive_merged_later_passes_on_its_aliases(db_session, dupes):
  249. """Y went into X; X later goes into O: Y's id finds O in one step."""
  250. from backend.app.services.archive_merge import merge_group, resolve_alias
  251. assert await merge_group(db_session, dupes.x, [dupes.y]) is None
  252. # W takes X in, as a merge keeping W would (same file, same project now).
  253. w = await _row(db_session, PrintArchive, dupes.w)
  254. w.project_id = None
  255. await db_session.commit()
  256. assert await merge_group(db_session, dupes.w, [dupes.x]) is None
  257. assert await resolve_alias(db_session, dupes.y) == dupes.w
  258. assert await resolve_alias(db_session, dupes.x) == dupes.w
  259. async def test_a_video_only_its_archive_held_goes_with_its_print(async_client, db_session, dupes):
  260. """Y's print was logged before runs kept their own video: Y alone held it."""
  261. r2 = await _row(db_session, PrintLogEntry, dupes.r2)
  262. r2.timelapse_path, r2.photos = None, None
  263. await db_session.commit()
  264. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  265. assert (await _row(db_session, PrintLogEntry, dupes.r2)).timelapse_path == "archive/1/x/y.mp4"
  266. async def test_the_survivors_own_old_video_stays_with_its_print(async_client, db_session, files, dupes):
  267. (dupes.x_dir / "x.mp4").write_bytes(b"xx")
  268. (await _row(db_session, PrintArchive, dupes.x)).timelapse_path = "archive/1/x/x.mp4"
  269. await db_session.commit()
  270. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  271. assert (await _row(db_session, PrintLogEntry, dupes.r1)).timelapse_path == "archive/1/x/x.mp4"
  272. async def test_the_survivors_own_one_tap_link_answers_its_own_print(async_client, db_session, dupes):
  273. x = await _row(db_session, PrintArchive, dupes.x)
  274. x.confirm_requested, x.confirm_token = True, "tok-x"
  275. await db_session.commit()
  276. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  277. response = await async_client.post("/api/v1/archives/confirm/tok-x/reject")
  278. assert response.status_code == 200, response.text
  279. assert (await _row(db_session, PrintLogEntry, dupes.r1)).user_verdict == "reject"
  280. assert (await _row(db_session, PrintLogEntry, dupes.r2)).user_verdict is None
  281. async def test_a_folder_shared_with_the_survivor_stays(async_client, db_session, files, dupes):
  282. """Archived in the same second under the same name: one folder."""
  283. y = await _row(db_session, PrintArchive, dupes.y)
  284. y.file_path = "archive/1/x/file.3mf"
  285. await db_session.commit()
  286. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  287. assert (dupes.x_dir / "file.3mf").exists()
  288. # Nothing of the survivor's for a later purge of the merged row to remove.
  289. assert (await _row(db_session, PrintArchive, dupes.y)).file_path == ""
  290. async def test_a_folder_another_archive_uses_stays(async_client, db_session, files, dupes):
  291. other = PrintArchive(filename="o.3mf", file_path="archive/1/y/other.3mf", file_size=1, created_by_id=None)
  292. db_session.add(other)
  293. await db_session.commit()
  294. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  295. assert dupes.y_dir.exists()
  296. async def test_different_source_files_wait_for_the_user(async_client, db_session, files, dupes):
  297. for archive_id, name, content in ((dupes.x, "x", b"one"), (dupes.y, "y", b"two")):
  298. (files / "archive" / "1" / name / "src.3mf").write_bytes(content)
  299. (await _row(db_session, PrintArchive, archive_id)).source_3mf_path = f"archive/1/{name}/src.3mf"
  300. await db_session.commit()
  301. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  302. assert group["skipped"] == "conflicting_files"
  303. async def test_the_same_source_file_twice_merges(async_client, db_session, files, dupes):
  304. for archive_id, name in ((dupes.x, "x"), (dupes.y, "y")):
  305. (files / "archive" / "1" / name / "src.3mf").write_bytes(b"same")
  306. (await _row(db_session, PrintArchive, archive_id)).source_3mf_path = f"archive/1/{name}/src.3mf"
  307. await db_session.commit()
  308. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  309. assert group["skipped"] is None
  310. @pytest.mark.parametrize("held_by", ["dispatch", "active_print", "pipeline", "timelapse_job"])
  311. async def test_an_archive_held_by_work_in_flight_waits(async_client, db_session, dupes, held_by, monkeypatch):
  312. from backend.app import main as main_module
  313. from backend.app.services import archive_work, timelapse_jobs
  314. if held_by == "dispatch":
  315. archive_work.hold(dupes.y)
  316. elif held_by == "active_print":
  317. monkeypatch.setitem(main_module._active_prints, (dupes.printer, "part.3mf"), dupes.y)
  318. elif held_by == "pipeline":
  319. from backend.app.models.pipeline_run import PipelineRun
  320. db_session.add(PipelineRun(source_archive_id=dupes.y, status="slicing"))
  321. await db_session.commit()
  322. else:
  323. monkeypatch.setitem(timelapse_jobs._jobs, dupes.y, SimpleNamespace(status=timelapse_jobs.RUNNING))
  324. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  325. assert group["skipped"] == "busy"
  326. async def test_a_held_archive_is_left_alone_even_after_the_preview(db_session, dupes):
  327. """Held between the group search and its merge: a dispatch chose it."""
  328. from backend.app.services import archive_merge, archive_work
  329. real_find = archive_merge.find_groups
  330. async def find_then_hold(db, visible):
  331. groups = await real_find(db, visible)
  332. archive_work.hold(dupes.y)
  333. return groups
  334. with patch.object(archive_merge, "find_groups", find_then_hold):
  335. merged, skipped = await archive_merge.merge(db_session, [dupes.x], [])
  336. assert (merged, skipped) == ([], [{"survivor_id": dupes.x, "reason": "busy"}])
  337. async def test_an_edit_made_after_the_preview_is_kept(db_session, test_engine, dupes):
  338. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  339. from backend.app.services.archive_merge import merge_group
  340. x = await _row(db_session, PrintArchive, dupes.x)
  341. assert x.notes == "first"
  342. async with async_sessionmaker(test_engine, class_=AsyncSession)() as other:
  343. (await other.get(PrintArchive, dupes.x)).notes = "edited"
  344. await other.commit()
  345. assert await merge_group(db_session, dupes.x, [dupes.y, dupes.z]) is None
  346. assert (await _row(db_session, PrintArchive, dupes.x)).notes == "edited\n\nsecond"
  347. async def test_a_dispatch_leaves_an_archive_being_merged(db_session, dupes):
  348. from backend.app.services import archive_work
  349. with archive_work.merging([dupes.y]) as marked:
  350. assert marked
  351. with archive_work.merging([dupes.y]) as again:
  352. assert not again
  353. async def test_a_group_that_got_to_work_after_the_preview_waits(db_session, dupes):
  354. """Queued between the group search and its merge."""
  355. from backend.app.services import archive_merge
  356. real_find = archive_merge.find_groups
  357. async def find_then_queue(db, visible):
  358. groups = await real_find(db, visible)
  359. db.add(PrintQueueItem(printer_id=dupes.printer, archive_id=dupes.z, status="pending", position=3))
  360. await db.commit()
  361. return groups
  362. with patch.object(archive_merge, "find_groups", find_then_queue):
  363. merged, skipped = await archive_merge.merge(db_session, [dupes.x], [])
  364. assert (merged, skipped) == ([], [{"survivor_id": dupes.x, "reason": "busy"}])
  365. async def test_a_failing_group_leaves_the_next_to_merge(db_session, files, dupes):
  366. from backend.app.services import archive_merge
  367. first = PrintArchive(filename="o.3mf", file_path="archive/1/o1/file.3mf", file_size=1, content_hash="b" * 64)
  368. second = PrintArchive(filename="o.3mf", file_path="archive/1/o2/file.3mf", file_size=1, content_hash="b" * 64)
  369. db_session.add_all([first, second])
  370. await db_session.commit()
  371. first_id, second_id = first.id, second.id
  372. real = archive_merge._merge
  373. calls = []
  374. async def fail_the_first(db, survivor_id, merged_ids, files_):
  375. calls.append(survivor_id)
  376. if len(calls) == 1:
  377. await db.flush()
  378. raise RuntimeError("boom")
  379. return await real(db, survivor_id, merged_ids, files_)
  380. with patch.object(archive_merge, "_merge", fail_the_first):
  381. merged, skipped = await archive_merge.merge(db_session, [dupes.x, first_id], [])
  382. assert skipped == [{"survivor_id": dupes.x, "reason": "failed"}]
  383. assert merged == [{"survivor_id": first_id, "merged_ids": [second_id]}]
  384. async def test_a_video_attached_by_hand_to_an_unprinted_archive_stays(async_client, db_session, files, dupes):
  385. """Y was uploaded, never printed, and given a video by hand: the group's
  386. only video, so the archive keeps it."""
  387. await db_session.delete(await _row(db_session, PrintLogEntry, dupes.r2))
  388. await db_session.commit()
  389. for archive_id in (dupes.y, dupes.z):
  390. (await _row(db_session, PrintArchive, archive_id)).status = "archived"
  391. await db_session.commit()
  392. (await _row(db_session, PrintArchive, dupes.z)).timelapse_path = None
  393. await db_session.commit()
  394. response = await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  395. assert response.json()["skipped"] == [], response.text
  396. assert (await _row(db_session, PrintArchive, dupes.x)).timelapse_path == "archive/1/x/y.mp4"
  397. assert (dupes.x_dir / "y.mp4").exists()
  398. async def test_a_video_attached_by_hand_beside_another_waits(async_client, db_session, files, dupes):
  399. """Z's print brings its own video: the archive could show only one."""
  400. await db_session.delete(await _row(db_session, PrintLogEntry, dupes.r2))
  401. await db_session.commit()
  402. (await _row(db_session, PrintArchive, dupes.y)).status = "archived"
  403. await db_session.commit()
  404. [group] = (await async_client.get("/api/v1/archives/merge-preview")).json()["groups"]
  405. response = await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  406. assert group["skipped"] == "conflicting_files"
  407. assert response.json()["skipped"] == [{"survivor_id": dupes.x, "reason": "conflicting_files"}]
  408. async def test_the_survivors_lost_file_comes_from_a_duplicate(async_client, db_session, files, dupes):
  409. (dupes.x_dir / "file.3mf").unlink()
  410. await async_client.post("/api/v1/archives/merge", json={"survivor_ids": [dupes.x]})
  411. archive = await _row(db_session, PrintArchive, dupes.x)
  412. assert (files / archive.file_path).read_bytes() == b"3mf"
  413. async def test_a_copy_that_fails_leaves_no_partial_file(files):
  414. from backend.app.services.archive_merge import _Files
  415. source = files / "big.mp4"
  416. source.write_bytes(b"x" * 100)
  417. target_dir = files / "target"
  418. def partial_copy(src, dst):
  419. Path(dst).write_bytes(b"x")
  420. raise OSError("disk full")
  421. with (
  422. patch("backend.app.services.archive_merge.os.link", side_effect=OSError("cross-device")),
  423. patch("backend.app.services.archive_merge.shutil.copy2", side_effect=partial_copy),
  424. pytest.raises(OSError),
  425. ):
  426. _Files().place(source, target_dir)
  427. assert list(target_dir.iterdir()) == []
  428. async def test_an_avi_conversion_holds_its_archive_from_the_spawn(db_session, files, dupes):
  429. from backend.app.services import archive_work
  430. from backend.app.services.archive import ArchiveService
  431. spawned = []
  432. with (
  433. patch("backend.app.services.archive.spawn_background_task", side_effect=lambda c, **_: spawned.append(c)),
  434. patch("backend.app.services.archive._announce_timelapse"),
  435. ):
  436. assert await ArchiveService(db_session).attach_timelapse(dupes.x, b"avi", "v.avi")
  437. assert archive_work.is_held(dupes.x)
  438. for coro in spawned:
  439. coro.close()