test_archives_api.py 125 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003
  1. """Integration tests for Archives API endpoints.
  2. Tests the full request/response cycle for /api/v1/archives/ endpoints.
  3. """
  4. from datetime import datetime
  5. from pathlib import Path
  6. from unittest.mock import AsyncMock, patch
  7. import pytest
  8. from httpx import AsyncClient
  9. from backend.app.core.config import settings
  10. from backend.app.services.bambu_ftp import FileListResult
  11. class TestArchivesAPI:
  12. """Integration tests for /api/v1/archives/ endpoints."""
  13. # ========================================================================
  14. # Upload endpoint
  15. # ========================================================================
  16. @pytest.mark.asyncio
  17. @pytest.mark.integration
  18. @pytest.mark.parametrize("prefer_filename_for_name", [True, False])
  19. async def test_upload_archive_forwards_prefer_filename_for_name(
  20. self, async_client: AsyncClient, archive_factory, printer_factory, prefer_filename_for_name
  21. ):
  22. """POST /archives/upload must forward prefer_filename_for_name to
  23. ArchiveService.archive_print unchanged — this flag lets a caller (e.g.
  24. the manual upload UI or an external integration) ask for the uploaded
  25. filename to win over the 3MF's embedded print_name (#1152 follow-up:
  26. the flag existed on the service but wasn't exposed on this route).
  27. archive_print is mocked (real 3MF metadata extraction isn't under test
  28. here) but its return value is a real PrintArchive row from the
  29. factory, so ArchiveResponse.model_validate in the route still exercises
  30. real serialization instead of masking a broken response behind a bare
  31. MagicMock.
  32. """
  33. printer = await printer_factory()
  34. archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
  35. archive_print_mock = AsyncMock(return_value=archive)
  36. files = {"file": ("My Print (final).gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")}
  37. params = {"prefer_filename_for_name": prefer_filename_for_name}
  38. with patch(
  39. "backend.app.api.routes.archives.ArchiveService.archive_print",
  40. archive_print_mock,
  41. ):
  42. response = await async_client.post("/api/v1/archives/upload", files=files, params=params)
  43. assert response.status_code == 200
  44. assert archive_print_mock.await_count == 1
  45. kwargs = archive_print_mock.await_args.kwargs
  46. assert kwargs.get("prefer_filename_for_name") is prefer_filename_for_name
  47. @pytest.mark.asyncio
  48. @pytest.mark.integration
  49. async def test_upload_archive_defaults_prefer_filename_for_name_false(
  50. self, async_client: AsyncClient, archive_factory, printer_factory
  51. ):
  52. """Omitting the query param must not change existing behavior for
  53. callers that predate this flag."""
  54. printer = await printer_factory()
  55. archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
  56. archive_print_mock = AsyncMock(return_value=archive)
  57. files = {"file": ("existing-caller.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")}
  58. with patch(
  59. "backend.app.api.routes.archives.ArchiveService.archive_print",
  60. archive_print_mock,
  61. ):
  62. response = await async_client.post("/api/v1/archives/upload", files=files)
  63. assert response.status_code == 200
  64. kwargs = archive_print_mock.await_args.kwargs
  65. assert kwargs.get("prefer_filename_for_name") is False
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. @pytest.mark.parametrize("prefer_filename_for_name", [True, False])
  69. async def test_upload_archives_bulk_forwards_prefer_filename_for_name(
  70. self, async_client: AsyncClient, archive_factory, printer_factory, prefer_filename_for_name
  71. ):
  72. """POST /archives/upload-bulk must forward prefer_filename_for_name to
  73. ArchiveService.archive_print for every file in the batch, keeping this
  74. route consistent with the single-file /archives/upload endpoint."""
  75. printer = await printer_factory()
  76. archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
  77. archive_print_mock = AsyncMock(return_value=archive)
  78. files = [
  79. ("files", ("first.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
  80. ("files", ("second.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
  81. ]
  82. params = {"prefer_filename_for_name": prefer_filename_for_name}
  83. with patch(
  84. "backend.app.api.routes.archives.ArchiveService.archive_print",
  85. archive_print_mock,
  86. ):
  87. response = await async_client.post("/api/v1/archives/upload-bulk", files=files, params=params)
  88. assert response.status_code == 200
  89. assert archive_print_mock.await_count == 2
  90. for call in archive_print_mock.await_args_list:
  91. assert call.kwargs.get("prefer_filename_for_name") is prefer_filename_for_name
  92. @pytest.mark.asyncio
  93. @pytest.mark.integration
  94. async def test_upload_archives_bulk_numbers_each_outcome_by_its_file(
  95. self, async_client: AsyncClient, archive_factory, printer_factory
  96. ):
  97. """Two files of the same name in one batch, the second failing: the
  98. upload dialog matched outcomes to its rows by name, so both rows took
  99. the first one's success. Each outcome now says which file it was."""
  100. printer = await printer_factory()
  101. archive = await archive_factory(printer.id, print_name="Benchy")
  102. archive_print_mock = AsyncMock(side_effect=[archive, None])
  103. files = [
  104. ("files", ("Benchy.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
  105. ("files", ("Benchy.3mf", b"PK\x03\x04fake3mf", "application/octet-stream")),
  106. ("files", ("notes.txt", b"hello", "text/plain")),
  107. ]
  108. with patch(
  109. "backend.app.api.routes.archives.ArchiveService.archive_print",
  110. archive_print_mock,
  111. ):
  112. response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
  113. assert response.status_code == 200
  114. body = response.json()
  115. assert [(r["index"], r["id"]) for r in body["results"]] == [(0, archive.id)]
  116. assert [e["index"] for e in body["errors"]] == [1, 2]
  117. @pytest.mark.asyncio
  118. @pytest.mark.integration
  119. async def test_upload_archives_bulk_defaults_prefer_filename_for_name_false(
  120. self, async_client: AsyncClient, archive_factory, printer_factory
  121. ):
  122. """Omitting the query param on the bulk route must not change existing
  123. behavior for callers that predate this flag."""
  124. printer = await printer_factory()
  125. archive = await archive_factory(printer.id, print_name="Mocked Return Archive")
  126. archive_print_mock = AsyncMock(return_value=archive)
  127. files = [("files", ("existing-caller.gcode.3mf", b"PK\x03\x04fake3mf", "application/octet-stream"))]
  128. with patch(
  129. "backend.app.api.routes.archives.ArchiveService.archive_print",
  130. archive_print_mock,
  131. ):
  132. response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
  133. assert response.status_code == 200
  134. kwargs = archive_print_mock.await_args.kwargs
  135. assert kwargs.get("prefer_filename_for_name") is False
  136. # ========================================================================
  137. # List endpoints
  138. # ========================================================================
  139. @pytest.mark.asyncio
  140. @pytest.mark.integration
  141. async def test_list_archives_empty(self, async_client: AsyncClient):
  142. """Verify empty list is returned when no archives exist."""
  143. response = await async_client.get("/api/v1/archives/")
  144. assert response.status_code == 200
  145. data = response.json()
  146. assert isinstance(data, list)
  147. assert len(data) == 0
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_list_archives_with_data(
  151. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  152. ):
  153. """Verify list returns existing archives."""
  154. printer = await printer_factory()
  155. await archive_factory(printer.id, print_name="Test Archive")
  156. response = await async_client.get("/api/v1/archives/")
  157. assert response.status_code == 200
  158. data = response.json()
  159. assert isinstance(data, list)
  160. assert len(data) >= 1
  161. assert any(a["print_name"] == "Test Archive" for a in data)
  162. @pytest.mark.asyncio
  163. @pytest.mark.integration
  164. async def test_list_archives_pagination(
  165. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  166. ):
  167. """Verify pagination works correctly."""
  168. printer = await printer_factory()
  169. # Create 5 archives
  170. for i in range(5):
  171. await archive_factory(printer.id, print_name=f"Archive {i}")
  172. # Get first page with limit 2
  173. response = await async_client.get("/api/v1/archives/?limit=2&offset=0")
  174. assert response.status_code == 200
  175. data = response.json()
  176. assert isinstance(data, list)
  177. assert len(data) == 2
  178. @pytest.mark.asyncio
  179. @pytest.mark.integration
  180. async def test_list_archives_filter_by_printer(
  181. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  182. ):
  183. """Verify filtering by printer_id works."""
  184. printer1 = await printer_factory(name="Printer 1", serial_number="00M09A000000001")
  185. printer2 = await printer_factory(name="Printer 2", serial_number="00M09A000000002")
  186. await archive_factory(printer1.id, print_name="Printer 1 Archive")
  187. await archive_factory(printer2.id, print_name="Printer 2 Archive")
  188. response = await async_client.get(f"/api/v1/archives/?printer_id={printer1.id}")
  189. assert response.status_code == 200
  190. data = response.json()
  191. assert all(a["printer_id"] == printer1.id for a in data)
  192. # ========================================================================
  193. # Get single endpoint
  194. # ========================================================================
  195. @pytest.mark.asyncio
  196. @pytest.mark.integration
  197. async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  198. """Verify single archive can be retrieved."""
  199. printer = await printer_factory()
  200. archive = await archive_factory(printer.id, print_name="Get Test Archive")
  201. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  202. assert response.status_code == 200
  203. result = response.json()
  204. assert result["id"] == archive.id
  205. assert result["print_name"] == "Get Test Archive"
  206. @pytest.mark.asyncio
  207. @pytest.mark.integration
  208. async def test_archive_response_exposes_selected_plate(
  209. self, async_client: AsyncClient, archive_factory, printer_factory
  210. ):
  211. """Both archive endpoints must report the stored plate (#2796).
  212. archive_to_response builds its response dict field by field and left
  213. plate_id out. ArchiveResponse.plate_id defaults to None, so every
  214. archive came back as plate_id: null and nothing raised an error.
  215. List and detail share the helper, so both are checked here. The archive
  216. without a plate guards a fix that substitutes a fallback plate.
  217. """
  218. printer = await printer_factory()
  219. with_plate = await archive_factory(printer.id, print_name="Plate 22 of a multi-plate 3MF", plate_id=22)
  220. without_plate = await archive_factory(printer.id, print_name="Single-plate print")
  221. listed = await async_client.get("/api/v1/archives/")
  222. assert listed.status_code == 200
  223. rows = {a["id"]: a for a in listed.json()}
  224. assert rows[with_plate.id]["plate_id"] == 22
  225. assert rows[without_plate.id]["plate_id"] is None
  226. detail = await async_client.get(f"/api/v1/archives/{with_plate.id}")
  227. assert detail.status_code == 200
  228. assert detail.json()["plate_id"] == 22
  229. @pytest.mark.asyncio
  230. @pytest.mark.integration
  231. async def test_get_archive_not_found(self, async_client: AsyncClient):
  232. """Verify 404 for non-existent archive."""
  233. response = await async_client.get("/api/v1/archives/9999")
  234. assert response.status_code == 404
  235. @pytest.mark.asyncio
  236. @pytest.mark.integration
  237. async def test_get_archive_printer_media_matches_timelapse_and_ipcam_chunks(
  238. self,
  239. async_client: AsyncClient,
  240. archive_factory,
  241. printer_factory,
  242. db_session,
  243. ):
  244. printer = await printer_factory()
  245. archive = await archive_factory(
  246. printer.id,
  247. started_at=datetime(2026, 8, 12, 10, 0),
  248. completed_at=datetime(2026, 8, 12, 11, 0),
  249. timelapse_path=None,
  250. )
  251. list_timeouts = []
  252. async def fake_list(_ip, _code, path, **kwargs):
  253. list_timeouts.append(kwargs.get("timeout"))
  254. if path == "/timelapse":
  255. return FileListResult(
  256. files=[
  257. {
  258. "name": "video_2026-08-12_18-00-00.mp4",
  259. "path": "/timelapse/video_2026-08-12_18-00-00.mp4",
  260. "size": 123,
  261. "mtime": datetime(2026, 8, 12, 11, 1),
  262. "is_directory": False,
  263. }
  264. ],
  265. available=True,
  266. )
  267. if path == "/ipcam":
  268. return FileListResult(
  269. files=[
  270. {
  271. "name": "ipcam-record.1.mp4",
  272. "path": "/ipcam/ipcam-record.1.mp4",
  273. "size": 250_000_000,
  274. "mtime": datetime(2026, 8, 12, 10, 5),
  275. "is_directory": False,
  276. },
  277. {
  278. "name": "ipcam-record.after.mp4",
  279. "path": "/ipcam/ipcam-record.after.mp4",
  280. "size": 250_000_000,
  281. "mtime": datetime(2026, 8, 12, 11, 30),
  282. "is_directory": False,
  283. },
  284. ],
  285. available=True,
  286. )
  287. return FileListResult(files=[], available=False)
  288. with (
  289. patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
  290. patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
  291. ):
  292. response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
  293. assert response.status_code == 200
  294. data = response.json()
  295. assert data["local_timelapse"] is None
  296. assert [(file["kind"], file["name"]) for file in data["remote_files"]] == [
  297. ("timelapse", "video_2026-08-12_18-00-00.mp4"),
  298. ("ipcam", "ipcam-record.1.mp4"),
  299. ]
  300. assert list_timeouts == [8.0, 8.0]
  301. @pytest.mark.asyncio
  302. @pytest.mark.integration
  303. async def test_archive_printer_media_skips_ftp_during_handshake_cooloff(
  304. self,
  305. async_client: AsyncClient,
  306. archive_factory,
  307. printer_factory,
  308. ):
  309. printer = await printer_factory()
  310. archive = await archive_factory(
  311. printer.id,
  312. started_at=datetime(2026, 8, 12, 10, 0),
  313. completed_at=datetime(2026, 8, 12, 11, 0),
  314. timelapse_path=None,
  315. )
  316. list_files = AsyncMock()
  317. with (
  318. patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=True),
  319. patch("backend.app.api.routes.archives.list_files_result_async", new=list_files),
  320. ):
  321. response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
  322. assert response.status_code == 200
  323. assert response.json()["remote_files"] == []
  324. assert response.json()["warnings"] == ["timelapse_unavailable", "ipcam_unavailable"]
  325. list_files.assert_not_awaited()
  326. @pytest.mark.asyncio
  327. @pytest.mark.integration
  328. async def test_archive_printer_media_checks_alternate_timelapse_directories_after_empty_listing(
  329. self,
  330. async_client: AsyncClient,
  331. archive_factory,
  332. printer_factory,
  333. ):
  334. printer = await printer_factory()
  335. archive = await archive_factory(
  336. printer.id,
  337. started_at=datetime(2026, 8, 12, 10, 0),
  338. completed_at=datetime(2026, 8, 12, 11, 0),
  339. timelapse_path=None,
  340. )
  341. paths: list[str] = []
  342. async def fake_list(_ip, _code, path, **_kwargs):
  343. paths.append(path)
  344. if path == "/timelapse/video":
  345. return FileListResult(
  346. files=[
  347. {
  348. "name": "video_2026-08-12_11-01-00.mp4",
  349. "path": "/timelapse/video/video_2026-08-12_11-01-00.mp4",
  350. "size": 321,
  351. "mtime": datetime(2026, 8, 12, 11, 1),
  352. "is_directory": False,
  353. }
  354. ],
  355. available=True,
  356. )
  357. return FileListResult(files=[], available=True)
  358. with (
  359. patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
  360. patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
  361. ):
  362. response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
  363. assert response.status_code == 200
  364. assert response.json()["remote_files"][0]["path"].startswith("/timelapse/video/")
  365. assert paths == ["/timelapse", "/timelapse/video", "/ipcam"]
  366. @pytest.mark.asyncio
  367. @pytest.mark.integration
  368. async def test_archive_printer_media_releases_db_session_before_ftp(
  369. self,
  370. async_client: AsyncClient,
  371. archive_factory,
  372. printer_factory,
  373. monkeypatch,
  374. ):
  375. from backend.app.api.routes import archives as archives_routes
  376. printer = await printer_factory()
  377. archive = await archive_factory(
  378. printer.id,
  379. started_at=datetime(2026, 8, 12, 10, 0),
  380. timelapse_path=None,
  381. )
  382. real_factory = archives_routes.database.async_session
  383. session_closed = False
  384. class TrackingSession:
  385. async def __aenter__(self):
  386. self.context = real_factory()
  387. return await self.context.__aenter__()
  388. async def __aexit__(self, *args):
  389. nonlocal session_closed
  390. result = await self.context.__aexit__(*args)
  391. session_closed = True
  392. return result
  393. async def fake_list(*_args, **_kwargs):
  394. assert session_closed
  395. return FileListResult(files=[], available=True)
  396. monkeypatch.setattr(archives_routes.database, "async_session", TrackingSession)
  397. with (
  398. patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
  399. patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
  400. ):
  401. response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
  402. assert response.status_code == 200
  403. @pytest.mark.asyncio
  404. @pytest.mark.integration
  405. async def test_archive_printer_media_baseline_excludes_old_timestamp_match(
  406. self,
  407. async_client: AsyncClient,
  408. archive_factory,
  409. printer_factory,
  410. ):
  411. printer = await printer_factory()
  412. archive = await archive_factory(
  413. printer.id,
  414. started_at=datetime(2026, 8, 12, 10, 0),
  415. completed_at=datetime(2026, 8, 12, 11, 0),
  416. timelapse_path=None,
  417. timelapse_baseline=["old.mp4"],
  418. )
  419. async def fake_list(_ip, _code, path, **_kwargs):
  420. if path == "/timelapse":
  421. return FileListResult(
  422. files=[
  423. {
  424. "name": "old.mp4",
  425. "path": "/timelapse/old.mp4",
  426. "size": 10,
  427. "mtime": datetime(2026, 8, 12, 11, 0),
  428. "is_directory": False,
  429. },
  430. {
  431. "name": "new.mp4",
  432. "path": "/timelapse/new.mp4",
  433. "size": 20,
  434. "mtime": datetime(2020, 1, 1),
  435. "is_directory": False,
  436. },
  437. ],
  438. available=True,
  439. )
  440. return FileListResult(files=[], available=True)
  441. with (
  442. patch("backend.app.api.routes.archives.list_files_result_async", new=AsyncMock(side_effect=fake_list)),
  443. patch("backend.app.api.routes.archives.ftps_handshake_blocked", return_value=False),
  444. ):
  445. response = await async_client.get(f"/api/v1/archives/{archive.id}/printer-media")
  446. assert response.status_code == 200
  447. timelapses = [item for item in response.json()["remote_files"] if item["kind"] == "timelapse"]
  448. assert [item["name"] for item in timelapses] == ["new.mp4"]
  449. @pytest.mark.asyncio
  450. @pytest.mark.integration
  451. async def test_archive_printer_media_requires_printer_files_permission(
  452. self,
  453. async_client: AsyncClient,
  454. archive_factory,
  455. printer_factory,
  456. tmp_path,
  457. monkeypatch,
  458. ):
  459. printer = await printer_factory()
  460. monkeypatch.setattr(settings, "base_dir", tmp_path)
  461. timelapse = tmp_path / "timelapses" / "attached.mp4"
  462. timelapse.parent.mkdir()
  463. timelapse.write_bytes(b"attached video")
  464. archive = await archive_factory(
  465. printer.id,
  466. started_at=datetime(2026, 8, 12, 10, 0),
  467. timelapse_path="timelapses/attached.mp4",
  468. )
  469. setup = await async_client.post(
  470. "/api/v1/auth/setup",
  471. json={
  472. "auth_enabled": True,
  473. "admin_username": "mediaadmin",
  474. "admin_password": "AdminPass1!",
  475. },
  476. )
  477. assert setup.status_code == 200, setup.text
  478. admin_login = await async_client.post(
  479. "/api/v1/auth/login",
  480. json={"username": "mediaadmin", "password": "AdminPass1!"},
  481. )
  482. admin_headers = {"Authorization": f"Bearer {admin_login.json()['access_token']}"}
  483. group = await async_client.post(
  484. "/api/v1/groups/",
  485. headers=admin_headers,
  486. json={"name": "archive-only-media", "permissions": ["archives:read_all"]},
  487. )
  488. assert group.status_code == 201, group.text
  489. user = await async_client.post(
  490. "/api/v1/users/",
  491. headers=admin_headers,
  492. json={
  493. "username": "archiveonlymedia",
  494. "password": "ArchivePass1!",
  495. "group_ids": [group.json()["id"]],
  496. },
  497. )
  498. assert user.status_code == 201, user.text
  499. login = await async_client.post(
  500. "/api/v1/auth/login",
  501. json={"username": "archiveonlymedia", "password": "ArchivePass1!"},
  502. )
  503. list_files = AsyncMock()
  504. with patch("backend.app.api.routes.archives.list_files_result_async", new=list_files):
  505. response = await async_client.get(
  506. f"/api/v1/archives/{archive.id}/printer-media",
  507. headers={"Authorization": f"Bearer {login.json()['access_token']}"},
  508. )
  509. assert response.status_code == 200
  510. assert response.json()["local_timelapse"] == {"name": "attached.mp4", "size": 14}
  511. assert response.json()["remote_files"] == []
  512. assert response.json()["warnings"] == ["printer_files_forbidden"]
  513. list_files.assert_not_awaited()
  514. token_response = await async_client.post(
  515. f"/api/v1/archives/{archive.id}/media-download-token",
  516. headers={"Authorization": f"Bearer {login.json()['access_token']}"},
  517. )
  518. assert token_response.status_code == 200
  519. download = await async_client.get(
  520. f"/api/v1/archives/{archive.id}/media/dl/{token_response.json()['token']}/attached.mp4"
  521. )
  522. assert download.status_code == 200
  523. assert download.content == b"attached video"
  524. @pytest.mark.asyncio
  525. @pytest.mark.integration
  526. async def test_archive_media_token_is_archive_bound_single_use(
  527. self,
  528. async_client: AsyncClient,
  529. archive_factory,
  530. printer_factory,
  531. tmp_path,
  532. monkeypatch,
  533. ):
  534. printer = await printer_factory()
  535. monkeypatch.setattr(settings, "base_dir", tmp_path)
  536. media = tmp_path / "timelapses" / "bound.mp4"
  537. media.parent.mkdir()
  538. media.write_bytes(b"bound media")
  539. archive_a = await archive_factory(printer.id, timelapse_path="timelapses/bound.mp4")
  540. archive_b = await archive_factory(printer.id, timelapse_path="timelapses/bound.mp4")
  541. minted = await async_client.post(f"/api/v1/archives/{archive_a.id}/media-download-token")
  542. assert minted.status_code == 200
  543. token = minted.json()["token"]
  544. wrong = await async_client.get(f"/api/v1/archives/{archive_b.id}/media/dl/{token}/bound.mp4")
  545. assert wrong.status_code == 403
  546. correct = await async_client.get(f"/api/v1/archives/{archive_a.id}/media/dl/{token}/bound.mp4")
  547. assert correct.status_code == 200
  548. replay = await async_client.get(f"/api/v1/archives/{archive_a.id}/media/dl/{token}/bound.mp4")
  549. assert replay.status_code == 403
  550. @pytest.mark.asyncio
  551. @pytest.mark.integration
  552. async def test_archive_printer_media_enforces_api_key_printer_scope(
  553. self,
  554. async_client: AsyncClient,
  555. archive_factory,
  556. printer_factory,
  557. db_session,
  558. ):
  559. from backend.app.core.auth import generate_api_key
  560. from backend.app.models.api_key import APIKey
  561. printer_a = await printer_factory(name="Media Scope A", serial_number="MEDIASCOPEA00001")
  562. printer_b = await printer_factory(name="Media Scope B", serial_number="MEDIASCOPEB00001")
  563. archive = await archive_factory(
  564. printer_b.id,
  565. started_at=datetime(2026, 8, 12, 10, 0),
  566. timelapse_path=None,
  567. )
  568. setup = await async_client.post(
  569. "/api/v1/auth/setup",
  570. json={
  571. "auth_enabled": True,
  572. "admin_username": "mediascopeadmin",
  573. "admin_password": "AdminPass1!",
  574. },
  575. )
  576. assert setup.status_code == 200, setup.text
  577. full_key, key_hash, key_prefix = generate_api_key()
  578. db_session.add(
  579. APIKey(
  580. name="media-printer-scope",
  581. key_hash=key_hash,
  582. key_prefix=key_prefix,
  583. can_read_status=True,
  584. can_control_printer=True,
  585. printer_ids=[printer_a.id],
  586. enabled=True,
  587. )
  588. )
  589. await db_session.commit()
  590. listing = AsyncMock()
  591. with patch("backend.app.api.routes.archives.list_files_result_async", new=listing):
  592. response = await async_client.get(
  593. f"/api/v1/archives/{archive.id}/printer-media",
  594. headers={"X-API-Key": full_key},
  595. )
  596. # An archive from a printer outside the key's scope is as missing as
  597. # the printer (#1727), so nothing is listed over FTP.
  598. assert response.status_code == 404
  599. listing.assert_not_awaited()
  600. @pytest.mark.asyncio
  601. @pytest.mark.integration
  602. async def test_camera_only_user_cannot_mint_archive_media_token(
  603. self,
  604. async_client: AsyncClient,
  605. archive_factory,
  606. printer_factory,
  607. tmp_path,
  608. monkeypatch,
  609. ):
  610. printer = await printer_factory()
  611. monkeypatch.setattr(settings, "base_dir", tmp_path)
  612. media = tmp_path / "timelapses" / "private.mp4"
  613. media.parent.mkdir()
  614. media.write_bytes(b"private")
  615. archive = await archive_factory(printer.id, timelapse_path="timelapses/private.mp4")
  616. setup = await async_client.post(
  617. "/api/v1/auth/setup",
  618. json={
  619. "auth_enabled": True,
  620. "admin_username": "cameraadmin",
  621. "admin_password": "AdminPass1!",
  622. },
  623. )
  624. assert setup.status_code == 200, setup.text
  625. admin_login = await async_client.post(
  626. "/api/v1/auth/login",
  627. json={"username": "cameraadmin", "password": "AdminPass1!"},
  628. )
  629. admin_headers = {"Authorization": f"Bearer {admin_login.json()['access_token']}"}
  630. group = await async_client.post(
  631. "/api/v1/groups/",
  632. headers=admin_headers,
  633. json={"name": "camera-only-media", "permissions": ["camera:view"]},
  634. )
  635. assert group.status_code == 201, group.text
  636. user = await async_client.post(
  637. "/api/v1/users/",
  638. headers=admin_headers,
  639. json={
  640. "username": "cameraonlymedia",
  641. "password": "CameraPass1!",
  642. "group_ids": [group.json()["id"]],
  643. },
  644. )
  645. assert user.status_code == 201, user.text
  646. login = await async_client.post(
  647. "/api/v1/auth/login",
  648. json={"username": "cameraonlymedia", "password": "CameraPass1!"},
  649. )
  650. response = await async_client.post(
  651. f"/api/v1/archives/{archive.id}/media-download-token",
  652. headers={"Authorization": f"Bearer {login.json()['access_token']}"},
  653. )
  654. assert response.status_code == 403
  655. # ========================================================================
  656. # Update endpoints
  657. # ========================================================================
  658. @pytest.mark.asyncio
  659. @pytest.mark.integration
  660. async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  661. """Verify archive name can be updated."""
  662. printer = await printer_factory()
  663. archive = await archive_factory(printer.id, print_name="Original Name")
  664. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"print_name": "Updated Name"})
  665. assert response.status_code == 200
  666. assert response.json()["print_name"] == "Updated Name"
  667. @pytest.mark.asyncio
  668. @pytest.mark.integration
  669. async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  670. """Verify archive notes can be updated."""
  671. printer = await printer_factory()
  672. archive = await archive_factory(printer.id)
  673. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Great print!"})
  674. assert response.status_code == 200
  675. assert response.json()["notes"] == "Great print!"
  676. @pytest.mark.asyncio
  677. @pytest.mark.integration
  678. async def test_update_archive_favorite(
  679. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  680. ):
  681. """Verify archive favorite status can be updated."""
  682. printer = await printer_factory()
  683. archive = await archive_factory(printer.id)
  684. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"is_favorite": True})
  685. assert response.status_code == 200
  686. assert response.json()["is_favorite"] is True
  687. @pytest.mark.asyncio
  688. @pytest.mark.integration
  689. async def test_update_archive_external_url(
  690. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  691. ):
  692. """Verify archive external_url can be updated."""
  693. printer = await printer_factory()
  694. archive = await archive_factory(printer.id)
  695. response = await async_client.patch(
  696. f"/api/v1/archives/{archive.id}", json={"external_url": "https://printables.com/model/12345"}
  697. )
  698. assert response.status_code == 200
  699. assert response.json()["external_url"] == "https://printables.com/model/12345"
  700. # Verify it can be cleared
  701. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"external_url": None})
  702. assert response.status_code == 200
  703. assert response.json()["external_url"] is None
  704. @pytest.mark.asyncio
  705. @pytest.mark.integration
  706. async def test_filament_grams_can_be_set_by_hand(
  707. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  708. ):
  709. """#1820: a print that archived without its 3MF has no filament figure
  710. and no way to recover one — rescan needs a file this archive does not
  711. have. The edit is the only route, so it has to reach the log entry too:
  712. the Projects roll-up and the Prometheus counter sum PrintLogEntry, not
  713. the archive.
  714. """
  715. from sqlalchemy import select
  716. from backend.app.models.print_log import PrintLogEntry
  717. printer = await printer_factory()
  718. archive = await archive_factory(printer.id, print_name="JOB_C")
  719. response = await async_client.patch(
  720. f"/api/v1/archives/{archive.id}",
  721. json={"filament_used_grams": 46.16},
  722. )
  723. assert response.status_code == 200, response.text
  724. assert response.json()["filament_used_grams"] == 46.16
  725. mirrored = (
  726. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  727. ).scalar_one()
  728. assert mirrored.filament_used_grams == 46.16
  729. @pytest.mark.asyncio
  730. @pytest.mark.integration
  731. async def test_clearing_the_figure_takes_the_mirrored_copy_with_it(
  732. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  733. ):
  734. """Undo has to be as complete as the correction: a run that only holds
  735. the figure because this archive gave it one must not keep it after the
  736. archive's is cleared, or the totals stay wrong with nothing on the card
  737. to explain them."""
  738. from sqlalchemy import select
  739. from backend.app.models.print_log import PrintLogEntry
  740. printer = await printer_factory()
  741. archive = await archive_factory(printer.id, filament_used_grams=None)
  742. entry = (
  743. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  744. ).scalar_one()
  745. assert entry.filament_used_grams is None
  746. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"filament_used_grams": 46.16})
  747. await db_session.refresh(entry)
  748. assert entry.filament_used_grams == 46.16
  749. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"filament_used_grams": None})
  750. assert response.status_code == 200, response.text
  751. await db_session.refresh(entry)
  752. assert entry.filament_used_grams is None
  753. @pytest.mark.asyncio
  754. @pytest.mark.integration
  755. async def test_a_measured_run_keeps_its_own_filament_figure(
  756. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  757. ):
  758. """A run whose grams came from the tracked spool delta measured itself.
  759. The archive's number is an estimate, so an edit of the estimate must not
  760. overwrite the measurement — the mirror only fills in a run that has no
  761. figure, or one that was copied from this archive in the first place.
  762. """
  763. from sqlalchemy import select
  764. from backend.app.models.print_log import PrintLogEntry
  765. printer = await printer_factory()
  766. archive = await archive_factory(printer.id, filament_used_grams=50.0)
  767. entry = (
  768. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  769. ).scalar_one()
  770. entry.filament_used_grams = 48.2 # what the spool actually lost
  771. await db_session.commit()
  772. response = await async_client.patch(
  773. f"/api/v1/archives/{archive.id}",
  774. json={"filament_used_grams": 61.0},
  775. )
  776. assert response.status_code == 200, response.text
  777. assert response.json()["filament_used_grams"] == 61.0
  778. await db_session.refresh(entry)
  779. assert entry.filament_used_grams == 48.2
  780. @pytest.mark.asyncio
  781. @pytest.mark.integration
  782. async def test_filament_grams_refuses_a_negative_figure(
  783. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  784. ):
  785. """It feeds the filament totals, so a value that would subtract from
  786. them is rejected rather than stored."""
  787. printer = await printer_factory()
  788. archive = await archive_factory(printer.id)
  789. response = await async_client.patch(
  790. f"/api/v1/archives/{archive.id}",
  791. json={"filament_used_grams": -5},
  792. )
  793. assert response.status_code == 422, response.text
  794. @pytest.mark.asyncio
  795. @pytest.mark.integration
  796. async def test_an_edit_that_leaves_filament_grams_alone_does_not_clear_it(
  797. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  798. ):
  799. """The field is optional on the schema, so an ordinary save of some
  800. other field must not read as "set grams to null"."""
  801. printer = await printer_factory()
  802. archive = await archive_factory(printer.id, filament_used_grams=12.5)
  803. response = await async_client.patch(
  804. f"/api/v1/archives/{archive.id}",
  805. json={"notes": "left the grams alone"},
  806. )
  807. assert response.status_code == 200, response.text
  808. assert response.json()["filament_used_grams"] == 12.5
  809. @pytest.mark.asyncio
  810. @pytest.mark.integration
  811. async def test_items_printed_accepts_zero_for_a_ruined_plate(
  812. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  813. ):
  814. """A jam can ruin every part on the plate while the printer still
  815. reports success (#3051). The project's completed-items count sums this
  816. column, so zero has to be storable, not floored to one.
  817. """
  818. printer = await printer_factory()
  819. archive = await archive_factory(printer.id, quantity=4)
  820. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": 0})
  821. assert response.status_code == 200, response.text
  822. assert response.json()["quantity"] == 0
  823. await db_session.refresh(archive)
  824. assert archive.quantity == 0
  825. @pytest.mark.asyncio
  826. @pytest.mark.integration
  827. async def test_items_printed_refuses_a_negative_count(
  828. self, async_client: AsyncClient, archive_factory, printer_factory
  829. ):
  830. """Zero means "nothing came off the plate"; below that would subtract
  831. from the project totals this column feeds."""
  832. printer = await printer_factory()
  833. archive = await archive_factory(printer.id, quantity=4)
  834. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"quantity": -1})
  835. assert response.status_code == 422, response.text
  836. @pytest.mark.asyncio
  837. @pytest.mark.integration
  838. async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
  839. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  840. ):
  841. """#1444: PATCH /archives/{id} with failure_reason must mirror to the
  842. latest PrintLogEntry so the Stats page Failure Analysis widget
  843. (which reads PrintLogEntry.failure_reason) reflects the user's
  844. reclassification instead of showing "Unknown" forever.
  845. """
  846. from sqlalchemy import select
  847. from backend.app.models.print_log import PrintLogEntry
  848. printer = await printer_factory()
  849. # archive_factory auto-creates a matching PrintLogEntry (failure_reason
  850. # carried from the archive, which is NULL here — same shape as the bug
  851. # repro: print completed → log entry written with NULL → user goes to
  852. # classify the failure afterwards).
  853. archive = await archive_factory(printer.id, print_name="Failed Print", status="failed", run_status="failed")
  854. response = await async_client.patch(
  855. f"/api/v1/archives/{archive.id}",
  856. json={"failure_reason": "Adhesion failure"},
  857. )
  858. assert response.status_code == 200
  859. result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  860. mirrored = result.scalar_one()
  861. assert mirrored.failure_reason == "Adhesion failure"
  862. @pytest.mark.asyncio
  863. @pytest.mark.integration
  864. async def test_update_archive_status_mirrors_to_print_log_entry(
  865. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  866. ):
  867. """#1444: PATCH /archives/{id} with status must mirror to the latest
  868. PrintLogEntry so stats that filter on PrintLogEntry.status see the
  869. user's reclassification.
  870. """
  871. from sqlalchemy import select
  872. from backend.app.models.print_log import PrintLogEntry
  873. printer = await printer_factory()
  874. archive = await archive_factory(printer.id, run_status="completed")
  875. response = await async_client.patch(
  876. f"/api/v1/archives/{archive.id}",
  877. json={"status": "failed"},
  878. )
  879. assert response.status_code == 200
  880. result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  881. mirrored = result.scalar_one()
  882. assert mirrored.status == "failed"
  883. @pytest.mark.asyncio
  884. @pytest.mark.integration
  885. async def test_update_archive_failure_reason_only_touches_latest_entry(
  886. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  887. ):
  888. """#1444: For an archive with multiple runs (reprints), only the
  889. latest PrintLogEntry should receive the reclassification. Earlier
  890. runs were classified at their own time and must not be retroactively
  891. overwritten.
  892. """
  893. from backend.app.models.print_log import PrintLogEntry
  894. printer = await printer_factory()
  895. # First run — created by the factory's auto-run with its own reason.
  896. archive = await archive_factory(printer.id, status="failed", run_status="failed")
  897. from sqlalchemy import select
  898. first_run = (
  899. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  900. ).scalar_one()
  901. first_run.failure_reason = "Filament tangle"
  902. await db_session.commit()
  903. # Second run — the reprint that just finished with NULL classification.
  904. latest_run = PrintLogEntry(archive_id=archive.id, status="failed", failure_reason=None)
  905. db_session.add(latest_run)
  906. await db_session.commit()
  907. response = await async_client.patch(
  908. f"/api/v1/archives/{archive.id}",
  909. json={"failure_reason": "Adhesion failure"},
  910. )
  911. assert response.status_code == 200
  912. await db_session.refresh(first_run)
  913. await db_session.refresh(latest_run)
  914. assert first_run.failure_reason == "Filament tangle"
  915. assert latest_run.failure_reason == "Adhesion failure"
  916. # ========================================================================
  917. # Delete endpoints
  918. # ========================================================================
  919. @pytest.mark.asyncio
  920. @pytest.mark.integration
  921. async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  922. """Verify archive can be deleted."""
  923. printer = await printer_factory()
  924. archive = await archive_factory(printer.id)
  925. archive_id = archive.id
  926. response = await async_client.delete(f"/api/v1/archives/{archive_id}")
  927. assert response.status_code == 200
  928. # Verify deleted
  929. response = await async_client.get(f"/api/v1/archives/{archive_id}")
  930. assert response.status_code == 404
  931. @pytest.mark.asyncio
  932. @pytest.mark.integration
  933. async def test_delete_nonexistent_archive(self, async_client: AsyncClient):
  934. """Verify deleting non-existent archive returns 404."""
  935. response = await async_client.delete("/api/v1/archives/9999")
  936. assert response.status_code == 404
  937. @pytest.mark.asyncio
  938. @pytest.mark.integration
  939. async def test_delete_archive_blocked_when_related_queue_item_printing(
  940. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  941. ):
  942. """#1734: archive delete must 409 when a related queue item is currently
  943. mid-print — deleting the archive would strip the dispatcher's metadata
  944. trail (filament / plate / ams_mapping) out from under the running print.
  945. Both soft and hard delete are gated by the same precondition.
  946. """
  947. from backend.app.models.print_queue import PrintQueueItem
  948. printer = await printer_factory()
  949. archive = await archive_factory(printer.id)
  950. db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=1))
  951. await db_session.commit()
  952. soft = await async_client.delete(f"/api/v1/archives/{archive.id}")
  953. assert soft.status_code == 409
  954. assert "printing" in soft.json()["detail"].lower()
  955. hard = await async_client.delete(f"/api/v1/archives/{archive.id}?purge_stats=true")
  956. assert hard.status_code == 409
  957. @pytest.mark.asyncio
  958. @pytest.mark.integration
  959. async def test_archive_delete_impact_reports_counts(
  960. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  961. ):
  962. """#1734: the delete-impact pre-flight endpoint reports the total
  963. number of related queue items AND how many are currently printing,
  964. so the frontend can both warn the user before they confirm AND
  965. disable the confirm button when the printing count is non-zero.
  966. """
  967. from backend.app.models.print_queue import PrintQueueItem
  968. printer = await printer_factory()
  969. archive = await archive_factory(printer.id)
  970. # Build a mixed-status set the way a Send All upload + later in-flight
  971. # dispatch looks at the wire (#1733).
  972. db_session.add_all(
  973. [
  974. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=1),
  975. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="pending", position=2),
  976. PrintQueueItem(printer_id=printer.id, archive_id=archive.id, status="printing", position=3),
  977. ]
  978. )
  979. # An unrelated archive's queue rows must not bleed into the count.
  980. other = await archive_factory(printer.id)
  981. db_session.add(PrintQueueItem(printer_id=printer.id, archive_id=other.id, status="pending", position=4))
  982. await db_session.commit()
  983. resp = await async_client.get(f"/api/v1/archives/{archive.id}/delete-impact")
  984. assert resp.status_code == 200
  985. body = resp.json()
  986. assert body["related_queue_items"] == 3
  987. assert body["currently_printing"] == 1
  988. @pytest.mark.asyncio
  989. @pytest.mark.integration
  990. async def test_soft_delete_preserves_stats_contribution(
  991. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  992. ):
  993. """#1343: deleting an archive without ``purge_stats`` keeps its
  994. contribution in Quick Stats. The row vanishes from listings but the
  995. filament / time / cost totals stay intact.
  996. """
  997. printer = await printer_factory()
  998. await archive_factory(
  999. printer.id,
  1000. status="completed",
  1001. print_time_seconds=3600,
  1002. filament_used_grams=50.0,
  1003. cost=1.50,
  1004. )
  1005. archive_to_delete = await archive_factory(
  1006. printer.id,
  1007. status="completed",
  1008. print_time_seconds=7200,
  1009. filament_used_grams=100.0,
  1010. cost=3.00,
  1011. )
  1012. # Pre-delete: stats include both archives.
  1013. pre = (await async_client.get("/api/v1/archives/stats")).json()
  1014. assert pre["total_prints"] == 2
  1015. assert pre["total_filament_grams"] == 150.0
  1016. assert pre["total_cost"] == 4.50
  1017. # Soft delete (default — no purge_stats param).
  1018. resp = await async_client.delete(f"/api/v1/archives/{archive_to_delete.id}")
  1019. assert resp.status_code == 200
  1020. body = resp.json()
  1021. assert body["purged_from_stats"] is False
  1022. # Listing hides the deleted archive…
  1023. listing = (await async_client.get("/api/v1/archives/")).json()
  1024. assert all(a["id"] != archive_to_delete.id for a in listing)
  1025. # …but stats still reflect both prints (the whole point of #1343).
  1026. post = (await async_client.get("/api/v1/archives/stats")).json()
  1027. assert post["total_prints"] == 2
  1028. assert post["total_filament_grams"] == 150.0
  1029. assert post["total_cost"] == 4.50
  1030. @pytest.mark.asyncio
  1031. @pytest.mark.integration
  1032. async def test_soft_delete_clears_thumbnail_path_on_linked_log_entries(
  1033. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1034. ):
  1035. """#1348 follow-up: soft-deleting an archive removes its files from disk;
  1036. the cached thumbnail_path on linked PrintLogEntry rows must be NULLed
  1037. in the same transaction so the print-log view doesn't 404-storm on the
  1038. now-deleted thumbnail file."""
  1039. from sqlalchemy import select
  1040. from backend.app.models.print_log import PrintLogEntry
  1041. printer = await printer_factory()
  1042. archive = await archive_factory(
  1043. printer.id,
  1044. status="completed",
  1045. thumbnail_path="archives/test/test_print/thumbnail.png",
  1046. )
  1047. # The factory's auto-PrintLogEntry doesn't copy thumbnail_path; set it
  1048. # manually to mirror what the production write_log_entry path stores.
  1049. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1050. run = run_query.scalar_one()
  1051. run.thumbnail_path = "archives/test/test_print/thumbnail.png"
  1052. await db_session.commit()
  1053. assert run.thumbnail_path is not None
  1054. resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
  1055. assert resp.status_code == 200
  1056. assert resp.json()["purged_from_stats"] is False
  1057. await db_session.refresh(run)
  1058. assert run.thumbnail_path is None, "soft-delete must NULL thumbnail_path on linked log entry"
  1059. # The log entry itself survives the soft delete (its filament/cost
  1060. # contribution still needs to flow into stats per #1343).
  1061. assert run.id is not None
  1062. assert run.archive_id == archive.id
  1063. @pytest.mark.asyncio
  1064. @pytest.mark.integration
  1065. async def test_hard_delete_clears_thumbnail_path_before_fk_cascade(
  1066. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1067. ):
  1068. """#1348 follow-up: the auto-purge sweeper (and any caller of
  1069. ArchiveService.delete_archive) hard-deletes the archive row but leaves
  1070. PrintLogEntry rows alive via ON DELETE SET NULL. The eager
  1071. thumbnail_path clear must run inside delete_archive so even orphaned
  1072. log entries don't surface stale paths."""
  1073. from sqlalchemy import select
  1074. from backend.app.models.print_log import PrintLogEntry
  1075. from backend.app.services.archive import ArchiveService
  1076. printer = await printer_factory()
  1077. archive = await archive_factory(
  1078. printer.id,
  1079. status="completed",
  1080. thumbnail_path="archives/test/test_print/thumbnail.png",
  1081. )
  1082. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1083. run = run_query.scalar_one()
  1084. run.thumbnail_path = "archives/test/test_print/thumbnail.png"
  1085. await db_session.commit()
  1086. run_id = run.id
  1087. service = ArchiveService(db_session)
  1088. assert await service.delete_archive(archive.id) is True
  1089. # Log entry survives the hard-delete (the FK is ON DELETE SET NULL
  1090. # in production; SQLite test config doesn't enable foreign_keys=ON
  1091. # by default so archive_id may still be set, but the row itself
  1092. # remains for audit). The thumbnail_path was cleared eagerly by
  1093. # _null_print_log_thumbnail_paths before db.delete(archive).
  1094. refetch = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.id == run_id))
  1095. survivor = refetch.scalar_one()
  1096. assert survivor.thumbnail_path is None, (
  1097. "delete_archive must NULL thumbnail_path before removing the archive row"
  1098. )
  1099. @pytest.mark.asyncio
  1100. @pytest.mark.integration
  1101. async def test_print_log_thumbnail_route_lazy_nulls_missing_file(
  1102. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1103. ):
  1104. """#1348 follow-up: GET /print-log/{id}/thumbnail self-heals when the
  1105. thumbnail_path on a log entry points at a missing file (failed print
  1106. whose thumbnail was never written, or a stale path that escaped the
  1107. delete-time cleanup)."""
  1108. from sqlalchemy import select
  1109. from backend.app.models.print_log import PrintLogEntry
  1110. printer = await printer_factory()
  1111. archive = await archive_factory(printer.id, status="failed")
  1112. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1113. run = run_query.scalar_one()
  1114. # Path points at a file that never existed (failed-print case where
  1115. # archive.thumbnail_path was set but the extractor never produced one).
  1116. run.thumbnail_path = "archives/missing/never_written/thumbnail.png"
  1117. await db_session.commit()
  1118. # Auth is disabled in the integration test config, so the stream-token
  1119. # guard is bypassed — the route runs the lazy-NULL branch directly.
  1120. resp = await async_client.get(f"/api/v1/print-log/{run.id}/thumbnail")
  1121. assert resp.status_code == 404
  1122. await db_session.refresh(run)
  1123. assert run.thumbnail_path is None, "missing file must self-heal to NULL"
  1124. @pytest.mark.asyncio
  1125. @pytest.mark.integration
  1126. async def test_purge_stats_drops_archive_from_quick_stats(
  1127. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1128. ):
  1129. """#1343: deleting with ``?purge_stats=true`` hard-deletes the row,
  1130. dropping its contribution from Quick Stats (the original behaviour,
  1131. now opt-in)."""
  1132. printer = await printer_factory()
  1133. keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
  1134. purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
  1135. resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
  1136. assert resp.status_code == 200
  1137. assert resp.json()["purged_from_stats"] is True
  1138. stats = (await async_client.get("/api/v1/archives/stats")).json()
  1139. assert stats["total_prints"] == 1
  1140. assert stats["total_filament_grams"] == 50.0
  1141. # The kept archive is still listed.
  1142. listing = (await async_client.get("/api/v1/archives/")).json()
  1143. assert [a["id"] for a in listing] == [keep.id]
  1144. @pytest.mark.asyncio
  1145. @pytest.mark.integration
  1146. async def test_soft_deleted_archive_404_on_detail(
  1147. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1148. ):
  1149. """A soft-deleted archive must 404 on GET — a stale bookmark or
  1150. direct URL should not expose a row the user has already removed."""
  1151. printer = await printer_factory()
  1152. archive = await archive_factory(printer.id)
  1153. await async_client.delete(f"/api/v1/archives/{archive.id}")
  1154. resp = await async_client.get(f"/api/v1/archives/{archive.id}")
  1155. assert resp.status_code == 404
  1156. @pytest.mark.asyncio
  1157. @pytest.mark.integration
  1158. async def test_soft_deleted_archive_hidden_from_search(
  1159. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1160. ):
  1161. """Search must skip soft-deleted archives. Uses the LIKE fallback by
  1162. querying a single-character pattern that the SQLite FTS5 rejects, so
  1163. the test covers the fallback path that the production FTS path also
  1164. respects."""
  1165. printer = await printer_factory()
  1166. archive = await archive_factory(printer.id, print_name="UniqueSoftDeleteCandidate")
  1167. await async_client.delete(f"/api/v1/archives/{archive.id}")
  1168. resp = await async_client.get("/api/v1/archives/search?q=UniqueSoftDeleteCandidate")
  1169. assert resp.status_code == 200
  1170. assert resp.json() == []
  1171. # ========================================================================
  1172. # Statistics endpoints
  1173. # ========================================================================
  1174. @pytest.mark.asyncio
  1175. @pytest.mark.integration
  1176. async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  1177. """Verify archive statistics can be retrieved."""
  1178. printer = await printer_factory()
  1179. await archive_factory(
  1180. printer.id,
  1181. status="completed",
  1182. print_time_seconds=3600,
  1183. filament_used_grams=50.0,
  1184. )
  1185. await archive_factory(
  1186. printer.id,
  1187. status="completed",
  1188. print_time_seconds=7200,
  1189. filament_used_grams=100.0,
  1190. )
  1191. response = await async_client.get("/api/v1/archives/stats")
  1192. assert response.status_code == 200
  1193. result = response.json()
  1194. # Check for actual stats fields
  1195. assert "total_prints" in result
  1196. assert "successful_prints" in result
  1197. class TestNo3MFWarning:
  1198. """`GET /archives/no-3mf-warning` — install step 4 reactive nudge.
  1199. The connection diagnostic's external_storage check only catches the
  1200. printer-side variant of the setting (newer firmware). For older slicers
  1201. where the toggle lives only in BambuStudio, the printer never reports
  1202. it. The fallback path in main.py creates the archive with
  1203. extra_data.no_3mf_available=True; this endpoint exposes that as a
  1204. boolean so the frontend can surface a one-time banner.
  1205. """
  1206. @pytest.mark.asyncio
  1207. @pytest.mark.integration
  1208. async def test_returns_true_when_recent_fallback_exists(
  1209. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1210. ):
  1211. printer = await printer_factory()
  1212. await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  1213. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1214. assert response.status_code == 200
  1215. assert response.json() == {"has_fallback": True, "reason": None}
  1216. @pytest.mark.asyncio
  1217. @pytest.mark.integration
  1218. async def test_returns_false_when_no_archives(self, async_client: AsyncClient):
  1219. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1220. assert response.status_code == 200
  1221. assert response.json() == {"has_fallback": False, "reason": None}
  1222. @pytest.mark.asyncio
  1223. @pytest.mark.integration
  1224. async def test_returns_false_when_only_normal_archives(
  1225. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1226. ):
  1227. printer = await printer_factory()
  1228. # extra_data has other keys but no_3mf_available is absent — normal
  1229. # archives must not trigger the nudge.
  1230. await archive_factory(printer.id, extra_data={"makerworld_url": "https://example"})
  1231. await archive_factory(printer.id, extra_data=None)
  1232. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1233. assert response.status_code == 200
  1234. assert response.json() == {"has_fallback": False, "reason": None}
  1235. @pytest.mark.asyncio
  1236. @pytest.mark.integration
  1237. async def test_ignores_archives_older_than_30_days(
  1238. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1239. ):
  1240. from datetime import datetime, timedelta, timezone
  1241. from backend.app.models.archive import PrintArchive
  1242. printer = await printer_factory()
  1243. archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  1244. # Backdate past the 30-day window — old fallbacks are forgiven.
  1245. archive.created_at = datetime.now(timezone.utc) - timedelta(days=45)
  1246. await db_session.commit()
  1247. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1248. assert response.status_code == 200
  1249. assert response.json() == {"has_fallback": False, "reason": None}
  1250. # Sanity: row really is in the DB, we just don't surface it.
  1251. assert (await db_session.get(PrintArchive, archive.id)) is not None
  1252. @pytest.mark.asyncio
  1253. @pytest.mark.integration
  1254. async def test_ignores_soft_deleted_fallbacks(
  1255. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1256. ):
  1257. from datetime import datetime, timezone
  1258. printer = await printer_factory()
  1259. archive = await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  1260. archive.deleted_at = datetime.now(timezone.utc)
  1261. await db_session.commit()
  1262. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1263. assert response.status_code == 200
  1264. # Soft-deleted fallbacks have been actioned (user clearing the
  1265. # evidence). Stop nudging.
  1266. assert response.json() == {"has_fallback": False, "reason": None}
  1267. class TestNo3MFWarningReason:
  1268. """Which explanation the banner shows (#2780).
  1269. The endpoint used to return a bare boolean and the banner had one wording:
  1270. "Store sent files on external storage" is off in the slicer, go turn it on.
  1271. For H2-series and P2S that is wrong twice over -- the setting is already on
  1272. and turning it on changes nothing, because the printer keeps the file on
  1273. internal storage FTPS does not serve. Sending people to re-do a step that
  1274. cannot help is worse than saying nothing, so the reason travels with the
  1275. flag.
  1276. """
  1277. @pytest.mark.asyncio
  1278. @pytest.mark.integration
  1279. async def test_internal_storage_is_reported(
  1280. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1281. ):
  1282. printer = await printer_factory()
  1283. await archive_factory(
  1284. printer.id,
  1285. extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"},
  1286. )
  1287. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1288. assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
  1289. @pytest.mark.asyncio
  1290. @pytest.mark.integration
  1291. async def test_an_empty_slot_is_reported(
  1292. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1293. ):
  1294. printer = await printer_factory()
  1295. await archive_factory(
  1296. printer.id,
  1297. extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"},
  1298. )
  1299. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1300. assert response.json() == {"has_fallback": True, "reason": "no_external_storage"}
  1301. @pytest.mark.asyncio
  1302. @pytest.mark.integration
  1303. async def test_a_known_reason_outranks_archives_that_carry_none(
  1304. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1305. ):
  1306. """Every archive written before this field exists carries no reason,
  1307. and a farm usually has more than one printer. One H2C among four
  1308. printers should still get the H2C explanation rather than have it
  1309. drowned out by older rows.
  1310. """
  1311. printer = await printer_factory()
  1312. await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  1313. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
  1314. await archive_factory(printer.id, extra_data={"no_3mf_available": True})
  1315. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1316. assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
  1317. @pytest.mark.asyncio
  1318. @pytest.mark.integration
  1319. async def test_internal_storage_outranks_an_empty_slot(
  1320. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1321. ):
  1322. """Both are real, and only one of them is surprising. "Put a card in"
  1323. is advice the user can act on unprompted; "this model keeps prints
  1324. somewhere Bambuddy cannot read" is the one they need told.
  1325. """
  1326. printer = await printer_factory()
  1327. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"})
  1328. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_storage"})
  1329. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1330. assert response.json() == {"has_fallback": True, "reason": "internal_storage"}
  1331. @pytest.mark.asyncio
  1332. @pytest.mark.integration
  1333. async def test_a_reason_on_a_non_fallback_archive_is_not_reported(
  1334. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1335. ):
  1336. """The reason only means anything alongside the flag it explains."""
  1337. printer = await printer_factory()
  1338. await archive_factory(printer.id, extra_data={"no_3mf_reason": "internal_storage"})
  1339. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1340. assert response.json() == {"has_fallback": False, "reason": None}
  1341. @pytest.mark.asyncio
  1342. @pytest.mark.integration
  1343. async def test_a_refused_handshake_is_reported(
  1344. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1345. ):
  1346. """#2957 stamps this slug and #2780 never taught the banner about it, so
  1347. an install whose only empty archives came from a printer refusing TLS on
  1348. port 990 was told the slicer had not written the file to the card. The
  1349. slicer had; nothing could read it back. The reporter could see the file
  1350. on the stick from his own computer, which is exactly why the advice read
  1351. as Bambuddy being broken.
  1352. """
  1353. printer = await printer_factory()
  1354. await archive_factory(
  1355. printer.id,
  1356. extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"},
  1357. )
  1358. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1359. assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
  1360. @pytest.mark.asyncio
  1361. @pytest.mark.integration
  1362. async def test_a_refused_handshake_outranks_every_settled_cause(
  1363. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1364. ):
  1365. """The other three describe an install working as configured. This one
  1366. reports a printer doing something we cannot yet explain, and the banner
  1367. dismisses one-shot into localStorage -- so a reason ranked below another
  1368. is not deferred, it is never shown to that user at all.
  1369. """
  1370. printer = await printer_factory()
  1371. for reason in ("internal_storage", "no_external_storage", "internal_history"):
  1372. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": reason})
  1373. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"})
  1374. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1375. assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
  1376. @pytest.mark.asyncio
  1377. @pytest.mark.integration
  1378. async def test_the_settled_causes_keep_their_order_behind_it(
  1379. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1380. ):
  1381. """Adding a new leader must not disturb the ranking underneath it: an
  1382. install with no refused handshake still gets exactly what it got before.
  1383. """
  1384. printer = await printer_factory()
  1385. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "internal_history"})
  1386. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "no_external_storage"})
  1387. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1388. assert response.json() == {"has_fallback": True, "reason": "no_external_storage"}
  1389. @pytest.mark.asyncio
  1390. @pytest.mark.integration
  1391. async def test_a_transfer_that_ran_out_of_time_is_reported(
  1392. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1393. ):
  1394. """#3063's P1S had the file on its card and served it three times in the
  1395. two minutes after the archive flow gave up on it. Told to switch on
  1396. "Store sent files on external storage", that reporter would be switching
  1397. on a setting that was already on and had already worked.
  1398. """
  1399. printer = await printer_factory()
  1400. await archive_factory(
  1401. printer.id,
  1402. extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"},
  1403. )
  1404. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1405. assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
  1406. @pytest.mark.asyncio
  1407. @pytest.mark.integration
  1408. async def test_a_slow_transfer_outranks_the_settled_causes_but_not_a_refused_handshake(
  1409. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1410. ):
  1411. """Both temporary causes describe a file still sitting on the card, so
  1412. both outrank the three that describe an install working as configured.
  1413. Between the two, a printer that will not complete a TLS handshake is the
  1414. worse fault and keeps the banner.
  1415. """
  1416. printer = await printer_factory()
  1417. for reason in ("internal_storage", "no_external_storage", "internal_history"):
  1418. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": reason})
  1419. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftp_transfer_failed"})
  1420. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1421. assert response.json() == {"has_fallback": True, "reason": "ftp_transfer_failed"}
  1422. await archive_factory(printer.id, extra_data={"no_3mf_available": True, "no_3mf_reason": "ftps_cooloff"})
  1423. response = await async_client.get("/api/v1/archives/no-3mf-warning")
  1424. assert response.json() == {"has_fallback": True, "reason": "ftps_cooloff"}
  1425. class TestPrintLogEntryDelete:
  1426. """#1687: per-row delete on the Print Log page.
  1427. Pin the route's three contracts: (1) deleting a row drops its filament
  1428. / cost / count contribution from /archives/stats in the same response
  1429. cycle; (2) the matching archive (if any) is untouched; (3) missing IDs
  1430. return 404 rather than 200-silently.
  1431. """
  1432. @pytest.mark.asyncio
  1433. @pytest.mark.integration
  1434. async def test_delete_print_log_entry_drops_from_stats(
  1435. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1436. ):
  1437. from sqlalchemy import select
  1438. from backend.app.models.print_log import PrintLogEntry
  1439. printer = await printer_factory()
  1440. keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
  1441. drop = await archive_factory(printer.id, status="completed", filament_used_grams=125.0)
  1442. drop_run = (
  1443. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == drop.id))
  1444. ).scalar_one()
  1445. resp = await async_client.delete(f"/api/v1/print-log/{drop_run.id}")
  1446. assert resp.status_code == 200
  1447. assert resp.json()["status"] == "deleted"
  1448. assert resp.json()["id"] == drop_run.id
  1449. # The linked archive survives — the row was a stats row, not the archive.
  1450. listing = (await async_client.get("/api/v1/archives/")).json()
  1451. assert {a["id"] for a in listing} == {keep.id, drop.id}
  1452. # /stats no longer counts the dropped run's filament contribution.
  1453. stats = (await async_client.get("/api/v1/archives/stats")).json()
  1454. assert stats["total_prints"] == 1
  1455. assert stats["total_filament_grams"] == 50.0
  1456. @pytest.mark.asyncio
  1457. @pytest.mark.integration
  1458. async def test_delete_print_log_entry_404_when_missing(self, async_client: AsyncClient):
  1459. resp = await async_client.delete("/api/v1/print-log/999999")
  1460. assert resp.status_code == 404
  1461. @pytest.mark.asyncio
  1462. @pytest.mark.integration
  1463. async def test_delete_print_log_entry_does_not_clear_others(
  1464. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1465. ):
  1466. """Deleting one row must not touch siblings — guard against an accidental
  1467. ``delete(PrintLogEntry)`` without a ``where`` clause (cf. clear_print_log
  1468. which intentionally drops everything)."""
  1469. from sqlalchemy import select
  1470. from backend.app.models.print_log import PrintLogEntry
  1471. printer = await printer_factory()
  1472. a = await archive_factory(printer.id, status="completed", filament_used_grams=10.0)
  1473. b = await archive_factory(printer.id, status="completed", filament_used_grams=20.0)
  1474. c = await archive_factory(printer.id, status="completed", filament_used_grams=30.0)
  1475. runs = {r.archive_id: r for r in (await db_session.execute(select(PrintLogEntry))).scalars().all()}
  1476. resp = await async_client.delete(f"/api/v1/print-log/{runs[b.id].id}")
  1477. assert resp.status_code == 200
  1478. survivors = (await db_session.execute(select(PrintLogEntry.archive_id))).scalars().all()
  1479. assert set(survivors) == {a.id, c.id}
  1480. class TestPrintLogEntryUpdate:
  1481. """Tests for ``PATCH /print-log/{entry_id}`` (#1687 part 4).
  1482. Pin the route's contracts: (1) GET serialiser actually surfaces
  1483. ``failure_reason`` (previously it was silently dropped from the response
  1484. even when set in the DB); (2) PATCH persists ``failure_reason`` and
  1485. ``status``; (3) unknown vocabulary returns 400 rather than getting stored
  1486. as raw garbage; (4) missing IDs return 404.
  1487. """
  1488. @pytest.mark.asyncio
  1489. @pytest.mark.integration
  1490. async def test_get_surfaces_failure_reason(
  1491. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1492. ):
  1493. """Pre-fix the GET endpoint built PrintLogEntrySchema without
  1494. ``failure_reason`` even though the column was populated, so the Print
  1495. Log table couldn't render what the Failure Analysis widget already
  1496. groups by. Regression guard for the silent-drop bug.
  1497. """
  1498. from sqlalchemy import select
  1499. from backend.app.models.print_log import PrintLogEntry
  1500. printer = await printer_factory()
  1501. archive = await archive_factory(printer.id, status="failed")
  1502. entry = (
  1503. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1504. ).scalar_one()
  1505. entry.failure_reason = "spaghettiDetached"
  1506. await db_session.commit()
  1507. body = (await async_client.get("/api/v1/print-log/")).json()
  1508. match = next(item for item in body["items"] if item["id"] == entry.id)
  1509. assert match["failure_reason"] == "spaghettiDetached"
  1510. # archive_id should also flow through so the frontend can tell orphan
  1511. # entries apart from archive-linked ones.
  1512. assert match["archive_id"] == archive.id
  1513. @pytest.mark.asyncio
  1514. @pytest.mark.integration
  1515. async def test_patch_sets_failure_reason(
  1516. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1517. ):
  1518. from sqlalchemy import select
  1519. from backend.app.models.print_log import PrintLogEntry
  1520. printer = await printer_factory()
  1521. archive = await archive_factory(printer.id, status="failed")
  1522. entry = (
  1523. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1524. ).scalar_one()
  1525. assert entry.failure_reason is None
  1526. resp = await async_client.patch(
  1527. f"/api/v1/print-log/{entry.id}",
  1528. json={"failure_reason": "cloggedNozzle"},
  1529. )
  1530. assert resp.status_code == 200
  1531. assert resp.json()["failure_reason"] == "cloggedNozzle"
  1532. await db_session.refresh(entry)
  1533. assert entry.failure_reason == "cloggedNozzle"
  1534. @pytest.mark.asyncio
  1535. @pytest.mark.integration
  1536. async def test_patch_can_clear_failure_reason(
  1537. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1538. ):
  1539. """Empty-string failure_reason stores back as NULL (the column's
  1540. nullable=True intent is preserved end-to-end)."""
  1541. from sqlalchemy import select
  1542. from backend.app.models.print_log import PrintLogEntry
  1543. printer = await printer_factory()
  1544. archive = await archive_factory(printer.id, status="failed")
  1545. entry = (
  1546. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1547. ).scalar_one()
  1548. entry.failure_reason = "warping"
  1549. await db_session.commit()
  1550. resp = await async_client.patch(
  1551. f"/api/v1/print-log/{entry.id}",
  1552. json={"failure_reason": ""},
  1553. )
  1554. assert resp.status_code == 200
  1555. assert resp.json()["failure_reason"] is None
  1556. await db_session.refresh(entry)
  1557. assert entry.failure_reason is None
  1558. @pytest.mark.asyncio
  1559. @pytest.mark.integration
  1560. async def test_patch_rejects_unknown_failure_reason(
  1561. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1562. ):
  1563. """Unknown values must 400 — otherwise the UI would render raw garbage
  1564. because the i18n layer maps the value back through the canonical
  1565. vocabulary."""
  1566. from sqlalchemy import select
  1567. from backend.app.models.print_log import PrintLogEntry
  1568. printer = await printer_factory()
  1569. archive = await archive_factory(printer.id, status="failed")
  1570. entry = (
  1571. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1572. ).scalar_one()
  1573. resp = await async_client.patch(
  1574. f"/api/v1/print-log/{entry.id}",
  1575. json={"failure_reason": "completely-made-up"},
  1576. )
  1577. assert resp.status_code == 400
  1578. @pytest.mark.asyncio
  1579. @pytest.mark.integration
  1580. async def test_patch_updates_status(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  1581. from sqlalchemy import select
  1582. from backend.app.models.print_log import PrintLogEntry
  1583. printer = await printer_factory()
  1584. archive = await archive_factory(printer.id, status="completed")
  1585. entry = (
  1586. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1587. ).scalar_one()
  1588. entry.status = "completed"
  1589. await db_session.commit()
  1590. resp = await async_client.patch(
  1591. f"/api/v1/print-log/{entry.id}",
  1592. json={"status": "failed", "failure_reason": "layerShift"},
  1593. )
  1594. assert resp.status_code == 200
  1595. assert resp.json()["status"] == "failed"
  1596. assert resp.json()["failure_reason"] == "layerShift"
  1597. @pytest.mark.asyncio
  1598. @pytest.mark.integration
  1599. async def test_patch_rejects_unknown_status(
  1600. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1601. ):
  1602. from sqlalchemy import select
  1603. from backend.app.models.print_log import PrintLogEntry
  1604. printer = await printer_factory()
  1605. archive = await archive_factory(printer.id, status="failed")
  1606. entry = (
  1607. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  1608. ).scalar_one()
  1609. resp = await async_client.patch(
  1610. f"/api/v1/print-log/{entry.id}",
  1611. json={"status": "bogus-status"},
  1612. )
  1613. assert resp.status_code == 400
  1614. @pytest.mark.asyncio
  1615. @pytest.mark.integration
  1616. async def test_patch_404_when_missing(self, async_client: AsyncClient):
  1617. resp = await async_client.patch(
  1618. "/api/v1/print-log/999999",
  1619. json={"failure_reason": "cloggedNozzle"},
  1620. )
  1621. assert resp.status_code == 404
  1622. @pytest.mark.asyncio
  1623. @pytest.mark.integration
  1624. async def test_patch_works_on_orphan_entry(self, async_client: AsyncClient, printer_factory, db_session):
  1625. """Orphan log entries (no archive_id) are the actual reason this
  1626. endpoint exists — the Archive Edit modal can't reach them. Make sure
  1627. the PATCH works for those rows specifically."""
  1628. from backend.app.models.print_log import PrintLogEntry
  1629. printer = await printer_factory()
  1630. orphan = PrintLogEntry(
  1631. archive_id=None,
  1632. print_name="failed-before-archive-created",
  1633. printer_id=printer.id,
  1634. status="failed",
  1635. failure_reason=None,
  1636. )
  1637. db_session.add(orphan)
  1638. await db_session.commit()
  1639. await db_session.refresh(orphan)
  1640. assert orphan.archive_id is None
  1641. resp = await async_client.patch(
  1642. f"/api/v1/print-log/{orphan.id}",
  1643. json={"failure_reason": "powerFailure"},
  1644. )
  1645. assert resp.status_code == 200
  1646. assert resp.json()["failure_reason"] == "powerFailure"
  1647. assert resp.json()["archive_id"] is None
  1648. class TestArchivesSlimAPI:
  1649. """Integration tests for /api/v1/archives/slim endpoint."""
  1650. @pytest.mark.asyncio
  1651. @pytest.mark.integration
  1652. async def test_slim_empty(self, async_client: AsyncClient):
  1653. """Verify empty list when no archives exist."""
  1654. response = await async_client.get("/api/v1/archives/slim")
  1655. assert response.status_code == 200
  1656. assert response.json() == []
  1657. @pytest.mark.asyncio
  1658. @pytest.mark.integration
  1659. async def test_slim_returns_only_expected_fields(
  1660. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1661. ):
  1662. """Verify response contains only slim fields, not full archive data."""
  1663. printer = await printer_factory()
  1664. await archive_factory(
  1665. printer.id,
  1666. print_name="Slim Test",
  1667. status="completed",
  1668. filament_type="PLA",
  1669. filament_color="#FF0000",
  1670. filament_used_grams=50.0,
  1671. print_time_seconds=3600,
  1672. cost=1.50,
  1673. quantity=2,
  1674. )
  1675. response = await async_client.get("/api/v1/archives/slim")
  1676. assert response.status_code == 200
  1677. data = response.json()
  1678. assert len(data) == 1
  1679. item = data[0]
  1680. # Expected fields present
  1681. assert item["printer_id"] == printer.id
  1682. assert item["print_name"] == "Slim Test"
  1683. assert item["status"] == "completed"
  1684. assert item["filament_type"] == "PLA"
  1685. assert item["filament_color"] == "#FF0000"
  1686. assert item["filament_used_grams"] == 50.0
  1687. assert item["print_time_seconds"] == 3600
  1688. assert item["cost"] == 1.50
  1689. # quantity is per-event semantics now (each PrintLogEntry = one run);
  1690. # the archive's quantity field is no longer surfaced through this
  1691. # endpoint after the #1390 per-event migration.
  1692. assert item["quantity"] == 1
  1693. assert "created_at" in item
  1694. # Full archive fields must NOT be present
  1695. assert "id" not in item
  1696. assert "filename" not in item
  1697. assert "file_path" not in item
  1698. assert "file_size" not in item
  1699. assert "extra_data" not in item
  1700. assert "notes" not in item
  1701. assert "tags" not in item
  1702. assert "photos" not in item
  1703. assert "thumbnail_path" not in item
  1704. assert "content_hash" not in item
  1705. assert "duplicates" not in item
  1706. assert "duplicate_count" not in item
  1707. @pytest.mark.asyncio
  1708. @pytest.mark.integration
  1709. async def test_slim_includes_energy_fields(
  1710. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1711. ):
  1712. """Per-print smart-plug energy surfaces through /slim so the stats
  1713. page can include it in cost records and trends (#1432)."""
  1714. printer = await printer_factory()
  1715. await archive_factory(
  1716. printer.id,
  1717. status="completed",
  1718. cost=1.50,
  1719. energy_kwh=0.421,
  1720. energy_cost=0.063,
  1721. )
  1722. response = await async_client.get("/api/v1/archives/slim")
  1723. assert response.status_code == 200
  1724. item = response.json()[0]
  1725. assert item["energy_kwh"] == 0.421
  1726. assert item["energy_cost"] == 0.063
  1727. @pytest.mark.asyncio
  1728. @pytest.mark.integration
  1729. async def test_slim_computes_actual_time(
  1730. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1731. ):
  1732. """Verify actual_time_seconds is computed from started_at/completed_at."""
  1733. from datetime import datetime, timezone
  1734. printer = await printer_factory()
  1735. started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
  1736. completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # 2 hours = 7200s
  1737. await archive_factory(
  1738. printer.id,
  1739. status="completed",
  1740. started_at=started,
  1741. completed_at=completed,
  1742. )
  1743. response = await async_client.get("/api/v1/archives/slim")
  1744. assert response.status_code == 200
  1745. item = response.json()[0]
  1746. assert item["actual_time_seconds"] == 7200
  1747. @pytest.mark.asyncio
  1748. @pytest.mark.integration
  1749. async def test_slim_actual_time_for_failed_includes_elapsed(
  1750. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1751. ):
  1752. """Failed prints report measured elapsed time so Printer Stats By Time
  1753. matches Quick Stats Print Time (#1390). Previously this returned null
  1754. and the frontend fell back to the slicer estimate, double-counting the
  1755. unfinished portion of the print."""
  1756. from datetime import datetime, timezone
  1757. printer = await printer_factory()
  1758. await archive_factory(
  1759. printer.id,
  1760. status="failed",
  1761. started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
  1762. completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
  1763. )
  1764. response = await async_client.get("/api/v1/archives/slim")
  1765. assert response.status_code == 200
  1766. item = response.json()[0]
  1767. assert item["actual_time_seconds"] == 3600
  1768. @pytest.mark.asyncio
  1769. @pytest.mark.integration
  1770. async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  1771. """Verify date_from and date_to filters work."""
  1772. from datetime import datetime, timezone
  1773. printer = await printer_factory()
  1774. await archive_factory(
  1775. printer.id,
  1776. print_name="Old Print",
  1777. created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
  1778. )
  1779. await archive_factory(
  1780. printer.id,
  1781. print_name="New Print",
  1782. created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
  1783. )
  1784. # Filter to only June 2024
  1785. response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
  1786. assert response.status_code == 200
  1787. data = response.json()
  1788. assert len(data) == 1
  1789. assert data[0]["print_name"] == "New Print"
  1790. @pytest.mark.asyncio
  1791. @pytest.mark.integration
  1792. async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  1793. """Verify limit and offset work."""
  1794. printer = await printer_factory()
  1795. for i in range(5):
  1796. await archive_factory(printer.id, print_name=f"Print {i}")
  1797. response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
  1798. assert response.status_code == 200
  1799. assert len(response.json()) == 2
  1800. @pytest.mark.asyncio
  1801. @pytest.mark.integration
  1802. async def test_slim_counts_reprints_as_separate_rows(
  1803. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1804. ):
  1805. """Reprints add events even though the archive row is overwritten (#1390).
  1806. Before the per-event migration, /archives/slim returned one row per
  1807. archive — so an archive that had been reprinted three times appeared
  1808. once and undercounted Filament Used / Cost / Time. The endpoint must
  1809. now return one row per logged event.
  1810. """
  1811. from backend.app.models.print_log import PrintLogEntry
  1812. printer = await printer_factory()
  1813. archive = await archive_factory(
  1814. printer.id,
  1815. print_name="Reprinted Model",
  1816. filament_used_grams=50.0,
  1817. cost=1.50,
  1818. )
  1819. # archive_factory synthesizes one event; add two more to simulate
  1820. # the same archive being reprinted twice more.
  1821. for _ in range(2):
  1822. db_session.add(
  1823. PrintLogEntry(
  1824. archive_id=archive.id,
  1825. printer_id=archive.printer_id,
  1826. status="completed",
  1827. filament_type=archive.filament_type,
  1828. filament_used_grams=archive.filament_used_grams,
  1829. cost=archive.cost,
  1830. print_name=archive.print_name,
  1831. )
  1832. )
  1833. await db_session.commit()
  1834. response = await async_client.get("/api/v1/archives/slim")
  1835. assert response.status_code == 200
  1836. data = response.json()
  1837. assert len(data) == 3, "Each reprint must contribute one row"
  1838. total_filament = sum(item["filament_used_grams"] or 0 for item in data)
  1839. assert total_filament == 150.0, "Sum across events must reflect all three runs"
  1840. @pytest.mark.asyncio
  1841. @pytest.mark.integration
  1842. async def test_slim_includes_orphan_events(self, async_client: AsyncClient, printer_factory, db_session):
  1843. """Events whose archive was hard-deleted still appear (#1390).
  1844. After ON DELETE SET NULL the event row survives with archive_id=NULL.
  1845. The slim endpoint must keep counting it so Quick Stats and the
  1846. archive-iterating widgets stay aligned.
  1847. """
  1848. from backend.app.models.print_log import PrintLogEntry
  1849. printer = await printer_factory()
  1850. db_session.add(
  1851. PrintLogEntry(
  1852. archive_id=None,
  1853. printer_id=printer.id,
  1854. status="completed",
  1855. filament_type="PETG",
  1856. filament_used_grams=25.0,
  1857. cost=0.75,
  1858. print_name="Orphaned Print",
  1859. )
  1860. )
  1861. await db_session.commit()
  1862. response = await async_client.get("/api/v1/archives/slim")
  1863. assert response.status_code == 200
  1864. data = response.json()
  1865. assert len(data) == 1
  1866. assert data[0]["print_name"] == "Orphaned Print"
  1867. assert data[0]["filament_used_grams"] == 25.0
  1868. # print_time_seconds (sliced estimate) comes from the archive table,
  1869. # which orphans no longer have — must surface as null gracefully.
  1870. assert data[0]["print_time_seconds"] is None
  1871. class TestFailureAnalysisAPI:
  1872. """Per-event failure analysis (#1390)."""
  1873. @pytest.mark.asyncio
  1874. @pytest.mark.integration
  1875. async def test_failure_analysis_counts_reprints_and_orphans(
  1876. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1877. ):
  1878. """Failure analysis aggregates per event, not per archive.
  1879. Verifies the dual fix for #1390: a reprint that adds a second failed
  1880. event must count twice, and an orphan failed event (archive deleted)
  1881. must still appear in the totals.
  1882. """
  1883. from backend.app.models.print_log import PrintLogEntry
  1884. printer = await printer_factory()
  1885. archive = await archive_factory(
  1886. printer.id,
  1887. print_name="Failing Model",
  1888. status="failed",
  1889. failure_reason="filament_runout",
  1890. )
  1891. # Add a second failed event for the same archive (a reprint that also
  1892. # failed) and one orphan failed event (archive was deleted).
  1893. db_session.add(
  1894. PrintLogEntry(
  1895. archive_id=archive.id,
  1896. printer_id=printer.id,
  1897. status="failed",
  1898. failure_reason="filament_runout",
  1899. filament_type=archive.filament_type,
  1900. print_name=archive.print_name,
  1901. )
  1902. )
  1903. db_session.add(
  1904. PrintLogEntry(
  1905. archive_id=None,
  1906. printer_id=printer.id,
  1907. status="failed",
  1908. failure_reason="bed_adhesion",
  1909. filament_type="PETG",
  1910. print_name="Orphaned Failed Print",
  1911. )
  1912. )
  1913. await db_session.commit()
  1914. response = await async_client.get("/api/v1/archives/analysis/failures")
  1915. assert response.status_code == 200
  1916. result = response.json()
  1917. assert result["total_prints"] == 3
  1918. assert result["failed_prints"] == 3
  1919. assert result["failures_by_reason"]["filament_runout"] == 2
  1920. assert result["failures_by_reason"]["bed_adhesion"] == 1
  1921. class TestArchiveDataIntegrity:
  1922. """Tests for archive data integrity."""
  1923. @pytest.mark.asyncio
  1924. @pytest.mark.integration
  1925. async def test_archive_linked_to_printer(
  1926. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1927. ):
  1928. """Verify archive is properly linked to printer."""
  1929. printer = await printer_factory(name="My Printer")
  1930. archive = await archive_factory(printer.id)
  1931. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1932. assert response.status_code == 200
  1933. result = response.json()
  1934. assert result["printer_id"] == printer.id
  1935. @pytest.mark.asyncio
  1936. @pytest.mark.integration
  1937. async def test_archive_stores_print_data(
  1938. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1939. ):
  1940. """Verify archive stores all print data correctly."""
  1941. printer = await printer_factory()
  1942. archive = await archive_factory(
  1943. printer.id,
  1944. print_name="Test Print",
  1945. filename="test.3mf",
  1946. status="completed",
  1947. filament_type="PLA",
  1948. filament_used_grams=75.5,
  1949. print_time_seconds=5400,
  1950. )
  1951. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1952. assert response.status_code == 200
  1953. result = response.json()
  1954. assert result["print_name"] == "Test Print"
  1955. assert result["filename"] == "test.3mf"
  1956. assert result["status"] == "completed"
  1957. assert result["filament_type"] == "PLA"
  1958. assert result["filament_used_grams"] == 75.5
  1959. assert result["print_time_seconds"] == 5400
  1960. @pytest.mark.asyncio
  1961. @pytest.mark.integration
  1962. async def test_archive_update_persists(
  1963. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1964. ):
  1965. """CRITICAL: Verify archive updates persist."""
  1966. printer = await printer_factory()
  1967. archive = await archive_factory(printer.id, notes="Original notes")
  1968. # Update
  1969. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
  1970. # Verify persistence
  1971. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1972. result = response.json()
  1973. assert result["notes"] == "Updated notes"
  1974. assert result["is_favorite"] is True
  1975. class TestArchiveF3DEndpoints:
  1976. """Tests for F3D (Fusion 360 design file) attachment endpoints."""
  1977. @pytest.mark.asyncio
  1978. @pytest.mark.integration
  1979. async def test_archive_response_includes_f3d_path(
  1980. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1981. ):
  1982. """Verify f3d_path is included in archive response."""
  1983. printer = await printer_factory()
  1984. archive = await archive_factory(printer.id, f3d_path="archives/test/design.f3d")
  1985. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1986. assert response.status_code == 200
  1987. result = response.json()
  1988. assert "f3d_path" in result
  1989. assert result["f3d_path"] == "archives/test/design.f3d"
  1990. @pytest.mark.asyncio
  1991. @pytest.mark.integration
  1992. async def test_archive_response_f3d_path_null_when_not_set(
  1993. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  1994. ):
  1995. """Verify f3d_path is null when no F3D file attached."""
  1996. printer = await printer_factory()
  1997. archive = await archive_factory(printer.id)
  1998. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  1999. assert response.status_code == 200
  2000. result = response.json()
  2001. assert "f3d_path" in result
  2002. assert result["f3d_path"] is None
  2003. @pytest.mark.asyncio
  2004. @pytest.mark.integration
  2005. async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):
  2006. """Verify 404 when uploading F3D to non-existent archive."""
  2007. # Create a minimal file-like upload
  2008. files = {"file": ("design.f3d", b"fake f3d content", "application/octet-stream")}
  2009. response = await async_client.post("/api/v1/archives/9999/f3d", files=files)
  2010. assert response.status_code == 404
  2011. @pytest.mark.asyncio
  2012. @pytest.mark.integration
  2013. async def test_download_f3d_not_found_when_no_file(
  2014. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  2015. ):
  2016. """Verify 404 when downloading F3D from archive without F3D file."""
  2017. printer = await printer_factory()
  2018. archive = await archive_factory(printer.id)
  2019. response = await async_client.get(f"/api/v1/archives/{archive.id}/f3d")
  2020. assert response.status_code == 404
  2021. @pytest.mark.asyncio
  2022. @pytest.mark.integration
  2023. async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):
  2024. """Verify 404 when downloading F3D from non-existent archive."""
  2025. response = await async_client.get("/api/v1/archives/9999/f3d")
  2026. assert response.status_code == 404
  2027. @pytest.mark.asyncio
  2028. @pytest.mark.integration
  2029. async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):
  2030. """Verify 404 when deleting F3D from non-existent archive."""
  2031. response = await async_client.delete("/api/v1/archives/9999/f3d")
  2032. assert response.status_code == 404
  2033. @pytest.mark.asyncio
  2034. @pytest.mark.integration
  2035. async def test_delete_f3d_when_no_file(
  2036. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  2037. ):
  2038. """Verify 404 when deleting F3D from archive without F3D file."""
  2039. printer = await printer_factory()
  2040. archive = await archive_factory(printer.id)
  2041. response = await async_client.delete(f"/api/v1/archives/{archive.id}/f3d")
  2042. assert response.status_code == 404
  2043. @pytest.mark.asyncio
  2044. @pytest.mark.integration
  2045. async def test_list_archives_includes_f3d_path(
  2046. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  2047. ):
  2048. """Verify f3d_path is included in archive list responses."""
  2049. printer = await printer_factory()
  2050. await archive_factory(printer.id, print_name="With F3D", f3d_path="archives/test/design.f3d")
  2051. await archive_factory(printer.id, print_name="Without F3D")
  2052. response = await async_client.get("/api/v1/archives/")
  2053. assert response.status_code == 200
  2054. data = response.json()
  2055. assert len(data) >= 2
  2056. with_f3d = next((a for a in data if a["print_name"] == "With F3D"), None)
  2057. without_f3d = next((a for a in data if a["print_name"] == "Without F3D"), None)
  2058. assert with_f3d is not None
  2059. assert with_f3d["f3d_path"] == "archives/test/design.f3d"
  2060. assert without_f3d is not None
  2061. assert without_f3d["f3d_path"] is None
  2062. # ========================================================================
  2063. # Multi-Plate 3MF endpoints (Issue #93)
  2064. # ========================================================================
  2065. @pytest.mark.asyncio
  2066. @pytest.mark.integration
  2067. async def test_get_archive_plates_not_found(self, async_client: AsyncClient):
  2068. """Verify 404 when fetching plates for non-existent archive."""
  2069. response = await async_client.get("/api/v1/archives/999999/plates")
  2070. assert response.status_code == 404
  2071. @pytest.mark.asyncio
  2072. @pytest.mark.integration
  2073. async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):
  2074. """Verify 404 when fetching plate thumbnail for non-existent archive."""
  2075. response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1")
  2076. assert response.status_code == 404
  2077. @pytest.mark.asyncio
  2078. @pytest.mark.integration
  2079. async def test_filament_requirements_not_found(self, async_client: AsyncClient):
  2080. """Verify filament-requirements returns 404 for non-existent archive."""
  2081. response = await async_client.get("/api/v1/archives/999999/filament-requirements")
  2082. assert response.status_code == 404
  2083. @pytest.mark.asyncio
  2084. @pytest.mark.integration
  2085. async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):
  2086. """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
  2087. response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
  2088. assert response.status_code == 404
  2089. async def _two_plate_archive(self, archive_factory, printer_factory, tmp_path):
  2090. """An archive whose 3MF stores plate 2 ahead of plate 1, as Studio writes it."""
  2091. import zipfile
  2092. printer = await printer_factory()
  2093. path = tmp_path / "two_plates.gcode.3mf"
  2094. with zipfile.ZipFile(path, "w") as zf:
  2095. zf.writestr("Metadata/plate_2.gcode", "; plate two\nG28\n")
  2096. zf.writestr("Metadata/plate_1.gcode", "; plate one\nG28\n")
  2097. # An absolute file_path collapses `settings.base_dir / file_path` onto
  2098. # itself, so the route reads the file written here.
  2099. return await archive_factory(printer.id, file_path=str(path), filename="two_plates.gcode.3mf")
  2100. @pytest.mark.asyncio
  2101. @pytest.mark.integration
  2102. async def test_archive_gcode_without_a_plate_serves_the_first_plate(
  2103. self, async_client: AsyncClient, archive_factory, printer_factory, tmp_path
  2104. ):
  2105. """Zip order is whatever the slicer wrote, so the first member here is
  2106. plate 2. Callers that pass no plate must still land on plate 1."""
  2107. archive = await self._two_plate_archive(archive_factory, printer_factory, tmp_path)
  2108. response = await async_client.get(f"/api/v1/archives/{archive.id}/gcode")
  2109. assert response.status_code == 200
  2110. assert "plate one" in response.text
  2111. @pytest.mark.asyncio
  2112. @pytest.mark.integration
  2113. async def test_archive_gcode_serves_the_requested_plate(
  2114. self, async_client: AsyncClient, archive_factory, printer_factory, tmp_path
  2115. ):
  2116. archive = await self._two_plate_archive(archive_factory, printer_factory, tmp_path)
  2117. first = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=1")
  2118. second = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=2")
  2119. assert "plate one" in first.text
  2120. assert "plate two" in second.text
  2121. @pytest.mark.asyncio
  2122. @pytest.mark.integration
  2123. async def test_archive_gcode_rejects_a_plate_the_file_does_not_hold(
  2124. self, async_client: AsyncClient, archive_factory, printer_factory, tmp_path
  2125. ):
  2126. archive = await self._two_plate_archive(archive_factory, printer_factory, tmp_path)
  2127. missing = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=3")
  2128. zeroth = await async_client.get(f"/api/v1/archives/{archive.id}/gcode?plate=0")
  2129. assert missing.status_code == 404
  2130. assert zeroth.status_code == 400
  2131. # ========================================================================
  2132. # Tag Management endpoints (Issue #183)
  2133. # ========================================================================
  2134. @pytest.mark.asyncio
  2135. @pytest.mark.integration
  2136. async def test_get_tags_empty(self, async_client: AsyncClient):
  2137. """Verify empty list when no tags exist."""
  2138. response = await async_client.get("/api/v1/archives/tags")
  2139. assert response.status_code == 200
  2140. data = response.json()
  2141. assert isinstance(data, list)
  2142. assert len(data) == 0
  2143. @pytest.mark.asyncio
  2144. @pytest.mark.integration
  2145. async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  2146. """Verify tags are returned with counts."""
  2147. printer = await printer_factory()
  2148. await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
  2149. await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
  2150. await archive_factory(printer.id, print_name="Archive 3", tags="test")
  2151. response = await async_client.get("/api/v1/archives/tags")
  2152. assert response.status_code == 200
  2153. data = response.json()
  2154. assert isinstance(data, list)
  2155. # Convert to dict for easier lookup
  2156. tags_dict = {t["name"]: t["count"] for t in data}
  2157. assert tags_dict.get("functional") == 2
  2158. assert tags_dict.get("test") == 2
  2159. assert tags_dict.get("calibration") == 1
  2160. @pytest.mark.asyncio
  2161. @pytest.mark.integration
  2162. async def test_get_tags_sorted_by_count(
  2163. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  2164. ):
  2165. """Verify tags are sorted by count descending, then by name."""
  2166. printer = await printer_factory()
  2167. await archive_factory(printer.id, tags="alpha")
  2168. await archive_factory(printer.id, tags="beta, alpha")
  2169. await archive_factory(printer.id, tags="gamma, beta, alpha")
  2170. response = await async_client.get("/api/v1/archives/tags")
  2171. assert response.status_code == 200
  2172. data = response.json()
  2173. # alpha=3, beta=2, gamma=1
  2174. assert data[0]["name"] == "alpha"
  2175. assert data[0]["count"] == 3
  2176. assert data[1]["name"] == "beta"
  2177. assert data[1]["count"] == 2
  2178. assert data[2]["name"] == "gamma"
  2179. assert data[2]["count"] == 1
  2180. @pytest.mark.asyncio
  2181. @pytest.mark.integration
  2182. async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  2183. """Verify renaming a tag updates all archives."""
  2184. printer = await printer_factory()
  2185. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
  2186. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
  2187. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  2188. response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
  2189. assert response.status_code == 200
  2190. data = response.json()
  2191. assert data["affected"] == 2
  2192. # Verify the archives were updated
  2193. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  2194. assert "new-tag" in response.json()["tags"]
  2195. assert "old-tag" not in response.json()["tags"]
  2196. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  2197. assert response.json()["tags"] == "new-tag"
  2198. @pytest.mark.asyncio
  2199. @pytest.mark.integration
  2200. async def test_rename_tag_no_change(self, async_client: AsyncClient):
  2201. """Verify renaming to same name returns 0 affected."""
  2202. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
  2203. assert response.status_code == 200
  2204. assert response.json()["affected"] == 0
  2205. @pytest.mark.asyncio
  2206. @pytest.mark.integration
  2207. async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
  2208. """Verify renaming to empty name returns error."""
  2209. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
  2210. assert response.status_code == 400
  2211. @pytest.mark.asyncio
  2212. @pytest.mark.integration
  2213. async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  2214. """Verify deleting a tag removes it from all archives."""
  2215. printer = await printer_factory()
  2216. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
  2217. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
  2218. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  2219. response = await async_client.delete("/api/v1/archives/tags/delete-me")
  2220. assert response.status_code == 200
  2221. data = response.json()
  2222. assert data["affected"] == 2
  2223. # Verify the archives were updated
  2224. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  2225. assert response.json()["tags"] == "keep"
  2226. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  2227. # Should be None or empty when last tag is removed
  2228. assert response.json()["tags"] is None or response.json()["tags"] == ""
  2229. @pytest.mark.asyncio
  2230. @pytest.mark.integration
  2231. async def test_delete_tag_not_found(self, async_client: AsyncClient):
  2232. """Verify deleting non-existent tag returns 0 affected."""
  2233. response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
  2234. assert response.status_code == 200
  2235. assert response.json()["affected"] == 0
  2236. class TestUploadSourceThreeMF:
  2237. """Regression for #1531: source-3MF upload on fallback archives."""
  2238. @staticmethod
  2239. def _minimal_3mf_bytes() -> bytes:
  2240. """Smallest valid .3mf — the upload path enforces a zip header check."""
  2241. import io
  2242. import zipfile
  2243. buf = io.BytesIO()
  2244. with zipfile.ZipFile(buf, "w") as zf:
  2245. zf.writestr("[Content_Types].xml", "<types/>")
  2246. return buf.getvalue()
  2247. @pytest.mark.asyncio
  2248. @pytest.mark.integration
  2249. async def test_fallback_archive_source_upload_lands_under_base_dir(
  2250. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  2251. ):
  2252. """Fallback archive (file_path='') must accept a source upload and store it inside base_dir.
  2253. Pre-fix, ``Path(base_dir) / ''`` collapsed to ``base_dir`` and the
  2254. ``.parent`` walked out of the data volume, sending the file to
  2255. ``/app/source/...`` and crashing on ``relative_to``.
  2256. """
  2257. from backend.app.core.config import settings as app_settings
  2258. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  2259. printer = await printer_factory()
  2260. archive = await archive_factory(
  2261. printer.id,
  2262. print_name="Cloud Print",
  2263. file_path="", # fallback archive — no source 3MF was archived
  2264. filename="Cloud Print.3mf",
  2265. )
  2266. files = {"file": ("cloud_print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  2267. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  2268. assert response.status_code == 200, response.text
  2269. payload = response.json()
  2270. rel = payload["source_3mf_path"]
  2271. # Stored as a relative path inside base_dir.
  2272. assert not rel.startswith("/"), f"source_3mf_path should be relative, got {rel!r}"
  2273. # File physically landed under base_dir (NOT escaped to /app/source/).
  2274. assert (tmp_path / rel).is_file()
  2275. # Deterministic fallback location keyed off archive id.
  2276. assert rel == f"archive/no_source/{archive.id}/cloud_print.3mf"
  2277. @pytest.mark.asyncio
  2278. @pytest.mark.integration
  2279. async def test_normal_archive_source_upload_unchanged(
  2280. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  2281. ):
  2282. """Normal archive (file_path set) still nests the source under <archive>/source/."""
  2283. from backend.app.core.config import settings as app_settings
  2284. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  2285. printer = await printer_factory()
  2286. # archive_factory's default file_path is "archives/test/test_print.gcode.3mf".
  2287. archive = await archive_factory(printer.id, print_name="Real Print")
  2288. files = {"file": ("real_print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  2289. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  2290. assert response.status_code == 200, response.text
  2291. rel = response.json()["source_3mf_path"]
  2292. assert rel == "archives/test/source/real_print.3mf"
  2293. assert (tmp_path / rel).is_file()
  2294. @pytest.mark.asyncio
  2295. @pytest.mark.integration
  2296. async def test_symlinked_data_dir_upload_succeeds(
  2297. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  2298. ):
  2299. """Regression: DATA_DIR that's a symlink to the real storage must not break the upload.
  2300. Common on TrueNAS / Synology / QNAP storage pools, and any
  2301. ``-v /symlinked/host/path:/app/data`` mount. The helper resolves
  2302. only for the containment check and returns literal paths so the
  2303. caller's ``relative_to(settings.base_dir)`` doesn't trip over a
  2304. canonical-vs-symlink mismatch.
  2305. """
  2306. from backend.app.core.config import settings as app_settings
  2307. real_dir = tmp_path / "real_storage"
  2308. real_dir.mkdir()
  2309. symlink_dir = tmp_path / "data_via_symlink"
  2310. symlink_dir.symlink_to(real_dir)
  2311. monkeypatch.setattr(app_settings, "base_dir", symlink_dir)
  2312. printer = await printer_factory()
  2313. archive = await archive_factory(
  2314. printer.id,
  2315. print_name="Symlinked Print",
  2316. file_path="archives/X1C/print.gcode.3mf",
  2317. filename="print.gcode.3mf",
  2318. )
  2319. files = {"file": ("print.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  2320. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  2321. assert response.status_code == 200, response.text
  2322. rel = response.json()["source_3mf_path"]
  2323. assert rel == "archives/X1C/source/print.3mf"
  2324. # Reachable via both the symlink and the canonical path.
  2325. assert (symlink_dir / rel).is_file()
  2326. assert (real_dir / rel).is_file()
  2327. @pytest.mark.asyncio
  2328. @pytest.mark.integration
  2329. async def test_absolute_file_path_rejected_with_clear_500(
  2330. self, async_client: AsyncClient, archive_factory, printer_factory, monkeypatch, tmp_path
  2331. ):
  2332. """A row whose file_path is absolute (corrupted by old import / manual edit)
  2333. must fail with the explicit "outside the data directory" message, not silently
  2334. write outside base_dir."""
  2335. from backend.app.core.config import settings as app_settings
  2336. monkeypatch.setattr(app_settings, "base_dir", tmp_path)
  2337. printer = await printer_factory()
  2338. archive = await archive_factory(
  2339. printer.id,
  2340. print_name="Corrupt Path",
  2341. file_path="/tmp/totally_outside.gcode.3mf", # nosec B108
  2342. filename="totally_outside.gcode.3mf",
  2343. )
  2344. files = {"file": ("totally_outside.3mf", self._minimal_3mf_bytes(), "application/octet-stream")}
  2345. response = await async_client.post(f"/api/v1/archives/{archive.id}/source", files=files)
  2346. assert response.status_code == 500
  2347. assert "outside the data directory" in response.json()["detail"]
  2348. # Did not write anything under the bogus /tmp/source/ either.
  2349. assert not (Path("/tmp") / "source").exists() or not (Path("/tmp") / "source" / "totally_outside.3mf").exists() # nosec B108
  2350. class TestSoftDeletedArchivesAreExcluded:
  2351. """Soft-deleted archives (#1343) must not leak into export or analysis (#2731).
  2352. The soft delete keeps the row so global Quick Stats can still count it, but
  2353. the archive is gone from every listing. Two consumers never got the memo:
  2354. the CSV export handed back rows the UI says do not exist, and per-project
  2355. failure analysis kept counting prints the user had deleted from the project
  2356. — disagreeing with the project's own figures.
  2357. """
  2358. @staticmethod
  2359. async def _soft_delete(db_session, archive) -> int:
  2360. from datetime import datetime, timezone
  2361. archive_id = archive.id
  2362. archive.deleted_at = datetime.now(timezone.utc)
  2363. await db_session.commit()
  2364. return archive_id
  2365. @pytest.mark.asyncio
  2366. @pytest.mark.integration
  2367. async def test_export_omits_soft_deleted_archives(
  2368. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  2369. ):
  2370. printer = await printer_factory()
  2371. await archive_factory(printer.id, print_name="Kept Print")
  2372. gone = await archive_factory(printer.id, print_name="Deleted Print")
  2373. await self._soft_delete(db_session, gone)
  2374. response = await async_client.get("/api/v1/archives/export?format=csv")
  2375. assert response.status_code == 200
  2376. body = response.text
  2377. assert "Kept Print" in body
  2378. assert "Deleted Print" not in body
  2379. @pytest.mark.asyncio
  2380. @pytest.mark.integration
  2381. async def test_project_failure_analysis_omits_soft_deleted_archives(
  2382. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  2383. ):
  2384. from backend.app.models.project import Project
  2385. project = Project(name="Analysis Project")
  2386. db_session.add(project)
  2387. await db_session.commit()
  2388. await db_session.refresh(project)
  2389. project_id = project.id
  2390. printer = await printer_factory()
  2391. await archive_factory(
  2392. printer.id,
  2393. print_name="Kept Failure",
  2394. status="failed",
  2395. failure_reason="bed_adhesion",
  2396. project_id=project_id,
  2397. )
  2398. gone = await archive_factory(
  2399. printer.id,
  2400. print_name="Deleted Failure",
  2401. status="failed",
  2402. failure_reason="filament_runout",
  2403. project_id=project_id,
  2404. )
  2405. await self._soft_delete(db_session, gone)
  2406. response = await async_client.get(f"/api/v1/archives/analysis/failures?project_id={project_id}")
  2407. assert response.status_code == 200
  2408. result = response.json()
  2409. assert result["failed_prints"] == 1
  2410. assert result["failures_by_reason"] == {"bed_adhesion": 1}
  2411. @pytest.mark.asyncio
  2412. @pytest.mark.integration
  2413. async def test_unscoped_failure_analysis_is_unchanged(
  2414. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  2415. ):
  2416. """Only the project-scoped path filters. Global analysis still counts
  2417. every run, including orphans, exactly as #1390 established."""
  2418. printer = await printer_factory()
  2419. gone = await archive_factory(
  2420. printer.id, print_name="Deleted Failure", status="failed", failure_reason="filament_runout"
  2421. )
  2422. await self._soft_delete(db_session, gone)
  2423. response = await async_client.get("/api/v1/archives/analysis/failures")
  2424. assert response.status_code == 200
  2425. assert response.json()["failed_prints"] == 1
  2426. class TestPrintLogSorting:
  2427. """#2636: the Print Log's column headers sort the whole log.
  2428. Sorting is server-side because paging is: ordering the rows the browser
  2429. happens to hold would answer "the most expensive print on this page",
  2430. not "the most expensive print". These pin the ordering contract the
  2431. headers depend on, including the two cases that differ per database
  2432. backend or per query plan if left implicit.
  2433. """
  2434. @staticmethod
  2435. async def _seed(db_session, printer_id: int, rows: list[dict]):
  2436. from datetime import datetime
  2437. from backend.app.models.print_log import PrintLogEntry
  2438. created = []
  2439. for i, row in enumerate(rows):
  2440. entry = PrintLogEntry(
  2441. printer_id=printer_id,
  2442. status=row.get("status", "completed"),
  2443. print_name=row.get("print_name", f"job-{i}"),
  2444. started_at=datetime(2026, 1, 1 + i, 12, 0, 0),
  2445. created_at=datetime(2026, 1, 1 + i, 12, 0, 0),
  2446. filament_used_grams=row.get("grams"),
  2447. cost=row.get("cost"),
  2448. energy_kwh=row.get("kwh"),
  2449. )
  2450. db_session.add(entry)
  2451. created.append(entry)
  2452. await db_session.commit()
  2453. return created
  2454. @pytest.mark.asyncio
  2455. @pytest.mark.integration
  2456. async def test_sorts_by_filament_used_in_both_directions(
  2457. self, async_client: AsyncClient, printer_factory, db_session
  2458. ):
  2459. printer = await printer_factory()
  2460. await self._seed(
  2461. db_session,
  2462. printer.id,
  2463. [{"grams": 5.0}, {"grams": 120.0}, {"grams": 30.0}],
  2464. )
  2465. asc = await async_client.get("/api/v1/print-log/?sort_by=filament_used&sort_dir=asc")
  2466. assert asc.status_code == 200
  2467. assert [e["filament_used_grams"] for e in asc.json()["items"]] == [5.0, 30.0, 120.0]
  2468. desc = await async_client.get("/api/v1/print-log/?sort_by=filament_used&sort_dir=desc")
  2469. assert [e["filament_used_grams"] for e in desc.json()["items"]] == [120.0, 30.0, 5.0]
  2470. @pytest.mark.asyncio
  2471. @pytest.mark.integration
  2472. async def test_empty_values_sort_last_whichever_direction(
  2473. self, async_client: AsyncClient, printer_factory, db_session
  2474. ):
  2475. """Cost is NULL until a spool is priced and energy until a smart plug
  2476. reports, so these columns are half-empty for most people. Postgres
  2477. sorts NULLs high and SQLite sorts them low, so without an explicit
  2478. NULLS LAST the same click gives a different first page depending on
  2479. which database the user deployed — and on one of them, a screenful
  2480. of blanks."""
  2481. printer = await printer_factory()
  2482. await self._seed(
  2483. db_session,
  2484. printer.id,
  2485. [{"cost": None}, {"cost": 2.5}, {"cost": None}, {"cost": 0.75}],
  2486. )
  2487. for direction, expected in (("asc", [0.75, 2.5]), ("desc", [2.5, 0.75])):
  2488. resp = await async_client.get(f"/api/v1/print-log/?sort_by=cost&sort_dir={direction}")
  2489. costs = [e["cost"] for e in resp.json()["items"]]
  2490. assert costs[:2] == expected, (direction, costs)
  2491. assert costs[2:] == [None, None], (direction, costs)
  2492. @pytest.mark.asyncio
  2493. @pytest.mark.integration
  2494. async def test_ties_are_broken_stably_across_pages(self, async_client: AsyncClient, printer_factory, db_session):
  2495. """Sorting by a column where every row shares a value (status) leaves
  2496. the order to the planner unless a tiebreaker is added — and an
  2497. unstable order can show the same row on two pages while another never
  2498. appears at all."""
  2499. printer = await printer_factory()
  2500. await self._seed(db_session, printer.id, [{"status": "completed"} for _ in range(6)])
  2501. first = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=0")
  2502. second = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=3")
  2503. page_1 = [e["id"] for e in first.json()["items"]]
  2504. page_2 = [e["id"] for e in second.json()["items"]]
  2505. assert len(set(page_1) & set(page_2)) == 0, "a row appeared on both pages"
  2506. assert len(set(page_1) | set(page_2)) == 6, "a row was never returned"
  2507. # Repeating the same request must give the same page back.
  2508. again = await async_client.get("/api/v1/print-log/?sort_by=status&sort_dir=asc&limit=3&offset=0")
  2509. assert [e["id"] for e in again.json()["items"]] == page_1
  2510. @pytest.mark.asyncio
  2511. @pytest.mark.integration
  2512. async def test_unknown_sort_column_is_rejected(self, async_client: AsyncClient):
  2513. """The client picks the sort key, so the column list is a whitelist —
  2514. anything else would let a request order by any attribute it can name."""
  2515. resp = await async_client.get("/api/v1/print-log/?sort_by=created_by_id")
  2516. assert resp.status_code == 400
  2517. resp = await async_client.get("/api/v1/print-log/?sort_by=1;DROP")
  2518. assert resp.status_code == 400
  2519. @pytest.mark.asyncio
  2520. @pytest.mark.integration
  2521. async def test_invalid_direction_is_rejected(self, async_client: AsyncClient):
  2522. resp = await async_client.get("/api/v1/print-log/?sort_by=date&sort_dir=sideways")
  2523. assert resp.status_code == 422
  2524. @pytest.mark.asyncio
  2525. @pytest.mark.integration
  2526. async def test_defaults_to_newest_first(self, async_client: AsyncClient, printer_factory, db_session):
  2527. """No sort params — the pre-#2636 behaviour, which existing clients
  2528. and the first page load both rely on."""
  2529. printer = await printer_factory()
  2530. await self._seed(db_session, printer.id, [{"print_name": "oldest"}, {"print_name": "newest"}])
  2531. resp = await async_client.get("/api/v1/print-log/")
  2532. assert [e["print_name"] for e in resp.json()["items"]] == ["newest", "oldest"]