test_archives_api.py 74 KB

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