test_archives_api.py 75 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837
  1. """Integration tests for Archives API endpoints.
  2. Tests the full request/response cycle for /api/v1/archives/ endpoints.
  3. """
  4. from pathlib import Path
  5. import pytest
  6. from httpx import AsyncClient
  7. class TestArchivesAPI:
  8. """Integration tests for /api/v1/archives/ endpoints."""
  9. # ========================================================================
  10. # List endpoints
  11. # ========================================================================
  12. @pytest.mark.asyncio
  13. @pytest.mark.integration
  14. async def test_list_archives_empty(self, async_client: AsyncClient):
  15. """Verify empty list is returned when no archives exist."""
  16. response = await async_client.get("/api/v1/archives/")
  17. assert response.status_code == 200
  18. data = response.json()
  19. assert isinstance(data, list)
  20. assert len(data) == 0
  21. @pytest.mark.asyncio
  22. @pytest.mark.integration
  23. async def test_list_archives_with_data(
  24. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  25. ):
  26. """Verify list returns existing archives."""
  27. printer = await printer_factory()
  28. await archive_factory(printer.id, print_name="Test Archive")
  29. response = await async_client.get("/api/v1/archives/")
  30. assert response.status_code == 200
  31. data = response.json()
  32. assert isinstance(data, list)
  33. assert len(data) >= 1
  34. assert any(a["print_name"] == "Test Archive" for a in data)
  35. @pytest.mark.asyncio
  36. @pytest.mark.integration
  37. async def test_list_archives_pagination(
  38. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  39. ):
  40. """Verify pagination works correctly."""
  41. printer = await printer_factory()
  42. # Create 5 archives
  43. for i in range(5):
  44. await archive_factory(printer.id, print_name=f"Archive {i}")
  45. # Get first page with limit 2
  46. response = await async_client.get("/api/v1/archives/?limit=2&offset=0")
  47. assert response.status_code == 200
  48. data = response.json()
  49. assert isinstance(data, list)
  50. assert len(data) == 2
  51. @pytest.mark.asyncio
  52. @pytest.mark.integration
  53. async def test_list_archives_filter_by_printer(
  54. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  55. ):
  56. """Verify filtering by printer_id works."""
  57. printer1 = await printer_factory(name="Printer 1", serial_number="00M09A000000001")
  58. printer2 = await printer_factory(name="Printer 2", serial_number="00M09A000000002")
  59. await archive_factory(printer1.id, print_name="Printer 1 Archive")
  60. await archive_factory(printer2.id, print_name="Printer 2 Archive")
  61. response = await async_client.get(f"/api/v1/archives/?printer_id={printer1.id}")
  62. assert response.status_code == 200
  63. data = response.json()
  64. assert all(a["printer_id"] == printer1.id for a in data)
  65. # ========================================================================
  66. # Get single endpoint
  67. # ========================================================================
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  71. """Verify single archive can be retrieved."""
  72. printer = await printer_factory()
  73. archive = await archive_factory(printer.id, print_name="Get Test Archive")
  74. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  75. assert response.status_code == 200
  76. result = response.json()
  77. assert result["id"] == archive.id
  78. assert result["print_name"] == "Get Test Archive"
  79. @pytest.mark.asyncio
  80. @pytest.mark.integration
  81. async def test_get_archive_not_found(self, async_client: AsyncClient):
  82. """Verify 404 for non-existent archive."""
  83. response = await async_client.get("/api/v1/archives/9999")
  84. assert response.status_code == 404
  85. # ========================================================================
  86. # Update endpoints
  87. # ========================================================================
  88. @pytest.mark.asyncio
  89. @pytest.mark.integration
  90. async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  91. """Verify archive name can be updated."""
  92. printer = await printer_factory()
  93. archive = await archive_factory(printer.id, print_name="Original Name")
  94. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"print_name": "Updated Name"})
  95. assert response.status_code == 200
  96. assert response.json()["print_name"] == "Updated Name"
  97. @pytest.mark.asyncio
  98. @pytest.mark.integration
  99. async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  100. """Verify archive notes can be updated."""
  101. printer = await printer_factory()
  102. archive = await archive_factory(printer.id)
  103. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Great print!"})
  104. assert response.status_code == 200
  105. assert response.json()["notes"] == "Great print!"
  106. @pytest.mark.asyncio
  107. @pytest.mark.integration
  108. async def test_update_archive_favorite(
  109. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  110. ):
  111. """Verify archive favorite status can be updated."""
  112. printer = await printer_factory()
  113. archive = await archive_factory(printer.id)
  114. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"is_favorite": True})
  115. assert response.status_code == 200
  116. assert response.json()["is_favorite"] is True
  117. @pytest.mark.asyncio
  118. @pytest.mark.integration
  119. async def test_update_archive_external_url(
  120. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  121. ):
  122. """Verify archive external_url can be updated."""
  123. printer = await printer_factory()
  124. archive = await archive_factory(printer.id)
  125. response = await async_client.patch(
  126. f"/api/v1/archives/{archive.id}", json={"external_url": "https://printables.com/model/12345"}
  127. )
  128. assert response.status_code == 200
  129. assert response.json()["external_url"] == "https://printables.com/model/12345"
  130. # Verify it can be cleared
  131. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"external_url": None})
  132. assert response.status_code == 200
  133. assert response.json()["external_url"] is None
  134. @pytest.mark.asyncio
  135. @pytest.mark.integration
  136. async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
  137. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  138. ):
  139. """#1444: PATCH /archives/{id} with failure_reason must mirror to the
  140. latest PrintLogEntry so the Stats page Failure Analysis widget
  141. (which reads PrintLogEntry.failure_reason) reflects the user's
  142. reclassification instead of showing "Unknown" forever.
  143. """
  144. from sqlalchemy import select
  145. from backend.app.models.print_log import PrintLogEntry
  146. printer = await printer_factory()
  147. # archive_factory auto-creates a matching PrintLogEntry (failure_reason
  148. # carried from the archive, which is NULL here — same shape as the bug
  149. # repro: print completed → log entry written with NULL → user goes to
  150. # classify the failure afterwards).
  151. archive = await archive_factory(printer.id, print_name="Failed Print", status="failed", run_status="failed")
  152. response = await async_client.patch(
  153. f"/api/v1/archives/{archive.id}",
  154. json={"failure_reason": "Adhesion failure"},
  155. )
  156. assert response.status_code == 200
  157. result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  158. mirrored = result.scalar_one()
  159. assert mirrored.failure_reason == "Adhesion failure"
  160. @pytest.mark.asyncio
  161. @pytest.mark.integration
  162. async def test_update_archive_status_mirrors_to_print_log_entry(
  163. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  164. ):
  165. """#1444: PATCH /archives/{id} with status must mirror to the latest
  166. PrintLogEntry so stats that filter on PrintLogEntry.status see the
  167. user's reclassification.
  168. """
  169. from sqlalchemy import select
  170. from backend.app.models.print_log import PrintLogEntry
  171. printer = await printer_factory()
  172. archive = await archive_factory(printer.id, run_status="completed")
  173. response = await async_client.patch(
  174. f"/api/v1/archives/{archive.id}",
  175. json={"status": "failed"},
  176. )
  177. assert response.status_code == 200
  178. result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  179. mirrored = result.scalar_one()
  180. assert mirrored.status == "failed"
  181. @pytest.mark.asyncio
  182. @pytest.mark.integration
  183. async def test_update_archive_failure_reason_only_touches_latest_entry(
  184. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  185. ):
  186. """#1444: For an archive with multiple runs (reprints), only the
  187. latest PrintLogEntry should receive the reclassification. Earlier
  188. runs were classified at their own time and must not be retroactively
  189. overwritten.
  190. """
  191. from backend.app.models.print_log import PrintLogEntry
  192. printer = await printer_factory()
  193. # First run — created by the factory's auto-run with its own reason.
  194. archive = await archive_factory(printer.id, status="failed", run_status="failed")
  195. from sqlalchemy import select
  196. first_run = (
  197. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  198. ).scalar_one()
  199. first_run.failure_reason = "Filament tangle"
  200. await db_session.commit()
  201. # Second run — the reprint that just finished with NULL classification.
  202. latest_run = PrintLogEntry(archive_id=archive.id, status="failed", failure_reason=None)
  203. db_session.add(latest_run)
  204. await db_session.commit()
  205. response = await async_client.patch(
  206. f"/api/v1/archives/{archive.id}",
  207. json={"failure_reason": "Adhesion failure"},
  208. )
  209. assert response.status_code == 200
  210. await db_session.refresh(first_run)
  211. await db_session.refresh(latest_run)
  212. assert first_run.failure_reason == "Filament tangle"
  213. assert latest_run.failure_reason == "Adhesion failure"
  214. # ========================================================================
  215. # Delete endpoints
  216. # ========================================================================
  217. @pytest.mark.asyncio
  218. @pytest.mark.integration
  219. async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  220. """Verify archive can be deleted."""
  221. printer = await printer_factory()
  222. archive = await archive_factory(printer.id)
  223. archive_id = archive.id
  224. response = await async_client.delete(f"/api/v1/archives/{archive_id}")
  225. assert response.status_code == 200
  226. # Verify deleted
  227. response = await async_client.get(f"/api/v1/archives/{archive_id}")
  228. assert response.status_code == 404
  229. @pytest.mark.asyncio
  230. @pytest.mark.integration
  231. async def test_delete_nonexistent_archive(self, async_client: AsyncClient):
  232. """Verify deleting non-existent archive returns 404."""
  233. response = await async_client.delete("/api/v1/archives/9999")
  234. assert response.status_code == 404
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_delete_archive_blocked_when_related_queue_item_printing(
  238. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  239. ):
  240. """#1734: archive delete must 409 when a related queue item is currently
  241. mid-print — deleting the archive would strip the dispatcher's metadata
  242. trail (filament / plate / ams_mapping) out from under the running print.
  243. Both soft and hard delete are gated by the same precondition.
  244. """
  245. from backend.app.models.print_queue import PrintQueueItem
  246. printer = await printer_factory()
  247. archive = await archive_factory(printer.id)
  248. db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=1))
  249. await db_session.commit()
  250. soft = await async_client.delete(f"/api/v1/archives/{archive.id}")
  251. assert soft.status_code == 409
  252. assert "printing" in soft.json()["detail"].lower()
  253. hard = await async_client.delete(f"/api/v1/archives/{archive.id}?purge_stats=true")
  254. assert hard.status_code == 409
  255. @pytest.mark.asyncio
  256. @pytest.mark.integration
  257. async def test_archive_delete_impact_reports_counts(
  258. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  259. ):
  260. """#1734: the delete-impact pre-flight endpoint reports the total
  261. number of related queue items AND how many are currently printing,
  262. so the frontend can both warn the user before they confirm AND
  263. disable the confirm button when the printing count is non-zero.
  264. """
  265. from backend.app.models.print_queue import PrintQueueItem
  266. printer = await printer_factory()
  267. archive = await archive_factory(printer.id)
  268. # Build a mixed-status set the way a Send All upload + later in-flight
  269. # dispatch looks at the wire (#1733).
  270. db_session.add_all(
  271. [
  272. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=1),
  273. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=2),
  274. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=3),
  275. ]
  276. )
  277. # An unrelated archive's queue rows must not bleed into the count.
  278. other = await archive_factory(printer.id)
  279. db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=other.id, status="pending", position=4))
  280. await db_session.commit()
  281. resp = await async_client.get(f"/api/v1/archives/{archive.id}/delete-impact")
  282. assert resp.status_code == 200
  283. body = resp.json()
  284. assert body["related_queue_items"] == 3
  285. assert body["currently_printing"] == 1
  286. @pytest.mark.asyncio
  287. @pytest.mark.integration
  288. async def test_soft_delete_preserves_stats_contribution(
  289. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  290. ):
  291. """#1343: deleting an archive without ``purge_stats`` keeps its
  292. contribution in Quick Stats. The row vanishes from listings but the
  293. filament / time / cost totals stay intact.
  294. """
  295. printer = await printer_factory()
  296. await archive_factory(
  297. printer.id,
  298. status="completed",
  299. print_time_seconds=3600,
  300. filament_used_grams=50.0,
  301. cost=1.50,
  302. )
  303. archive_to_delete = await archive_factory(
  304. printer.id,
  305. status="completed",
  306. print_time_seconds=7200,
  307. filament_used_grams=100.0,
  308. cost=3.00,
  309. )
  310. # Pre-delete: stats include both archives.
  311. pre = (await async_client.get("/api/v1/archives/stats")).json()
  312. assert pre["total_prints"] == 2
  313. assert pre["total_filament_grams"] == 150.0
  314. assert pre["total_cost"] == 4.50
  315. # Soft delete (default — no purge_stats param).
  316. resp = await async_client.delete(f"/api/v1/archives/{archive_to_delete.id}")
  317. assert resp.status_code == 200
  318. body = resp.json()
  319. assert body["purged_from_stats"] is False
  320. # Listing hides the deleted archive…
  321. listing = (await async_client.get("/api/v1/archives/")).json()
  322. assert all(a["id"] != archive_to_delete.id for a in listing)
  323. # …but stats still reflect both prints (the whole point of #1343).
  324. post = (await async_client.get("/api/v1/archives/stats")).json()
  325. assert post["total_prints"] == 2
  326. assert post["total_filament_grams"] == 150.0
  327. assert post["total_cost"] == 4.50
  328. @pytest.mark.asyncio
  329. @pytest.mark.integration
  330. async def test_soft_delete_clears_thumbnail_path_on_linked_log_entries(
  331. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  332. ):
  333. """#1348 follow-up: soft-deleting an archive removes its files from disk;
  334. the cached thumbnail_path on linked PrintLogEntry rows must be NULLed
  335. in the same transaction so the print-log view doesn't 404-storm on the
  336. now-deleted thumbnail file."""
  337. from sqlalchemy import select
  338. from backend.app.models.print_log import PrintLogEntry
  339. printer = await printer_factory()
  340. archive = await archive_factory(
  341. printer.id,
  342. status="completed",
  343. thumbnail_path="archives/test/test_print/thumbnail.png",
  344. )
  345. # The factory's auto-PrintLogEntry doesn't copy thumbnail_path; set it
  346. # manually to mirror what the production write_log_entry path stores.
  347. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  348. run = run_query.scalar_one()
  349. run.thumbnail_path = "archives/test/test_print/thumbnail.png"
  350. await db_session.commit()
  351. assert run.thumbnail_path is not None
  352. resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
  353. assert resp.status_code == 200
  354. assert resp.json()["purged_from_stats"] is False
  355. await db_session.refresh(run)
  356. assert run.thumbnail_path is None, "soft-delete must NULL thumbnail_path on linked log entry"
  357. # The log entry itself survives the soft delete (its filament/cost
  358. # contribution still needs to flow into stats per #1343).
  359. assert run.id is not None
  360. assert run.archive_id == archive.id
  361. @pytest.mark.asyncio
  362. @pytest.mark.integration
  363. async def test_hard_delete_clears_thumbnail_path_before_fk_cascade(
  364. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  365. ):
  366. """#1348 follow-up: the auto-purge sweeper (and any caller of
  367. ArchiveService.delete_archive) hard-deletes the archive row but leaves
  368. PrintLogEntry rows alive via ON DELETE SET NULL. The eager
  369. thumbnail_path clear must run inside delete_archive so even orphaned
  370. log entries don't surface stale paths."""
  371. from sqlalchemy import select
  372. from backend.app.models.print_log import PrintLogEntry
  373. from backend.app.services.archive import ArchiveService
  374. printer = await printer_factory()
  375. archive = await archive_factory(
  376. printer.id,
  377. status="completed",
  378. thumbnail_path="archives/test/test_print/thumbnail.png",
  379. )
  380. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  381. run = run_query.scalar_one()
  382. run.thumbnail_path = "archives/test/test_print/thumbnail.png"
  383. await db_session.commit()
  384. run_id = run.id
  385. service = ArchiveService(db_session)
  386. assert await service.delete_archive(archive.id) is True
  387. # Log entry survives the hard-delete (the FK is ON DELETE SET NULL
  388. # in production; SQLite test config doesn't enable foreign_keys=ON
  389. # by default so archive_id may still be set, but the row itself
  390. # remains for audit). The thumbnail_path was cleared eagerly by
  391. # _null_print_log_thumbnail_paths before db.delete(archive).
  392. refetch = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.id == run_id))
  393. survivor = refetch.scalar_one()
  394. assert survivor.thumbnail_path is None, (
  395. "delete_archive must NULL thumbnail_path before removing the archive row"
  396. )
  397. @pytest.mark.asyncio
  398. @pytest.mark.integration
  399. async def test_print_log_thumbnail_route_lazy_nulls_missing_file(
  400. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  401. ):
  402. """#1348 follow-up: GET /print-log/{id}/thumbnail self-heals when the
  403. thumbnail_path on a log entry points at a missing file (failed print
  404. whose thumbnail was never written, or a stale path that escaped the
  405. delete-time cleanup)."""
  406. from sqlalchemy import select
  407. from backend.app.models.print_log import PrintLogEntry
  408. printer = await printer_factory()
  409. archive = await archive_factory(printer.id, status="failed")
  410. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  411. run = run_query.scalar_one()
  412. # Path points at a file that never existed (failed-print case where
  413. # archive.thumbnail_path was set but the extractor never produced one).
  414. run.thumbnail_path = "archives/missing/never_written/thumbnail.png"
  415. await db_session.commit()
  416. # Auth is disabled in the integration test config, so the stream-token
  417. # guard is bypassed — the route runs the lazy-NULL branch directly.
  418. resp = await async_client.get(f"/api/v1/print-log/{run.id}/thumbnail")
  419. assert resp.status_code == 404
  420. await db_session.refresh(run)
  421. assert run.thumbnail_path is None, "missing file must self-heal to NULL"
  422. @pytest.mark.asyncio
  423. @pytest.mark.integration
  424. async def test_purge_stats_drops_archive_from_quick_stats(
  425. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  426. ):
  427. """#1343: deleting with ``?purge_stats=true`` hard-deletes the row,
  428. dropping its contribution from Quick Stats (the original behaviour,
  429. now opt-in)."""
  430. printer = await printer_factory()
  431. keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
  432. purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
  433. resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
  434. assert resp.status_code == 200
  435. assert resp.json()["purged_from_stats"] is True
  436. stats = (await async_client.get("/api/v1/archives/stats")).json()
  437. assert stats["total_prints"] == 1
  438. assert stats["total_filament_grams"] == 50.0
  439. # The kept archive is still listed.
  440. listing = (await async_client.get("/api/v1/archives/")).json()
  441. assert [a["id"] for a in listing] == [keep.id]
  442. @pytest.mark.asyncio
  443. @pytest.mark.integration
  444. async def test_soft_deleted_archive_404_on_detail(
  445. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  446. ):
  447. """A soft-deleted archive must 404 on GET — a stale bookmark or
  448. direct URL should not expose a row the user has already removed."""
  449. printer = await printer_factory()
  450. archive = await archive_factory(printer.id)
  451. await async_client.delete(f"/api/v1/archives/{archive.id}")
  452. resp = await async_client.get(f"/api/v1/archives/{archive.id}")
  453. assert resp.status_code == 404
  454. @pytest.mark.asyncio
  455. @pytest.mark.integration
  456. async def test_soft_deleted_archive_hidden_from_search(
  457. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  458. ):
  459. """Search must skip soft-deleted archives. Uses the LIKE fallback by
  460. querying a single-character pattern that the SQLite FTS5 rejects, so
  461. the test covers the fallback path that the production FTS path also
  462. respects."""
  463. printer = await printer_factory()
  464. archive = await archive_factory(printer.id, print_name="UniqueSoftDeleteCandidate")
  465. await async_client.delete(f"/api/v1/archives/{archive.id}")
  466. resp = await async_client.get("/api/v1/archives/search?q=UniqueSoftDeleteCandidate")
  467. assert resp.status_code == 200
  468. assert resp.json() == []
  469. # ========================================================================
  470. # Statistics endpoints
  471. # ========================================================================
  472. @pytest.mark.asyncio
  473. @pytest.mark.integration
  474. async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  475. """Verify archive statistics can be retrieved."""
  476. printer = await printer_factory()
  477. await archive_factory(
  478. printer.id,
  479. status="completed",
  480. print_time_seconds=3600,
  481. filament_used_grams=50.0,
  482. )
  483. await archive_factory(
  484. printer.id,
  485. status="completed",
  486. print_time_seconds=7200,
  487. filament_used_grams=100.0,
  488. )
  489. response = await async_client.get("/api/v1/archives/stats")
  490. assert response.status_code == 200
  491. result = response.json()
  492. # Check for actual stats fields
  493. assert "total_prints" in result
  494. assert "successful_prints" in result
  495. class TestNo3MFWarning:
  496. """`GET /archives/no-3mf-warning` — install step 4 reactive nudge.
  497. The connection diagnostic's external_storage check only catches the
  498. printer-side variant of the setting (newer firmware). For older slicers
  499. where the toggle lives only in BambuStudio, the printer never reports
  500. it. The fallback path in main.py creates the archive with
  501. extra_data.no_3mf_available=True; this endpoint exposes that as a
  502. boolean so the frontend can surface a one-time banner.
  503. """
  504. @pytest.mark.asyncio
  505. @pytest.mark.integration
  506. async def test_returns_true_when_recent_fallback_exists(
  507. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  508. ):
  509. printer = await printer_factory()
  510. await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  511. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  512. assert response.status_code == 200
  513. assert response.json() == {"has_fallback": True}
  514. @pytest.mark.asyncio
  515. @pytest.mark.integration
  516. async def test_returns_false_when_no_archives(self, async_client: AsyncClient):
  517. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  518. assert response.status_code == 200
  519. assert response.json() == {"has_fallback": False}
  520. @pytest.mark.asyncio
  521. @pytest.mark.integration
  522. async def test_returns_false_when_only_normal_archives(
  523. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  524. ):
  525. printer = await printer_factory()
  526. # extra_data has other keys but no_3mf_available is absent — normal
  527. # archives must not trigger the nudge.
  528. await archive_factory(printer.id, extra_data={"makerworld_url": "https://example"})
  529. await archive_factory(printer.id, extra_data=None)
  530. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  531. assert response.status_code == 200
  532. assert response.json() == {"has_fallback": False}
  533. @pytest.mark.asyncio
  534. @pytest.mark.integration
  535. async def test_ignores_archives_older_than_30_days(
  536. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  537. ):
  538. from datetime import datetime, timedelta, timezone
  539. from backend.app.models.archive import PrintArchive
  540. printer = await printer_factory()
  541. archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  542. # Backdate past the 30-day window — old fallbacks are forgiven.
  543. archive.created_at = datetime.now(timezone.utc) - timedelta(days=45)
  544. await db_session.commit()
  545. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  546. assert response.status_code == 200
  547. assert response.json() == {"has_fallback": False}
  548. # Sanity: row really is in the DB, we just don't surface it.
  549. assert (await db_session.get(PrintArchive, archive.id)) is not None
  550. @pytest.mark.asyncio
  551. @pytest.mark.integration
  552. async def test_ignores_soft_deleted_fallbacks(
  553. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  554. ):
  555. from datetime import datetime, timezone
  556. printer = await printer_factory()
  557. archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  558. archive.deleted_at = datetime.now(timezone.utc)
  559. await db_session.commit()
  560. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  561. assert response.status_code == 200
  562. # Soft-deleted fallbacks have been actioned (user clearing the
  563. # evidence). Stop nudging.
  564. assert response.json() == {"has_fallback": False}
  565. class TestPrintLogEntryDelete:
  566. """#1687: per-row delete on the Print Log page.
  567. Pin the route's three contracts: (1) deleting a row drops its filament
  568. / cost / count contribution from /archives/stats in the same response
  569. cycle; (2) the matching archive (if any) is untouched; (3) missing IDs
  570. return 404 rather than 200-silently.
  571. """
  572. @pytest.mark.asyncio
  573. @pytest.mark.integration
  574. async def test_delete_print_log_entry_drops_from_stats(
  575. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  576. ):
  577. from sqlalchemy import select
  578. from backend.app.models.print_log import PrintLogEntry
  579. printer = await printer_factory()
  580. keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
  581. drop = await archive_factory(printer.id, status="completed", filament_used_grams=125.0)
  582. drop_run = (
  583. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == drop.id))
  584. ).scalar_one()
  585. resp = await async_client.delete(f"/api/v1/print-log/{drop_run.id}")
  586. assert resp.status_code == 200
  587. assert resp.json()["status"] == "deleted"
  588. assert resp.json()["id"] == drop_run.id
  589. # The linked archive survives — the row was a stats row, not the archive.
  590. listing = (await async_client.get("/api/v1/archives/")).json()
  591. assert {a["id"] for a in listing} == {keep.id, drop.id}
  592. # /stats no longer counts the dropped run's filament contribution.
  593. stats = (await async_client.get("/api/v1/archives/stats")).json()
  594. assert stats["total_prints"] == 1
  595. assert stats["total_filament_grams"] == 50.0
  596. @pytest.mark.asyncio
  597. @pytest.mark.integration
  598. async def test_delete_print_log_entry_404_when_missing(self, async_client: AsyncClient):
  599. resp = await async_client.delete("/api/v1/print-log/999999")
  600. assert resp.status_code == 404
  601. @pytest.mark.asyncio
  602. @pytest.mark.integration
  603. async def test_delete_print_log_entry_does_not_clear_others(
  604. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  605. ):
  606. """Deleting one row must not touch siblings — guard against an accidental
  607. ``delete(PrintLogEntry)`` without a ``where`` clause (cf. clear_print_log
  608. which intentionally drops everything)."""
  609. from sqlalchemy import select
  610. from backend.app.models.print_log import PrintLogEntry
  611. printer = await printer_factory()
  612. a = await archive_factory(printer.id, status="completed", filament_used_grams=10.0)
  613. b = await archive_factory(printer.id, status="completed", filament_used_grams=20.0)
  614. c = await archive_factory(printer.id, status="completed", filament_used_grams=30.0)
  615. runs = {r.archive_id: r for r in (await db_session.execute(select(PrintLogEntry))).scalars().all()}
  616. resp = await async_client.delete(f"/api/v1/print-log/{runs[b.id].id}")
  617. assert resp.status_code == 200
  618. survivors = (await db_session.execute(select(PrintLogEntry.archive_id))).scalars().all()
  619. assert set(survivors) == {a.id, c.id}
  620. class TestPrintLogEntryUpdate:
  621. """Tests for ``PATCH /print-log/{entry_id}`` (#1687 part 4).
  622. Pin the route's contracts: (1) GET serialiser actually surfaces
  623. ``failure_reason`` (previously it was silently dropped from the response
  624. even when set in the DB); (2) PATCH persists ``failure_reason`` and
  625. ``status``; (3) unknown vocabulary returns 400 rather than getting stored
  626. as raw garbage; (4) missing IDs return 404.
  627. """
  628. @pytest.mark.asyncio
  629. @pytest.mark.integration
  630. async def test_get_surfaces_failure_reason(
  631. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  632. ):
  633. """Pre-fix the GET endpoint built PrintLogEntrySchema without
  634. ``failure_reason`` even though the column was populated, so the Print
  635. Log table couldn't render what the Failure Analysis widget already
  636. groups by. Regression guard for the silent-drop bug.
  637. """
  638. from sqlalchemy import select
  639. from backend.app.models.print_log import PrintLogEntry
  640. printer = await printer_factory()
  641. archive = await archive_factory(printer.id, status="failed")
  642. entry = (
  643. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  644. ).scalar_one()
  645. entry.failure_reason = "spaghettiDetached"
  646. await db_session.commit()
  647. body = (await async_client.get("/api/v1/print-log/")).json()
  648. match = next(item for item in body["items"] if item["id"] == entry.id)
  649. assert match["failure_reason"] == "spaghettiDetached"
  650. # archive_id should also flow through so the frontend can tell orphan
  651. # entries apart from archive-linked ones.
  652. assert match["archive_id"] == archive.id
  653. @pytest.mark.asyncio
  654. @pytest.mark.integration
  655. async def test_patch_sets_failure_reason(
  656. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  657. ):
  658. from sqlalchemy import select
  659. from backend.app.models.print_log import PrintLogEntry
  660. printer = await printer_factory()
  661. archive = await archive_factory(printer.id, status="failed")
  662. entry = (
  663. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  664. ).scalar_one()
  665. assert entry.failure_reason is None
  666. resp = await async_client.patch(
  667. f"/api/v1/print-log/{entry.id}",
  668. json={"failure_reason": "cloggedNozzle"},
  669. )
  670. assert resp.status_code == 200
  671. assert resp.json()["failure_reason"] == "cloggedNozzle"
  672. await db_session.refresh(entry)
  673. assert entry.failure_reason == "cloggedNozzle"
  674. @pytest.mark.asyncio
  675. @pytest.mark.integration
  676. async def test_patch_can_clear_failure_reason(
  677. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  678. ):
  679. """Empty-string failure_reason stores back as NULL (the column's
  680. nullable=True intent is preserved end-to-end)."""
  681. from sqlalchemy import select
  682. from backend.app.models.print_log import PrintLogEntry
  683. printer = await printer_factory()
  684. archive = await archive_factory(printer.id, status="failed")
  685. entry = (
  686. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  687. ).scalar_one()
  688. entry.failure_reason = "warping"
  689. await db_session.commit()
  690. resp = await async_client.patch(
  691. f"/api/v1/print-log/{entry.id}",
  692. json={"failure_reason": ""},
  693. )
  694. assert resp.status_code == 200
  695. assert resp.json()["failure_reason"] is None
  696. await db_session.refresh(entry)
  697. assert entry.failure_reason is None
  698. @pytest.mark.asyncio
  699. @pytest.mark.integration
  700. async def test_patch_rejects_unknown_failure_reason(
  701. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  702. ):
  703. """Unknown values must 400 — otherwise the UI would render raw garbage
  704. because the i18n layer maps the value back through the canonical
  705. vocabulary."""
  706. from sqlalchemy import select
  707. from backend.app.models.print_log import PrintLogEntry
  708. printer = await printer_factory()
  709. archive = await archive_factory(printer.id, status="failed")
  710. entry = (
  711. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  712. ).scalar_one()
  713. resp = await async_client.patch(
  714. f"/api/v1/print-log/{entry.id}",
  715. json={"failure_reason": "completely-made-up"},
  716. )
  717. assert resp.status_code == 400
  718. @pytest.mark.asyncio
  719. @pytest.mark.integration
  720. async def test_patch_updates_status(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  721. from sqlalchemy import select
  722. from backend.app.models.print_log import PrintLogEntry
  723. printer = await printer_factory()
  724. archive = await archive_factory(printer.id, status="completed")
  725. entry = (
  726. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  727. ).scalar_one()
  728. entry.status = "completed"
  729. await db_session.commit()
  730. resp = await async_client.patch(
  731. f"/api/v1/print-log/{entry.id}",
  732. json={"status": "failed", "failure_reason": "layerShift"},
  733. )
  734. assert resp.status_code == 200
  735. assert resp.json()["status"] == "failed"
  736. assert resp.json()["failure_reason"] == "layerShift"
  737. @pytest.mark.asyncio
  738. @pytest.mark.integration
  739. async def test_patch_rejects_unknown_status(
  740. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  741. ):
  742. from sqlalchemy import select
  743. from backend.app.models.print_log import PrintLogEntry
  744. printer = await printer_factory()
  745. archive = await archive_factory(printer.id, status="failed")
  746. entry = (
  747. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  748. ).scalar_one()
  749. resp = await async_client.patch(
  750. f"/api/v1/print-log/{entry.id}",
  751. json={"status": "bogus-status"},
  752. )
  753. assert resp.status_code == 400
  754. @pytest.mark.asyncio
  755. @pytest.mark.integration
  756. async def test_patch_404_when_missing(self, async_client: AsyncClient):
  757. resp = await async_client.patch(
  758. "/api/v1/print-log/999999",
  759. json={"failure_reason": "cloggedNozzle"},
  760. )
  761. assert resp.status_code == 404
  762. @pytest.mark.asyncio
  763. @pytest.mark.integration
  764. async def test_patch_works_on_orphan_entry(self, async_client: AsyncClient, printer_factory, db_session):
  765. """Orphan log entries (no archive_id) are the actual reason this
  766. endpoint exists — the Archive Edit modal can't reach them. Make sure
  767. the PATCH works for those rows specifically."""
  768. from backend.app.models.print_log import PrintLogEntry
  769. printer = await printer_factory()
  770. orphan = PrintLogEntry(
  771. archive_id=None,
  772. print_name="failed-before-archive-created",
  773. printer_id=printer.id,
  774. status="failed",
  775. failure_reason=None,
  776. )
  777. db_session.add(orphan)
  778. await db_session.commit()
  779. await db_session.refresh(orphan)
  780. assert orphan.archive_id is None
  781. resp = await async_client.patch(
  782. f"/api/v1/print-log/{orphan.id}",
  783. json={"failure_reason": "powerFailure"},
  784. )
  785. assert resp.status_code == 200
  786. assert resp.json()["failure_reason"] == "powerFailure"
  787. assert resp.json()["archive_id"] is None
  788. class TestArchivesSlimAPI:
  789. """Integration tests for /api/v1/archives/slim endpoint."""
  790. @pytest.mark.asyncio
  791. @pytest.mark.integration
  792. async def test_slim_empty(self, async_client: AsyncClient):
  793. """Verify empty list when no archives exist."""
  794. response = await async_client.get("/api/v1/archives/slim")
  795. assert response.status_code == 200
  796. assert response.json() == []
  797. @pytest.mark.asyncio
  798. @pytest.mark.integration
  799. async def test_slim_returns_only_expected_fields(
  800. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  801. ):
  802. """Verify response contains only slim fields, not full archive data."""
  803. printer = await printer_factory()
  804. await archive_factory(
  805. printer.id,
  806. print_name="Slim Test",
  807. status="completed",
  808. filament_type="PLA",
  809. filament_color="#FF0000",
  810. filament_used_grams=50.0,
  811. print_time_seconds=3600,
  812. cost=1.50,
  813. quantity=2,
  814. )
  815. response = await async_client.get("/api/v1/archives/slim")
  816. assert response.status_code == 200
  817. data = response.json()
  818. assert len(data) == 1
  819. item = data[0]
  820. # Expected fields present
  821. assert item["printer_id"] == printer.id
  822. assert item["print_name"] == "Slim Test"
  823. assert item["status"] == "completed"
  824. assert item["filament_type"] == "PLA"
  825. assert item["filament_color"] == "#FF0000"
  826. assert item["filament_used_grams"] == 50.0
  827. assert item["print_time_seconds"] == 3600
  828. assert item["cost"] == 1.50
  829. # quantity is per-event semantics now (each PrintLogEntry = one run);
  830. # the archive's quantity field is no longer surfaced through this
  831. # endpoint after the #1390 per-event migration.
  832. assert item["quantity"] == 1
  833. assert "created_at" in item
  834. # Full archive fields must NOT be present
  835. assert "id" not in item
  836. assert "filename" not in item
  837. assert "file_path" not in item
  838. assert "file_size" not in item
  839. assert "extra_data" not in item
  840. assert "notes" not in item
  841. assert "tags" not in item
  842. assert "photos" not in item
  843. assert "thumbnail_path" not in item
  844. assert "content_hash" not in item
  845. assert "duplicates" not in item
  846. assert "duplicate_count" not in item
  847. @pytest.mark.asyncio
  848. @pytest.mark.integration
  849. async def test_slim_includes_energy_fields(
  850. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  851. ):
  852. """Per-print smart-plug energy surfaces through /slim so the stats
  853. page can include it in cost records and trends (#1432)."""
  854. printer = await printer_factory()
  855. await archive_factory(
  856. printer.id,
  857. status="completed",
  858. cost=1.50,
  859. energy_kwh=0.421,
  860. energy_cost=0.063,
  861. )
  862. response = await async_client.get("/api/v1/archives/slim")
  863. assert response.status_code == 200
  864. item = response.json()[0]
  865. assert item["energy_kwh"] == 0.421
  866. assert item["energy_cost"] == 0.063
  867. @pytest.mark.asyncio
  868. @pytest.mark.integration
  869. async def test_slim_computes_actual_time(
  870. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  871. ):
  872. """Verify actual_time_seconds is computed from started_at/completed_at."""
  873. from datetime import datetime, timezone
  874. printer = await printer_factory()
  875. started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
  876. completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # 2 hours = 7200s
  877. await archive_factory(
  878. printer.id,
  879. status="completed",
  880. started_at=started,
  881. completed_at=completed,
  882. )
  883. response = await async_client.get("/api/v1/archives/slim")
  884. assert response.status_code == 200
  885. item = response.json()[0]
  886. assert item["actual_time_seconds"] == 7200
  887. @pytest.mark.asyncio
  888. @pytest.mark.integration
  889. async def test_slim_actual_time_for_failed_includes_elapsed(
  890. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  891. ):
  892. """Failed prints report measured elapsed time so Printer Stats By Time
  893. matches Quick Stats Print Time (#1390). Previously this returned null
  894. and the frontend fell back to the slicer estimate, double-counting the
  895. unfinished portion of the print."""
  896. from datetime import datetime, timezone
  897. printer = await printer_factory()
  898. await archive_factory(
  899. printer.id,
  900. status="failed",
  901. started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
  902. completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
  903. )
  904. response = await async_client.get("/api/v1/archives/slim")
  905. assert response.status_code == 200
  906. item = response.json()[0]
  907. assert item["actual_time_seconds"] == 3600
  908. @pytest.mark.asyncio
  909. @pytest.mark.integration
  910. async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  911. """Verify date_from and date_to filters work."""
  912. from datetime import datetime, timezone
  913. printer = await printer_factory()
  914. await archive_factory(
  915. printer.id,
  916. print_name="Old Print",
  917. created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
  918. )
  919. await archive_factory(
  920. printer.id,
  921. print_name="New Print",
  922. created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
  923. )
  924. # Filter to only June 2024
  925. response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
  926. assert response.status_code == 200
  927. data = response.json()
  928. assert len(data) == 1
  929. assert data[0]["print_name"] == "New Print"
  930. @pytest.mark.asyncio
  931. @pytest.mark.integration
  932. async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  933. """Verify limit and offset work."""
  934. printer = await printer_factory()
  935. for i in range(5):
  936. await archive_factory(printer.id, print_name=f"Print {i}")
  937. response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
  938. assert response.status_code == 200
  939. assert len(response.json()) == 2
  940. @pytest.mark.asyncio
  941. @pytest.mark.integration
  942. async def test_slim_counts_reprints_as_separate_rows(
  943. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  944. ):
  945. """Reprints add events even though the archive row is overwritten (#1390).
  946. Before the per-event migration, /archives/slim returned one row per
  947. archive — so an archive that had been reprinted three times appeared
  948. once and undercounted Filament Used / Cost / Time. The endpoint must
  949. now return one row per logged event.
  950. """
  951. from backend.app.models.print_log import PrintLogEntry
  952. printer = await printer_factory()
  953. archive = await archive_factory(
  954. printer.id,
  955. print_name="Reprinted Model",
  956. filament_used_grams=50.0,
  957. cost=1.50,
  958. )
  959. # archive_factory synthesizes one event; add two more to simulate
  960. # the same archive being reprinted twice more.
  961. for _ in range(2):
  962. db_session.add(
  963. PrintLogEntry(
  964. archive_id=archive.id,
  965. printer_id=archive.printer_id,
  966. status="completed",
  967. filament_type=archive.filament_type,
  968. filament_used_grams=archive.filament_used_grams,
  969. cost=archive.cost,
  970. print_name=archive.print_name,
  971. )
  972. )
  973. await db_session.commit()
  974. response = await async_client.get("/api/v1/archives/slim")
  975. assert response.status_code == 200
  976. data = response.json()
  977. assert len(data) == 3, "Each reprint must contribute one row"
  978. total_filament = sum(item["filament_used_grams"] or 0 for item in data)
  979. assert total_filament == 150.0, "Sum across events must reflect all three runs"
  980. @pytest.mark.asyncio
  981. @pytest.mark.integration
  982. async def test_slim_includes_orphan_events(self, async_client: AsyncClient, printer_factory, db_session):
  983. """Events whose archive was hard-deleted still appear (#1390).
  984. After ON DELETE SET NULL the event row survives with archive_id=NULL.
  985. The slim endpoint must keep counting it so Quick Stats and the
  986. archive-iterating widgets stay aligned.
  987. """
  988. from backend.app.models.print_log import PrintLogEntry
  989. printer = await printer_factory()
  990. db_session.add(
  991. PrintLogEntry(
  992. archive_id=None,
  993. printer_id=printer.id,
  994. status="completed",
  995. filament_type="PETG",
  996. filament_used_grams=25.0,
  997. cost=0.75,
  998. print_name="Orphaned Print",
  999. )
  1000. )
  1001. await db_session.commit()
  1002. response = await async_client.get("/api/v1/archives/slim")
  1003. assert response.status_code == 200
  1004. data = response.json()
  1005. assert len(data) == 1
  1006. assert data[0]["print_name"] == "Orphaned Print"
  1007. assert data[0]["filament_used_grams"] == 25.0
  1008. # print_time_seconds (sliced estimate) comes from the archive table,
  1009. # which orphans no longer have — must surface as null gracefully.
  1010. assert data[0]["print_time_seconds"] is None
  1011. class TestFailureAnalysisAPI:
  1012. """Per-event failure analysis (#1390)."""
  1013. @pytest.mark.asyncio
  1014. @pytest.mark.integration
  1015. async def test_failure_analysis_counts_reprints_and_orphans(
  1016. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1017. ):
  1018. """Failure analysis aggregates per event, not per archive.
  1019. Verifies the dual fix for #1390: a reprint that adds a second failed
  1020. event must count twice, and an orphan failed event (archive deleted)
  1021. must still appear in the totals.
  1022. """
  1023. from backend.app.models.print_log import PrintLogEntry
  1024. printer = await printer_factory()
  1025. archive = await archive_factory(
  1026. printer.id,
  1027. print_name="Failing Model",
  1028. status="failed",
  1029. failure_reason="filament_runout",
  1030. )
  1031. # Add a second failed event for the same archive (a reprint that also
  1032. # failed) and one orphan failed event (archive was deleted).
  1033. db_session.add(
  1034. PrintLogEntry(
  1035. archive_id=archive.id,
  1036. printer_id=printer.id,
  1037. status="failed",
  1038. failure_reason="filament_runout",
  1039. filament_type=archive.filament_type,
  1040. print_name=archive.print_name,
  1041. )
  1042. )
  1043. db_session.add(
  1044. PrintLogEntry(
  1045. archive_id=None,
  1046. printer_id=printer.id,
  1047. status="failed",
  1048. failure_reason="bed_adhesion",
  1049. filament_type="PETG",
  1050. print_name="Orphaned Failed Print",
  1051. )
  1052. )
  1053. await db_session.commit()
  1054. response = await async_client.get("/api/v1/archives/analysis/failures")
  1055. assert response.status_code == 200
  1056. result = response.json()
  1057. assert result["total_prints"] == 3
  1058. assert result["failed_prints"] == 3
  1059. assert result["failures_by_reason"]["filament_runout"] == 2
  1060. assert result["failures_by_reason"]["bed_adhesion"] == 1
  1061. class TestArchiveDataIntegrity:
  1062. """Tests for archive data integrity."""
  1063. @pytest.mark.asyncio
  1064. @pytest.mark.integration
  1065. async def test_archive_linked_to_printer(
  1066. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1067. ):
  1068. """Verify archive is properly linked to printer."""
  1069. printer = await printer_factory(name="My Printer")
  1070. archive = await archive_factory(printer.id)
  1071. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1072. assert response.status_code == 200
  1073. result = response.json()
  1074. assert result["printer_id"] == printer.id
  1075. @pytest.mark.asyncio
  1076. @pytest.mark.integration
  1077. async def test_archive_stores_print_data(
  1078. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1079. ):
  1080. """Verify archive stores all print data correctly."""
  1081. printer = await printer_factory()
  1082. archive = await archive_factory(
  1083. printer.id,
  1084. print_name="Test Print",
  1085. filename="test.3mf",
  1086. status="completed",
  1087. filament_type="PLA",
  1088. filament_used_grams=75.5,
  1089. print_time_seconds=5400,
  1090. )
  1091. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1092. assert response.status_code == 200
  1093. result = response.json()
  1094. assert result["print_name"] == "Test Print"
  1095. assert result["filename"] == "test.3mf"
  1096. assert result["status"] == "completed"
  1097. assert result["filament_type"] == "PLA"
  1098. assert result["filament_used_grams"] == 75.5
  1099. assert result["print_time_seconds"] == 5400
  1100. @pytest.mark.asyncio
  1101. @pytest.mark.integration
  1102. async def test_archive_update_persists(
  1103. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1104. ):
  1105. """CRITICAL: Verify archive updates persist."""
  1106. printer = await printer_factory()
  1107. archive = await archive_factory(printer.id, notes="Original notes")
  1108. # Update
  1109. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
  1110. # Verify persistence
  1111. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1112. result = response.json()
  1113. assert result["notes"] == "Updated notes"
  1114. assert result["is_favorite"] is True
  1115. class TestArchiveF3DEndpoints:
  1116. """Tests for F3D (Fusion 360 design file) attachment endpoints."""
  1117. @pytest.mark.asyncio
  1118. @pytest.mark.integration
  1119. async def test_archive_response_includes_f3d_path(
  1120. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1121. ):
  1122. """Verify f3d_path is included in archive response."""
  1123. printer = await printer_factory()
  1124. archive = await archive_factory(printer.id, f3d_path="archives/test/design.f3d")
  1125. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1126. assert response.status_code == 200
  1127. result = response.json()
  1128. assert "f3d_path" in result
  1129. assert result["f3d_path"] == "archives/test/design.f3d"
  1130. @pytest.mark.asyncio
  1131. @pytest.mark.integration
  1132. async def test_archive_response_f3d_path_null_when_not_set(
  1133. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1134. ):
  1135. """Verify f3d_path is null when no F3D file attached."""
  1136. printer = await printer_factory()
  1137. archive = await archive_factory(printer.id)
  1138. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1139. assert response.status_code == 200
  1140. result = response.json()
  1141. assert "f3d_path" in result
  1142. assert result["f3d_path"] is None
  1143. @pytest.mark.asyncio
  1144. @pytest.mark.integration
  1145. async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):
  1146. """Verify 404 when uploading F3D to non-existent archive."""
  1147. # Create a minimal file-like upload
  1148. files = {"file": ("design.f3d", b"fake f3d content", "application/octet-stream")}
  1149. response = await async_client.post("/api/v1/archives/9999/f3d", files=files)
  1150. assert response.status_code == 404
  1151. @pytest.mark.asyncio
  1152. @pytest.mark.integration
  1153. async def test_download_f3d_not_found_when_no_file(
  1154. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1155. ):
  1156. """Verify 404 when downloading F3D from archive without F3D file."""
  1157. printer = await printer_factory()
  1158. archive = await archive_factory(printer.id)
  1159. response = await async_client.get(f"/api/v1/archives/{archive.id}/f3d")
  1160. assert response.status_code == 404
  1161. @pytest.mark.asyncio
  1162. @pytest.mark.integration
  1163. async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):
  1164. """Verify 404 when downloading F3D from non-existent archive."""
  1165. response = await async_client.get("/api/v1/archives/9999/f3d")
  1166. assert response.status_code == 404
  1167. @pytest.mark.asyncio
  1168. @pytest.mark.integration
  1169. async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):
  1170. """Verify 404 when deleting F3D from non-existent archive."""
  1171. response = await async_client.delete("/api/v1/archives/9999/f3d")
  1172. assert response.status_code == 404
  1173. @pytest.mark.asyncio
  1174. @pytest.mark.integration
  1175. async def test_delete_f3d_when_no_file(
  1176. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1177. ):
  1178. """Verify 404 when deleting F3D from archive without F3D file."""
  1179. printer = await printer_factory()
  1180. archive = await archive_factory(printer.id)
  1181. response = await async_client.delete(f"/api/v1/archives/{archive.id}/f3d")
  1182. assert response.status_code == 404
  1183. @pytest.mark.asyncio
  1184. @pytest.mark.integration
  1185. async def test_list_archives_includes_f3d_path(
  1186. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1187. ):
  1188. """Verify f3d_path is included in archive list responses."""
  1189. printer = await printer_factory()
  1190. await archive_factory(printer.id, print_name="With F3D", f3d_path="archives/test/design.f3d")
  1191. await archive_factory(printer.id, print_name="Without F3D")
  1192. response = await async_client.get("/api/v1/archives/")
  1193. assert response.status_code == 200
  1194. data = response.json()
  1195. assert len(data) >= 2
  1196. with_f3d = next((a for a in data if a["print_name"] == "With F3D"), None)
  1197. without_f3d = next((a for a in data if a["print_name"] == "Without F3D"), None)
  1198. assert with_f3d is not None
  1199. assert with_f3d["f3d_path"] == "archives/test/design.f3d"
  1200. assert without_f3d is not None
  1201. assert without_f3d["f3d_path"] is None
  1202. # ========================================================================
  1203. # Multi-Plate 3MF endpoints (Issue #93)
  1204. # ========================================================================
  1205. @pytest.mark.asyncio
  1206. @pytest.mark.integration
  1207. async def test_get_archive_plates_not_found(self, async_client: AsyncClient):
  1208. """Verify 404 when fetching plates for non-existent archive."""
  1209. response = await async_client.get("/api/v1/archives/999999/plates")
  1210. assert response.status_code == 404
  1211. @pytest.mark.asyncio
  1212. @pytest.mark.integration
  1213. async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):
  1214. """Verify 404 when fetching plate thumbnail for non-existent archive."""
  1215. response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1")
  1216. assert response.status_code == 404
  1217. @pytest.mark.asyncio
  1218. @pytest.mark.integration
  1219. async def test_filament_requirements_not_found(self, async_client: AsyncClient):
  1220. """Verify filament-requirements returns 404 for non-existent archive."""
  1221. response = await async_client.get("/api/v1/archives/999999/filament-requirements")
  1222. assert response.status_code == 404
  1223. @pytest.mark.asyncio
  1224. @pytest.mark.integration
  1225. async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):
  1226. """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
  1227. response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
  1228. assert response.status_code == 404
  1229. # ========================================================================
  1230. # Tag Management endpoints (Issue #183)
  1231. # ========================================================================
  1232. @pytest.mark.asyncio
  1233. @pytest.mark.integration
  1234. async def test_get_tags_empty(self, async_client: AsyncClient):
  1235. """Verify empty list when no tags exist."""
  1236. response = await async_client.get("/api/v1/archives/tags")
  1237. assert response.status_code == 200
  1238. data = response.json()
  1239. assert isinstance(data, list)
  1240. assert len(data) == 0
  1241. @pytest.mark.asyncio
  1242. @pytest.mark.integration
  1243. async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  1244. """Verify tags are returned with counts."""
  1245. printer = await printer_factory()
  1246. await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
  1247. await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
  1248. await archive_factory(printer.id, print_name="Archive 3", tags="test")
  1249. response = await async_client.get("/api/v1/archives/tags")
  1250. assert response.status_code == 200
  1251. data = response.json()
  1252. assert isinstance(data, list)
  1253. # Convert to dict for easier lookup
  1254. tags_dict = {t["name"]: t["count"] for t in data}
  1255. assert tags_dict.get("functional") == 2
  1256. assert tags_dict.get("test") == 2
  1257. assert tags_dict.get("calibration") == 1
  1258. @pytest.mark.asyncio
  1259. @pytest.mark.integration
  1260. async def test_get_tags_sorted_by_count(
  1261. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1262. ):
  1263. """Verify tags are sorted by count descending, then by name."""
  1264. printer = await printer_factory()
  1265. await archive_factory(printer.id, tags="alpha")
  1266. await archive_factory(printer.id, tags="beta, alpha")
  1267. await archive_factory(printer.id, tags="gamma, beta, alpha")
  1268. response = await async_client.get("/api/v1/archives/tags")
  1269. assert response.status_code == 200
  1270. data = response.json()
  1271. # alpha=3, beta=2, gamma=1
  1272. assert data[0]["name"] == "alpha"
  1273. assert data[0]["count"] == 3
  1274. assert data[1]["name"] == "beta"
  1275. assert data[1]["count"] == 2
  1276. assert data[2]["name"] == "gamma"
  1277. assert data[2]["count"] == 1
  1278. @pytest.mark.asyncio
  1279. @pytest.mark.integration
  1280. async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  1281. """Verify renaming a tag updates all archives."""
  1282. printer = await printer_factory()
  1283. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
  1284. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
  1285. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  1286. response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
  1287. assert response.status_code == 200
  1288. data = response.json()
  1289. assert data["affected"] == 2
  1290. # Verify the archives were updated
  1291. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  1292. assert "new-tag" in response.json()["tags"]
  1293. assert "old-tag" not in response.json()["tags"]
  1294. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  1295. assert response.json()["tags"] == "new-tag"
  1296. @pytest.mark.asyncio
  1297. @pytest.mark.integration
  1298. async def test_rename_tag_no_change(self, async_client: AsyncClient):
  1299. """Verify renaming to same name returns 0 affected."""
  1300. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
  1301. assert response.status_code == 200
  1302. assert response.json()["affected"] == 0
  1303. @pytest.mark.asyncio
  1304. @pytest.mark.integration
  1305. async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
  1306. """Verify renaming to empty name returns error."""
  1307. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
  1308. assert response.status_code == 400
  1309. @pytest.mark.asyncio
  1310. @pytest.mark.integration
  1311. async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  1312. """Verify deleting a tag removes it from all archives."""
  1313. printer = await printer_factory()
  1314. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
  1315. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
  1316. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  1317. response = await async_client.delete("/api/v1/archives/tags/delete-me")
  1318. assert response.status_code == 200
  1319. data = response.json()
  1320. assert data["affected"] == 2
  1321. # Verify the archives were updated
  1322. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  1323. assert response.json()["tags"] == "keep"
  1324. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  1325. # Should be None or empty when last tag is removed
  1326. assert response.json()["tags"] is None or response.json()["tags"] == ""
  1327. @pytest.mark.asyncio
  1328. @pytest.mark.integration
  1329. async def test_delete_tag_not_found(self, async_client: AsyncClient):
  1330. """Verify deleting non-existent tag returns 0 affected."""
  1331. response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
  1332. assert response.status_code == 200
  1333. assert response.json()["affected"] == 0
  1334. class TestUploadSourceThreeMF:
  1335. """Regression for #1531: source-3MF upload on fallback archives."""
  1336. @staticmethod
  1337. def _minimal_3mf_bytes() -> bytes:
  1338. """Smallest valid .3mf — the upload path enforces a zip header check."""
  1339. import io
  1340. import zipfile
  1341. buf = io.BytesIO()
  1342. with zipfile.ZipFile(buf, "w") as zf:
  1343. zf.writestr("[Content_Types].xml", "<types/>")
  1344. return buf.getvalue()
  1345. @pytest.mark.asyncio
  1346. @pytest.mark.integration
  1347. async def test_fallback_archive_source_upload_lands_under_base_dir(
  1348. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  1349. ):
  1350. """Fallback archive (file_path='') must accept a source upload and store it inside base_dir.
  1351. Pre-fix, ``Path(base_dir) / ''`` collapsed to ``base_dir`` and the
  1352. ``.parent`` walked out of the data volume, sending the file to
  1353. ``/app/source/...`` and crashing on ``relative_to``.
  1354. """
  1355. from backend.app.core.config import settings as app_settings
  1356. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1357. printer = await printer_factory()
  1358. archive = await archive_factory(
  1359. printer.id,
  1360. print_name="Cloud Print",
  1361. file_path="", # fallback archive — no source 3MF was archived
  1362. filename="Cloud Print.3mf",
  1363. )
  1364. files = {"file": ("cloud_print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  1365. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  1366. assert response.status_code == 200, response.text
  1367. payload = response.json()
  1368. rel = payload["source_3mf_path"]
  1369. # Stored as a relative path inside base_dir.
  1370. assert not rel.startswith("/"), f"source_3mf_path should be relative, got {rel!r}"
  1371. # File physically landed under base_dir (NOT escaped to /app/source/).
  1372. assert (tmp_path / rel).is_file()
  1373. # Deterministic fallback location keyed off archive id.
  1374. assert rel == f"archive/no_source/{archive.id}/cloud_print.3mf"
  1375. @pytest.mark.asyncio
  1376. @pytest.mark.integration
  1377. async def test_normal_archive_source_upload_unchanged(
  1378. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  1379. ):
  1380. """Normal archive (file_path set) still nests the source under <archive>/source/."""
  1381. from backend.app.core.config import settings as app_settings
  1382. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1383. printer = await printer_factory()
  1384. # archive_factory's default file_path is "archives/test/test_print.gcode.3mf".
  1385. archive = await archive_factory(printer.id, print_name="Real Print")
  1386. files = {"file": ("real_print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  1387. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  1388. assert response.status_code == 200, response.text
  1389. rel = response.json()["source_3mf_path"]
  1390. assert rel == "archives/test/source/real_print.3mf"
  1391. assert (tmp_path / rel).is_file()
  1392. @pytest.mark.asyncio
  1393. @pytest.mark.integration
  1394. async def test_symlinked_data_dir_upload_succeeds(
  1395. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  1396. ):
  1397. """Regression: DATA_DIR that's a symlink to the real storage must not break the upload.
  1398. Common on TrueNAS / Synology / QNAP storage pools, and any
  1399. ``-v /symlinked/host/path:/app/data`` mount. The helper resolves
  1400. only for the containment check and returns literal paths so the
  1401. caller's ``relative_to(settings.base_dir)`` doesn't trip over a
  1402. canonical-vs-symlink mismatch.
  1403. """
  1404. from backend.app.core.config import settings as app_settings
  1405. real_dir = tmp_path / "real_storage"
  1406. real_dir.mkdir()
  1407. symlink_dir = tmp_path / "data_via_symlink"
  1408. symlink_dir.symlink_to(real_dir)
  1409. monkeypatch.setattr(app_settings, "base_dir", symlink_dir)
  1410. printer = await printer_factory()
  1411. archive = await archive_factory(
  1412. printer.id,
  1413. print_name="Symlinked Print",
  1414. file_path="archives/X1C/print.gcode.3mf",
  1415. filename="print.gcode.3mf",
  1416. )
  1417. files = {"file": ("print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  1418. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  1419. assert response.status_code == 200, response.text
  1420. rel = response.json()["source_3mf_path"]
  1421. assert rel == "archives/X1C/source/print.3mf"
  1422. # Reachable via both the symlink and the canonical path.
  1423. assert (symlink_dir / rel).is_file()
  1424. assert (real_dir / rel).is_file()
  1425. @pytest.mark.asyncio
  1426. @pytest.mark.integration
  1427. async def test_absolute_file_path_rejected_with_clear_500(
  1428. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  1429. ):
  1430. """A row whose file_path is absolute (corrupted by old import / manual edit)
  1431. must fail with the explicit "outside the data directory" message, not silently
  1432. write outside base_dir."""
  1433. from backend.app.core.config import settings as app_settings
  1434. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  1435. printer = await printer_factory()
  1436. archive = await archive_factory(
  1437. printer.id,
  1438. print_name="Corrupt Path",
  1439. file_path="/tmp/totally_outside.gcode.3mf", # nosec B108
  1440. filename="totally_outside.gcode.3mf",
  1441. )
  1442. files = {"file": ("totally_outside.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  1443. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  1444. assert response.status_code == 500
  1445. assert "outside the data directory" in response.json()["detail"]
  1446. # Did not write anything under the bogus /tmp/source/ either.
  1447. assert not (Path("/tmp") / "source").exists() or not (Path("/tmp") / "source" / "totally_outside.3mf").exists() # nosec B108
  1448. class TestSoftDeletedArchivesAreExcluded:
  1449. """Soft-deleted archives (#1343) must not leak into export or analysis (#2731).
  1450. The soft delete keeps the row so global Quick Stats can still count it, but
  1451. the archive is gone from every listing. Two consumers never got the memo:
  1452. the CSV export handed back rows the UI says do not exist, and per-project
  1453. failure analysis kept counting prints the user had deleted from the project
  1454. — disagreeing with the project's own figures.
  1455. """
  1456. @staticmethod
  1457. async def _soft_delete(db_session, archive) -> int:
  1458. from datetime import datetime, timezone
  1459. archive_id = archive.id
  1460. archive.deleted_at = datetime.now(timezone.utc)
  1461. await db_session.commit()
  1462. return archive_id
  1463. @pytest.mark.asyncio
  1464. @pytest.mark.integration
  1465. async def test_export_omits_soft_deleted_archives(
  1466. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1467. ):
  1468. printer = await printer_factory()
  1469. await archive_factory(printer.id, print_name="Kept Print")
  1470. gone = await archive_factory(printer.id, print_name="Deleted Print")
  1471. await self._soft_delete(db_session, gone)
  1472. response = await async_client.get("/api/v1/archives/export?format=csv")
  1473. assert response.status_code == 200
  1474. body = response.text
  1475. assert "Kept Print" in body
  1476. assert "Deleted Print" not in body
  1477. @pytest.mark.asyncio
  1478. @pytest.mark.integration
  1479. async def test_project_failure_analysis_omits_soft_deleted_archives(
  1480. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1481. ):
  1482. from backend.app.models.project import Project
  1483. project = Project(name="Analysis Project")
  1484. db_session.add(project)
  1485. await db_session.commit()
  1486. await db_session.refresh(project)
  1487. project_id = project.id
  1488. printer = await printer_factory()
  1489. await archive_factory(
  1490. printer.id,
  1491. print_name="Kept Failure",
  1492. status="failed",
  1493. failure_reason="bed_adhesion",
  1494. project_id=project_id,
  1495. )
  1496. gone = await archive_factory(
  1497. printer.id,
  1498. print_name="Deleted Failure",
  1499. status="failed",
  1500. failure_reason="filament_runout",
  1501. project_id=project_id,
  1502. )
  1503. await self._soft_delete(db_session, gone)
  1504. response = await async_client.get(f"/api/v1/archives/analysis/failures?project_id={project_id}")
  1505. assert response.status_code == 200
  1506. result = response.json()
  1507. assert result["failed_prints"] == 1
  1508. assert result["failures_by_reason"] == {"bed_adhesion": 1}
  1509. @pytest.mark.asyncio
  1510. @pytest.mark.integration
  1511. async def test_unscoped_failure_analysis_is_unchanged(
  1512. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1513. ):
  1514. """Only the project-scoped path filters. Global analysis still counts
  1515. every run, including orphans, exactly as #1390 established."""
  1516. printer = await printer_factory()
  1517. gone = await archive_factory(
  1518. printer.id, print_name="Deleted Failure", status="failed", failure_reason="filament_runout"
  1519. )
  1520. await self._soft_delete(db_session, gone)
  1521. response = await async_client.get("/api/v1/archives/analysis/failures")
  1522. assert response.status_code == 200
  1523. assert response.json()["failed_prints"] == 1