test_archives_api.py 93 KB

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