test_library_api.py 89 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083
  1. """Integration tests for Library API endpoints."""
  2. import io
  3. import tempfile
  4. import zipfile
  5. from pathlib import Path
  6. from uuid import uuid4
  7. import pytest
  8. from httpx import AsyncClient
  9. from sqlalchemy import select
  10. from backend.app.core.config import settings as app_settings
  11. from backend.app.models.print_queue import PrintQueueItem
  12. async def _read_queue_item(db_session, item_id: int) -> PrintQueueItem:
  13. """Re-read a queue row the route just committed through its own session."""
  14. db_session.expire_all()
  15. return (await db_session.execute(select(PrintQueueItem).where(PrintQueueItem.id == item_id))).scalar_one()
  16. class TestLibraryFoldersAPI:
  17. """Integration tests for library folders endpoints."""
  18. @pytest.fixture
  19. async def folder_factory(self, db_session):
  20. """Factory to create test folders."""
  21. _counter = [0]
  22. async def _create_folder(**kwargs):
  23. from backend.app.models.library import LibraryFolder
  24. _counter[0] += 1
  25. counter = _counter[0]
  26. defaults = {
  27. "name": f"Test Folder {counter}",
  28. }
  29. defaults.update(kwargs)
  30. folder = LibraryFolder(**defaults)
  31. db_session.add(folder)
  32. await db_session.commit()
  33. await db_session.refresh(folder)
  34. return folder
  35. return _create_folder
  36. @pytest.mark.asyncio
  37. @pytest.mark.integration
  38. async def test_list_folders_empty(self, async_client: AsyncClient, db_session):
  39. """Verify empty folder list returns empty array."""
  40. response = await async_client.get("/api/v1/library/folders")
  41. assert response.status_code == 200
  42. assert response.json() == []
  43. @pytest.mark.asyncio
  44. @pytest.mark.integration
  45. async def test_folder_tree_exposes_latest_activity_at_from_files(
  46. self, async_client: AsyncClient, folder_factory, db_session
  47. ):
  48. """#1770: folder list returns latest_activity_at = MAX(folder.updated_at,
  49. MAX(immediate-child file.updated_at)) so the frontend can sort by
  50. recent activity. Adding a file with a later updated_at must bubble it.
  51. """
  52. from datetime import datetime, timedelta
  53. from backend.app.models.library import LibraryFile
  54. folder = await folder_factory(name="Active Folder")
  55. # File whose updated_at is well after the folder's. Activity should
  56. # surface this timestamp, not the folder's stale one.
  57. future = datetime.utcnow() + timedelta(hours=24)
  58. db_session.add(
  59. LibraryFile(
  60. folder_id=folder.id,
  61. filename="model.3mf",
  62. file_path="library/model.3mf",
  63. file_type="3mf",
  64. file_size=123,
  65. updated_at=future,
  66. )
  67. )
  68. await db_session.commit()
  69. response = await async_client.get("/api/v1/library/folders")
  70. assert response.status_code == 200
  71. items = response.json()
  72. assert len(items) == 1
  73. item = items[0]
  74. assert item["id"] == folder.id
  75. assert item["latest_activity_at"] is not None
  76. # latest_activity_at should be at least the future stamp we set.
  77. assert item["latest_activity_at"] >= future.isoformat()
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_folder_tree_latest_activity_at_falls_back_to_folder_updated_at(
  81. self, async_client: AsyncClient, folder_factory, db_session
  82. ):
  83. """#1770: a folder with no files reports its own updated_at, not null —
  84. otherwise the activity sort would dump every empty folder to one end."""
  85. await folder_factory(name="Empty Folder")
  86. response = await async_client.get("/api/v1/library/folders")
  87. assert response.status_code == 200
  88. items = response.json()
  89. assert len(items) == 1
  90. item = items[0]
  91. # latest_activity_at == folder.updated_at when there are no files
  92. assert item["latest_activity_at"] is not None
  93. @pytest.mark.asyncio
  94. @pytest.mark.integration
  95. async def test_create_folder(self, async_client: AsyncClient, db_session):
  96. """Verify folder can be created."""
  97. data = {"name": "New Folder"}
  98. response = await async_client.post("/api/v1/library/folders", json=data)
  99. assert response.status_code == 200
  100. result = response.json()
  101. assert result["name"] == "New Folder"
  102. assert result["id"] is not None
  103. @pytest.mark.asyncio
  104. @pytest.mark.integration
  105. async def test_create_nested_folder(self, async_client: AsyncClient, folder_factory, db_session):
  106. """Verify nested folder can be created."""
  107. parent = await folder_factory(name="Parent")
  108. data = {"name": "Child", "parent_id": parent.id}
  109. response = await async_client.post("/api/v1/library/folders", json=data)
  110. assert response.status_code == 200
  111. result = response.json()
  112. assert result["name"] == "Child"
  113. assert result["parent_id"] == parent.id
  114. @pytest.mark.asyncio
  115. @pytest.mark.integration
  116. async def test_get_folder(self, async_client: AsyncClient, folder_factory, db_session):
  117. """Verify single folder can be retrieved."""
  118. folder = await folder_factory(name="Test Folder")
  119. response = await async_client.get(f"/api/v1/library/folders/{folder.id}")
  120. assert response.status_code == 200
  121. result = response.json()
  122. assert result["id"] == folder.id
  123. assert result["name"] == "Test Folder"
  124. @pytest.mark.asyncio
  125. @pytest.mark.integration
  126. async def test_get_folder_not_found(self, async_client: AsyncClient, db_session):
  127. """Verify 404 for non-existent folder."""
  128. response = await async_client.get("/api/v1/library/folders/9999")
  129. assert response.status_code == 404
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_update_folder(self, async_client: AsyncClient, folder_factory, db_session):
  133. """Verify folder can be updated."""
  134. folder = await folder_factory(name="Old Name")
  135. data = {"name": "New Name"}
  136. response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json=data)
  137. assert response.status_code == 200
  138. result = response.json()
  139. assert result["name"] == "New Name"
  140. @pytest.mark.asyncio
  141. @pytest.mark.integration
  142. async def test_move_folder_into_another_folder(self, async_client: AsyncClient, folder_factory, db_session):
  143. """A folder moves under another folder with parent_id (#3305)."""
  144. projects = await folder_factory(name="Projects")
  145. archive = await folder_factory(name="Archive")
  146. response = await async_client.put(f"/api/v1/library/folders/{archive.id}", json={"parent_id": projects.id})
  147. assert response.status_code == 200
  148. assert response.json()["parent_id"] == projects.id
  149. @pytest.mark.asyncio
  150. @pytest.mark.integration
  151. async def test_move_folder_to_root(self, async_client: AsyncClient, folder_factory, db_session):
  152. """parent_id 0 lifts a nested folder to the top level (#3305)."""
  153. projects = await folder_factory(name="Projects")
  154. nested = await folder_factory(name="Nested", parent_id=projects.id)
  155. response = await async_client.put(f"/api/v1/library/folders/{nested.id}", json={"parent_id": 0})
  156. assert response.status_code == 200
  157. assert response.json()["parent_id"] is None
  158. @pytest.mark.asyncio
  159. @pytest.mark.integration
  160. async def test_move_folder_into_missing_parent_is_404(self, async_client: AsyncClient, folder_factory, db_session):
  161. """A parent_id that does not exist is refused before anything changes (#3305)."""
  162. folder = await folder_factory(name="Projects")
  163. response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json={"parent_id": 9999})
  164. assert response.status_code == 404
  165. assert response.json()["detail"] == "Parent folder not found"
  166. await db_session.refresh(folder)
  167. assert folder.parent_id is None
  168. @pytest.mark.asyncio
  169. @pytest.mark.integration
  170. async def test_move_external_folder_is_refused(self, async_client: AsyncClient, folder_factory, db_session):
  171. """An external folder mirrors a path on disk and cannot be moved (#3305)."""
  172. target = await folder_factory(name="Projects")
  173. external = await folder_factory(name="Mount", is_external=True, external_path="/data/mount")
  174. for parent_id in (target.id, 0):
  175. response = await async_client.put(f"/api/v1/library/folders/{external.id}", json={"parent_id": parent_id})
  176. if parent_id == 0:
  177. # Already at the root: not a move, so it goes through.
  178. assert response.status_code == 200
  179. else:
  180. assert response.status_code == 400
  181. assert response.json()["detail"] == "External folders cannot be moved"
  182. await db_session.refresh(external)
  183. assert external.parent_id is None
  184. @pytest.mark.asyncio
  185. @pytest.mark.integration
  186. async def test_move_folder_into_external_folder_is_refused(
  187. self, async_client: AsyncClient, folder_factory, db_session
  188. ):
  189. """Nothing can be moved into an external folder (#3305)."""
  190. external = await folder_factory(name="Mount", is_external=True, external_path="/data/mount")
  191. folder = await folder_factory(name="Projects")
  192. response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json={"parent_id": external.id})
  193. assert response.status_code == 400
  194. assert response.json()["detail"] == "Cannot move a folder into an external folder"
  195. await db_session.refresh(folder)
  196. assert folder.parent_id is None
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session):
  200. """Verify folder can be deleted."""
  201. folder = await folder_factory()
  202. response = await async_client.delete(f"/api/v1/library/folders/{folder.id}")
  203. assert response.status_code == 200
  204. result = response.json()
  205. assert result.get("message") or result.get("success", True)
  206. class TestLibraryFilesAPI:
  207. """Integration tests for library files endpoints."""
  208. @pytest.fixture
  209. async def folder_factory(self, db_session):
  210. """Factory to create test folders."""
  211. _counter = [0]
  212. async def _create_folder(**kwargs):
  213. from backend.app.models.library import LibraryFolder
  214. _counter[0] += 1
  215. counter = _counter[0]
  216. defaults = {"name": f"Test Folder {counter}"}
  217. defaults.update(kwargs)
  218. folder = LibraryFolder(**defaults)
  219. db_session.add(folder)
  220. await db_session.commit()
  221. await db_session.refresh(folder)
  222. return folder
  223. return _create_folder
  224. @pytest.fixture
  225. async def file_factory(self, db_session):
  226. """Factory to create test files."""
  227. _counter = [0]
  228. async def _create_file(**kwargs):
  229. from backend.app.models.library import LibraryFile
  230. _counter[0] += 1
  231. counter = _counter[0]
  232. defaults = {
  233. "filename": f"test_file_{counter}.3mf",
  234. "file_path": f"/test/path/test_file_{counter}.3mf",
  235. "file_size": 1024,
  236. "file_type": "3mf",
  237. }
  238. defaults.update(kwargs)
  239. lib_file = LibraryFile(**defaults)
  240. db_session.add(lib_file)
  241. await db_session.commit()
  242. await db_session.refresh(lib_file)
  243. return lib_file
  244. return _create_file
  245. @pytest.mark.asyncio
  246. @pytest.mark.integration
  247. async def test_list_files_empty(self, async_client: AsyncClient, db_session):
  248. """Verify empty file list returns empty array."""
  249. response = await async_client.get("/api/v1/library/files")
  250. assert response.status_code == 200
  251. assert response.json() == []
  252. @pytest.mark.asyncio
  253. @pytest.mark.integration
  254. async def test_list_files_in_folder(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  255. """Verify files can be filtered by folder."""
  256. folder = await folder_factory()
  257. file1 = await file_factory(folder_id=folder.id)
  258. await file_factory() # File in root (no folder)
  259. response = await async_client.get(f"/api/v1/library/files?folder_id={folder.id}")
  260. assert response.status_code == 200
  261. result = response.json()
  262. assert len(result) == 1
  263. assert result[0]["id"] == file1.id
  264. @pytest.mark.asyncio
  265. @pytest.mark.integration
  266. async def test_list_files_by_project_id(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  267. """#932: project_id filter returns files across all folders linked to the project.
  268. Replaces the prior N+1 pattern where the frontend fired one request per
  269. linked folder. A single JOIN query must return every file in folders whose
  270. project_id matches, while excluding files from unlinked folders.
  271. """
  272. from backend.app.models.project import Project
  273. project = Project(name="Test Project for Files", color="#00ff00")
  274. db_session.add(project)
  275. await db_session.commit()
  276. await db_session.refresh(project)
  277. folder_a = await folder_factory(name="Folder A", project_id=project.id)
  278. folder_b = await folder_factory(name="Folder B", project_id=project.id)
  279. other_folder = await folder_factory(name="Unlinked")
  280. linked_a = await file_factory(folder_id=folder_a.id, filename="a.3mf")
  281. linked_b = await file_factory(folder_id=folder_b.id, filename="b.3mf")
  282. await file_factory(folder_id=other_folder.id, filename="unlinked.3mf")
  283. await file_factory(filename="root.3mf") # no folder → not part of any project
  284. response = await async_client.get(f"/api/v1/library/files?project_id={project.id}")
  285. assert response.status_code == 200
  286. result = response.json()
  287. ids = {f["id"] for f in result}
  288. assert ids == {linked_a.id, linked_b.id}
  289. @pytest.mark.asyncio
  290. @pytest.mark.integration
  291. async def test_list_files_folder_id_takes_precedence_over_project_id(
  292. self, async_client: AsyncClient, folder_factory, file_factory, db_session
  293. ):
  294. """When both folder_id and project_id are passed, folder_id wins.
  295. Documented precedence in list_files(): folder_id > project_id > include_root.
  296. This guards the behavior so a future refactor can't silently flip it.
  297. """
  298. from backend.app.models.project import Project
  299. project = Project(name="Precedence Project")
  300. db_session.add(project)
  301. await db_session.commit()
  302. await db_session.refresh(project)
  303. folder_linked = await folder_factory(name="Linked", project_id=project.id)
  304. folder_other = await folder_factory(name="Other")
  305. await file_factory(folder_id=folder_linked.id, filename="linked.3mf")
  306. other_file = await file_factory(folder_id=folder_other.id, filename="other.3mf")
  307. # folder_id points at a folder that is NOT in the project — must return
  308. # that folder's contents and ignore project_id entirely.
  309. response = await async_client.get(f"/api/v1/library/files?folder_id={folder_other.id}&project_id={project.id}")
  310. assert response.status_code == 200
  311. result = response.json()
  312. assert len(result) == 1
  313. assert result[0]["id"] == other_file.id
  314. @pytest.mark.asyncio
  315. @pytest.mark.integration
  316. @pytest.mark.parametrize("direction", ["into", "out of"])
  317. async def test_an_edit_does_not_move_a_file_across_the_external_boundary(
  318. self, async_client: AsyncClient, folder_factory, file_factory, db_session, direction
  319. ):
  320. """F8: the edit route only changed folder_id -- no byte move, no
  321. read-only check -- so the file showed in a folder its bytes are not
  322. in. Such a move goes through POST /library/files/move."""
  323. internal = await folder_factory(name="Mine")
  324. nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
  325. if direction == "into":
  326. file = await file_factory(folder_id=internal.id, filename="a.3mf", is_external=False)
  327. target = nas.id
  328. else:
  329. file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
  330. target = internal.id
  331. response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": target})
  332. assert response.status_code == 400
  333. assert "/library/files/move" in response.json()["detail"]
  334. await db_session.refresh(file)
  335. assert file.folder_id != target
  336. @pytest.mark.asyncio
  337. @pytest.mark.integration
  338. async def test_an_external_file_sent_with_its_own_folder_is_no_move(
  339. self, async_client: AsyncClient, folder_factory, file_factory
  340. ):
  341. nas = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
  342. file = await file_factory(folder_id=nas.id, filename="a.3mf", is_external=True)
  343. response = await async_client.put(
  344. f"/api/v1/library/files/{file.id}", json={"folder_id": nas.id, "notes": "kept"}
  345. )
  346. assert response.status_code == 200
  347. @pytest.mark.asyncio
  348. @pytest.mark.integration
  349. async def test_an_edit_still_moves_a_file_between_its_own_folders(
  350. self, async_client: AsyncClient, folder_factory, file_factory, db_session
  351. ):
  352. first = await folder_factory(name="One")
  353. second = await folder_factory(name="Two")
  354. file = await file_factory(folder_id=first.id, filename="a.3mf", is_external=False)
  355. response = await async_client.put(f"/api/v1/library/files/{file.id}", json={"folder_id": second.id})
  356. assert response.status_code == 200
  357. await db_session.refresh(file)
  358. assert file.folder_id == second.id
  359. @pytest.mark.asyncio
  360. @pytest.mark.integration
  361. async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  362. """#1621: `internal_only=true` restricts the listing to files in managed
  363. storage (`is_external=False`) so a linked NAS with hundreds of files
  364. doesn't drown the user's own uploads in the "All Files" sidebar view."""
  365. internal_folder = await folder_factory(name="My uploads")
  366. external_folder = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
  367. internal_file = await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
  368. await file_factory(folder_id=external_folder.id, filename="nas.3mf", is_external=True)
  369. root_file = await file_factory(filename="root.3mf", is_external=False) # Root-uploaded is always internal.
  370. response = await async_client.get("/api/v1/library/files?include_root=false&internal_only=true")
  371. assert response.status_code == 200
  372. ids = {f["id"] for f in response.json()}
  373. assert ids == {internal_file.id, root_file.id}
  374. @pytest.mark.asyncio
  375. @pytest.mark.integration
  376. async def test_list_files_external_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  377. """#1621 symmetric: `external_only=true` returns the combined view
  378. across every linked external folder so users with several mounts can
  379. see all external content in one place without clicking each folder."""
  380. internal_folder = await folder_factory(name="My uploads")
  381. nas_a = await folder_factory(name="NAS A", is_external=True, external_path="/mnt/a")
  382. nas_b = await folder_factory(name="NAS B", is_external=True, external_path="/mnt/b")
  383. await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
  384. ext_a = await file_factory(folder_id=nas_a.id, filename="a.3mf", is_external=True)
  385. ext_b = await file_factory(folder_id=nas_b.id, filename="b.3mf", is_external=True)
  386. response = await async_client.get("/api/v1/library/files?include_root=false&external_only=true")
  387. assert response.status_code == 200
  388. ids = {f["id"] for f in response.json()}
  389. assert ids == {ext_a.id, ext_b.id}
  390. @pytest.mark.asyncio
  391. @pytest.mark.integration
  392. async def test_list_files_internal_and_external_mutually_exclusive(self, async_client: AsyncClient, db_session):
  393. """Both flags together is a caller bug — fail loud (400) rather than
  394. silently picking one, so a frontend regression is caught immediately."""
  395. response = await async_client.get("/api/v1/library/files?internal_only=true&external_only=true")
  396. assert response.status_code == 400
  397. assert "mutually exclusive" in response.json()["detail"]
  398. @pytest.mark.asyncio
  399. @pytest.mark.integration
  400. async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):
  401. """Verify single file can be retrieved."""
  402. lib_file = await file_factory(filename="test.3mf")
  403. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  404. assert response.status_code == 200
  405. result = response.json()
  406. assert result["id"] == lib_file.id
  407. assert result["filename"] == "test.3mf"
  408. @pytest.mark.asyncio
  409. @pytest.mark.integration
  410. async def test_get_file_not_found(self, async_client: AsyncClient, db_session):
  411. """Verify 404 for non-existent file."""
  412. response = await async_client.get("/api/v1/library/files/9999")
  413. assert response.status_code == 404
  414. @pytest.mark.asyncio
  415. @pytest.mark.integration
  416. async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session):
  417. """Verify file can be deleted."""
  418. lib_file = await file_factory()
  419. response = await async_client.delete(f"/api/v1/library/files/{lib_file.id}")
  420. assert response.status_code == 200
  421. result = response.json()
  422. assert result.get("message") or result.get("success", True)
  423. @pytest.mark.asyncio
  424. @pytest.mark.integration
  425. async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
  426. """Verify file can be renamed."""
  427. lib_file = await file_factory(filename="old_name.3mf")
  428. data = {"filename": "new_name.3mf"}
  429. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  430. assert response.status_code == 200
  431. result = response.json()
  432. assert result["filename"] == "new_name.3mf"
  433. @pytest.mark.asyncio
  434. @pytest.mark.integration
  435. async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
  436. """Verify file rename fails with a forward slash (FAT32-illegal, #1540)."""
  437. lib_file = await file_factory(filename="test.3mf")
  438. data = {"filename": "path/to/file.3mf"}
  439. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  440. assert response.status_code == 400
  441. assert "invalid character" in response.json()["detail"].lower()
  442. assert "/" in response.json()["detail"]
  443. @pytest.mark.asyncio
  444. @pytest.mark.integration
  445. async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
  446. """Verify file rename fails with a backslash (FAT32-illegal, #1540)."""
  447. lib_file = await file_factory(filename="test.3mf")
  448. data = {"filename": "path\\to\\file.3mf"}
  449. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  450. assert response.status_code == 400
  451. assert "invalid character" in response.json()["detail"].lower()
  452. assert "\\" in response.json()["detail"]
  453. @pytest.mark.asyncio
  454. @pytest.mark.integration
  455. async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  456. """Verify library stats endpoint returns counts."""
  457. await folder_factory()
  458. await folder_factory()
  459. await file_factory()
  460. response = await async_client.get("/api/v1/library/stats")
  461. assert response.status_code == 200
  462. result = response.json()
  463. assert result["total_folders"] == 2
  464. assert result["total_files"] == 1
  465. @pytest.mark.asyncio
  466. @pytest.mark.integration
  467. async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  468. """Verify file list response includes user tracking fields (Issue #206)."""
  469. lib_file = await file_factory(filename="test.3mf")
  470. response = await async_client.get("/api/v1/library/files?include_root=false")
  471. assert response.status_code == 200
  472. result = response.json()
  473. assert len(result) >= 1
  474. # Find our test file
  475. test_file = next((f for f in result if f["id"] == lib_file.id), None)
  476. assert test_file is not None
  477. # User tracking fields should be present (even if null)
  478. assert "created_by_id" in test_file
  479. assert "created_by_username" in test_file
  480. @pytest.mark.asyncio
  481. @pytest.mark.integration
  482. async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  483. """Verify file detail response includes user tracking fields (Issue #206)."""
  484. lib_file = await file_factory(filename="test_detail.3mf")
  485. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  486. assert response.status_code == 200
  487. result = response.json()
  488. # User tracking fields should be present (even if null)
  489. assert "created_by_id" in result
  490. assert "created_by_username" in result
  491. @pytest.mark.asyncio
  492. @pytest.mark.integration
  493. async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
  494. """Verify file created with user shows username in response (Issue #206)."""
  495. from backend.app.models.library import LibraryFile
  496. from backend.app.models.user import User
  497. # Create a test user
  498. user = User(username="testuploader", password_hash="fakehash", role="user")
  499. db_session.add(user)
  500. await db_session.flush()
  501. # Create a file with created_by_id set
  502. lib_file = LibraryFile(
  503. filename="user_uploaded.3mf",
  504. file_path="/test/user_uploaded.3mf",
  505. file_size=2048,
  506. file_type="3mf",
  507. created_by_id=user.id,
  508. )
  509. db_session.add(lib_file)
  510. await db_session.commit()
  511. await db_session.refresh(lib_file)
  512. # Verify file detail shows username
  513. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  514. assert response.status_code == 200
  515. result = response.json()
  516. assert result["created_by_id"] == user.id
  517. assert result["created_by_username"] == "testuploader"
  518. # Verify file list also shows username
  519. response = await async_client.get("/api/v1/library/files?include_root=false")
  520. assert response.status_code == 200
  521. files = response.json()
  522. test_file = next((f for f in files if f["id"] == lib_file.id), None)
  523. assert test_file is not None
  524. assert test_file["created_by_id"] == user.id
  525. assert test_file["created_by_username"] == "testuploader"
  526. @pytest.mark.asyncio
  527. @pytest.mark.integration
  528. async def test_list_files_recursive_includes_subfolders(
  529. self, async_client: AsyncClient, folder_factory, file_factory
  530. ):
  531. """#1268: ?recursive=true with folder_id must include every descendant.
  532. Tree:
  533. toys/ ← f_toys, direct file "robot_top.3mf"
  534. cars/ ← child of toys, file "robot_car.3mf"
  535. race/ ← grandchild, file "robot_race.3mf"
  536. other/ ← unrelated, file "robot_other.3mf" (must NOT appear)
  537. """
  538. toys = await folder_factory(name="toys")
  539. cars = await folder_factory(name="cars", parent_id=toys.id)
  540. race = await folder_factory(name="race", parent_id=cars.id)
  541. other = await folder_factory(name="other")
  542. top = await file_factory(folder_id=toys.id, filename="robot_top.3mf")
  543. mid = await file_factory(folder_id=cars.id, filename="robot_car.3mf")
  544. deep = await file_factory(folder_id=race.id, filename="robot_race.3mf")
  545. await file_factory(folder_id=other.id, filename="robot_other.3mf")
  546. # Non-recursive: only the file directly under toys.
  547. r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}")
  548. assert r.status_code == 200
  549. assert {f["id"] for f in r.json()} == {top.id}
  550. # Recursive: toys + cars + race files, but NOT other/.
  551. r = await async_client.get(f"/api/v1/library/files?folder_id={toys.id}&recursive=true")
  552. assert r.status_code == 200
  553. assert {f["id"] for f in r.json()} == {top.id, mid.id, deep.id}
  554. @pytest.mark.asyncio
  555. @pytest.mark.integration
  556. async def test_list_files_recursive_without_folder_id_is_noop(
  557. self, async_client: AsyncClient, folder_factory, file_factory
  558. ):
  559. """recursive=true is meaningful only with folder_id — without it the
  560. existing include_root branch handles scoping. Just confirming the new
  561. param doesn't shadow that path."""
  562. folder = await folder_factory()
  563. f_in = await file_factory(folder_id=folder.id)
  564. f_root = await file_factory()
  565. r = await async_client.get("/api/v1/library/files?include_root=false&recursive=true")
  566. assert r.status_code == 200
  567. assert {f["id"] for f in r.json()} == {f_in.id, f_root.id}
  568. @pytest.mark.asyncio
  569. @pytest.mark.integration
  570. async def test_get_folder_readme_returns_first_markdown(
  571. self, async_client: AsyncClient, folder_factory, file_factory
  572. ):
  573. """#1268: /folders/{id}/readme reads on-disk content of the first .md."""
  574. folder = await folder_factory()
  575. with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
  576. f.write("# Robot\n\nA cute little robot.")
  577. md_path = f.name
  578. try:
  579. await file_factory(
  580. folder_id=folder.id,
  581. filename="README.md",
  582. file_path=md_path,
  583. file_type="md",
  584. file_size=Path(md_path).stat().st_size,
  585. )
  586. r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
  587. assert r.status_code == 200
  588. body = r.json()
  589. assert body["filename"] == "README.md"
  590. assert body["content"] == "# Robot\n\nA cute little robot."
  591. assert body["truncated"] is False
  592. finally:
  593. import os
  594. os.unlink(md_path)
  595. @pytest.mark.asyncio
  596. @pytest.mark.integration
  597. async def test_get_folder_readme_prefers_readme_over_other_md(
  598. self, async_client: AsyncClient, folder_factory, file_factory
  599. ):
  600. """When the folder has multiple .md files, README.md / description.md
  601. wins regardless of insertion order or filename case."""
  602. folder = await folder_factory()
  603. with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
  604. f.write("notes notes notes")
  605. notes_path = f.name
  606. with tempfile.NamedTemporaryFile(suffix=".md", delete=False, mode="w", encoding="utf-8") as f:
  607. f.write("the real one")
  608. readme_path = f.name
  609. try:
  610. # notes.md inserted FIRST — naive ordering would pick this one.
  611. await file_factory(
  612. folder_id=folder.id,
  613. filename="notes.md",
  614. file_path=notes_path,
  615. file_type="md",
  616. )
  617. await file_factory(
  618. folder_id=folder.id,
  619. filename="readme.md", # lowercase to confirm case-insensitive match
  620. file_path=readme_path,
  621. file_type="md",
  622. )
  623. r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
  624. assert r.status_code == 200
  625. assert r.json()["filename"] == "readme.md"
  626. assert r.json()["content"] == "the real one"
  627. finally:
  628. import os
  629. os.unlink(notes_path)
  630. os.unlink(readme_path)
  631. @pytest.mark.asyncio
  632. @pytest.mark.integration
  633. async def test_get_folder_readme_404_when_no_markdown(
  634. self, async_client: AsyncClient, folder_factory, file_factory
  635. ):
  636. """No .md in the folder → 404 so the FE can hide the side panel."""
  637. folder = await folder_factory()
  638. await file_factory(folder_id=folder.id, filename="model.3mf", file_type="3mf")
  639. r = await async_client.get(f"/api/v1/library/folders/{folder.id}/readme")
  640. assert r.status_code == 404
  641. @pytest.mark.asyncio
  642. @pytest.mark.integration
  643. async def test_get_folder_readme_404_when_folder_missing(self, async_client: AsyncClient):
  644. r = await async_client.get("/api/v1/library/folders/999999/readme")
  645. assert r.status_code == 404
  646. class TestLibraryAddToQueueAPI:
  647. """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
  648. @pytest.fixture
  649. async def printer_factory(self, db_session):
  650. """Factory to create test printers."""
  651. _counter = [0]
  652. async def _create_printer(**kwargs):
  653. from backend.app.models.printer import Printer
  654. _counter[0] += 1
  655. counter = _counter[0]
  656. defaults = {
  657. "name": f"Test Printer {counter}",
  658. "ip_address": f"192.168.1.{100 + counter}",
  659. "serial_number": f"TESTSERIAL{counter:04d}",
  660. "access_code": "12345678",
  661. "model": "X1C",
  662. }
  663. defaults.update(kwargs)
  664. printer = Printer(**defaults)
  665. db_session.add(printer)
  666. await db_session.commit()
  667. await db_session.refresh(printer)
  668. return printer
  669. return _create_printer
  670. @pytest.fixture
  671. async def library_file_factory(self, db_session):
  672. """Factory to create test library files."""
  673. _counter = [0]
  674. async def _create_library_file(**kwargs):
  675. from backend.app.models.library import LibraryFile
  676. _counter[0] += 1
  677. counter = _counter[0]
  678. defaults = {
  679. "filename": f"test_file_{counter}.gcode.3mf",
  680. "file_path": f"/test/path/test_file_{counter}.gcode.3mf",
  681. "file_size": 1024,
  682. "file_type": "3mf",
  683. }
  684. defaults.update(kwargs)
  685. lib_file = LibraryFile(**defaults)
  686. db_session.add(lib_file)
  687. await db_session.commit()
  688. await db_session.refresh(lib_file)
  689. return lib_file
  690. return _create_library_file
  691. @pytest.fixture
  692. async def on_disk_file_factory(self, library_file_factory):
  693. """A library file whose bytes exist, so the route gets past its disk check."""
  694. written: list[Path] = []
  695. async def _create(**kwargs):
  696. # A unique name: this is the checkout's real library folder, shared by
  697. # every xdist worker, and a sibling's teardown deleted a same-named
  698. # probe this test still needed ("File not found on disk").
  699. rel_path = kwargs.pop("file_path", f"archive/library/files/queue_probe_{uuid4().hex}.gcode.3mf")
  700. abs_path = Path(app_settings.base_dir) / rel_path
  701. abs_path.parent.mkdir(parents=True, exist_ok=True)
  702. abs_path.write_bytes(b"probe")
  703. written.append(abs_path)
  704. kwargs.setdefault("filename", Path(rel_path).name)
  705. return await library_file_factory(file_path=rel_path, **kwargs)
  706. yield _create
  707. for path in written:
  708. path.unlink(missing_ok=True)
  709. @pytest.mark.asyncio
  710. @pytest.mark.integration
  711. async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
  712. """Nothing queued is not a success (#3112).
  713. This used to assert 200: the caller got an OK for a call that created
  714. nothing, with the reason in a body it had no cause to read. The reason
  715. is still reported, now where a failed call puts it.
  716. """
  717. await printer_factory()
  718. data = {"file_ids": [9999]}
  719. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  720. assert response.status_code == 400
  721. errors = response.json()["detail"]["errors"]
  722. assert len(errors) == 1
  723. assert errors[0]["file_id"] == 9999
  724. @pytest.mark.asyncio
  725. @pytest.mark.integration
  726. async def test_add_non_sliced_file_to_queue_fails(
  727. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  728. ):
  729. """Verify non-sliced file cannot be added to queue."""
  730. await printer_factory()
  731. lib_file = await library_file_factory(
  732. filename="model.stl",
  733. file_path="/test/path/model.stl",
  734. file_type="stl",
  735. )
  736. data = {"file_ids": [lib_file.id]}
  737. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  738. assert response.status_code == 400
  739. errors = response.json()["detail"]["errors"]
  740. assert len(errors) == 1
  741. assert "sliced" in errors[0]["error"].lower()
  742. @pytest.mark.asyncio
  743. @pytest.mark.integration
  744. async def test_partial_success_still_returns_200(
  745. self, async_client: AsyncClient, printer_factory, library_file_factory, on_disk_file_factory, db_session
  746. ):
  747. """Items really were created, so the call succeeded (#3112).
  748. The per-file errors ride along with them, which is the whole point of a
  749. bulk endpoint. Only a call that produced nothing is a failed call.
  750. """
  751. await printer_factory()
  752. good = await on_disk_file_factory()
  753. bad = await library_file_factory(filename="model.stl", file_path="/test/path/model.stl", file_type="stl")
  754. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [good.id, bad.id]})
  755. assert response.status_code == 200
  756. result = response.json()
  757. assert [a["file_id"] for a in result["added"]] == [good.id]
  758. assert [e["file_id"] for e in result["errors"]] == [bad.id]
  759. @pytest.mark.asyncio
  760. @pytest.mark.integration
  761. async def test_target_model_is_inferred_so_the_item_can_be_dispatched(
  762. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  763. ):
  764. """#3112: an item with no printer and no target model is inert.
  765. The scheduler dispatches on `item.printer_id` or on
  766. `item.target_model or item.variants`; a row with neither matches no
  767. branch and waits forever. With an active X1C present, a file that says
  768. it was sliced for one is aimed at it.
  769. """
  770. await printer_factory(model="X1C")
  771. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
  772. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  773. assert response.status_code == 200
  774. item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
  775. assert item.printer_id is None
  776. assert item.target_model == "X1C"
  777. @pytest.mark.asyncio
  778. @pytest.mark.integration
  779. async def test_no_printer_of_that_model_leaves_the_item_unassigned(
  780. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  781. ):
  782. """Aiming an item at hardware nobody owns would only look like progress.
  783. Having no H2D is the user's situation, not their mistake, so the file
  784. is still queued -- as the unassigned row it has always been.
  785. """
  786. await printer_factory(model="X1C")
  787. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
  788. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  789. assert response.status_code == 200
  790. item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
  791. assert item.printer_id is None
  792. assert item.target_model is None
  793. @pytest.mark.asyncio
  794. @pytest.mark.integration
  795. async def test_explicit_printer_wins_over_the_files_own_model(
  796. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  797. ):
  798. printer = await printer_factory(model="X1C")
  799. # Read before the queue row is re-read: that expires the session, and
  800. # a lazy refresh of this row would then happen outside the greenlet.
  801. printer_id = printer.id
  802. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
  803. response = await async_client.post(
  804. "/api/v1/library/files/add-to-queue",
  805. json={"file_ids": [lib_file.id], "printer_id": printer_id},
  806. )
  807. assert response.status_code == 200
  808. item = await _read_queue_item(db_session, response.json()["added"][0]["queue_item_id"])
  809. assert item.printer_id == printer_id
  810. assert item.target_model is None
  811. @pytest.mark.asyncio
  812. @pytest.mark.integration
  813. async def test_incompatible_target_model_is_refused_per_file(
  814. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  815. ):
  816. """The same cross-model gate POST /queue/ applies (#2578).
  817. The scheduler hands model-based items to hardware with no human in the
  818. loop, so a file sliced for one model must not be aimed at another.
  819. """
  820. await printer_factory(model="A1")
  821. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "X1C"})
  822. response = await async_client.post(
  823. "/api/v1/library/files/add-to-queue",
  824. json={"file_ids": [lib_file.id], "target_model": "A1"},
  825. )
  826. assert response.status_code == 400
  827. assert "cannot be dispatched" in response.json()["detail"]["errors"][0]["error"]
  828. @pytest.mark.asyncio
  829. @pytest.mark.integration
  830. async def test_target_model_without_an_active_printer_is_refused(
  831. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  832. ):
  833. await printer_factory(model="X1C")
  834. lib_file = await on_disk_file_factory(file_metadata={"sliced_for_model": "H2D"})
  835. response = await async_client.post(
  836. "/api/v1/library/files/add-to-queue",
  837. json={"file_ids": [lib_file.id], "target_model": "H2D"},
  838. )
  839. assert response.status_code == 400
  840. assert "No active printers" in response.json()["detail"]
  841. @pytest.mark.asyncio
  842. @pytest.mark.integration
  843. async def test_printer_and_target_model_together_are_refused(
  844. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  845. ):
  846. printer = await printer_factory(model="X1C")
  847. lib_file = await on_disk_file_factory()
  848. response = await async_client.post(
  849. "/api/v1/library/files/add-to-queue",
  850. json={"file_ids": [lib_file.id], "printer_id": printer.id, "target_model": "X1C"},
  851. )
  852. assert response.status_code == 400
  853. assert "both" in response.json()["detail"].lower()
  854. @pytest.mark.asyncio
  855. @pytest.mark.integration
  856. async def test_unknown_printer_is_refused(
  857. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  858. ):
  859. await printer_factory(model="X1C")
  860. lib_file = await on_disk_file_factory()
  861. response = await async_client.post(
  862. "/api/v1/library/files/add-to-queue",
  863. json={"file_ids": [lib_file.id], "printer_id": 999999},
  864. )
  865. assert response.status_code == 400
  866. assert response.json()["detail"] == "Printer not found"
  867. @pytest.mark.asyncio
  868. @pytest.mark.integration
  869. async def test_a_filename_the_printer_cannot_store_is_refused(
  870. self, async_client: AsyncClient, printer_factory, on_disk_file_factory, db_session
  871. ):
  872. """The Bambu SD card is FAT32; an illegal character 553s at upload.
  873. POST /queue/ has rejected these at queue time since #1540. This route
  874. did not, so the mistake surfaced as a print that failed later.
  875. """
  876. await printer_factory(model="X1C")
  877. lib_file = await on_disk_file_factory(filename="bad:name?.gcode.3mf")
  878. response = await async_client.post("/api/v1/library/files/add-to-queue", json={"file_ids": [lib_file.id]})
  879. assert response.status_code == 400
  880. assert response.json()["detail"]["errors"][0]["file_id"] == lib_file.id
  881. class TestLibraryZipExtractAPI:
  882. """Integration tests for ZIP extraction endpoint."""
  883. @pytest.mark.asyncio
  884. @pytest.mark.integration
  885. async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
  886. """Verify non-ZIP files are rejected."""
  887. # Create a fake file that's not a ZIP
  888. files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
  889. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  890. assert response.status_code == 400
  891. assert "ZIP" in response.json()["detail"]
  892. @pytest.mark.asyncio
  893. @pytest.mark.integration
  894. async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
  895. """Verify basic ZIP extraction works."""
  896. import io
  897. # Create a simple ZIP file in memory
  898. zip_buffer = io.BytesIO()
  899. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  900. zf.writestr("test1.txt", "Content of file 1")
  901. zf.writestr("test2.txt", "Content of file 2")
  902. zip_buffer.seek(0)
  903. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  904. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  905. assert response.status_code == 200
  906. result = response.json()
  907. assert result["extracted"] == 2
  908. assert len(result["files"]) == 2
  909. assert len(result["errors"]) == 0
  910. @pytest.mark.asyncio
  911. @pytest.mark.integration
  912. async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
  913. """Verify ZIP extraction preserves folder structure."""
  914. import io
  915. # Create a ZIP file with folder structure
  916. zip_buffer = io.BytesIO()
  917. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  918. zf.writestr("folder1/file1.txt", "Content 1")
  919. zf.writestr("folder1/subfolder/file2.txt", "Content 2")
  920. zf.writestr("folder2/file3.txt", "Content 3")
  921. zip_buffer.seek(0)
  922. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  923. params = {"preserve_structure": "true"}
  924. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  925. assert response.status_code == 200
  926. result = response.json()
  927. assert result["extracted"] == 3
  928. assert result["folders_created"] >= 3 # folder1, folder1/subfolder, folder2
  929. @pytest.mark.asyncio
  930. @pytest.mark.integration
  931. async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
  932. """Verify ZIP extraction can extract flat (no folders)."""
  933. import io
  934. # Create a ZIP file with folder structure
  935. zip_buffer = io.BytesIO()
  936. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  937. zf.writestr("folder/file1.txt", "Content 1")
  938. zf.writestr("folder/file2.txt", "Content 2")
  939. zip_buffer.seek(0)
  940. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  941. params = {"preserve_structure": "false"}
  942. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  943. assert response.status_code == 200
  944. result = response.json()
  945. assert result["extracted"] == 2
  946. assert result["folders_created"] == 0 # No folders created when flat
  947. @pytest.mark.asyncio
  948. @pytest.mark.integration
  949. async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
  950. """Verify ZIP extraction skips __MACOSX and hidden files."""
  951. import io
  952. # Create a ZIP file with macOS junk files
  953. zip_buffer = io.BytesIO()
  954. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  955. zf.writestr("real_file.txt", "Real content")
  956. zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
  957. zf.writestr(".hidden_file", "Hidden content")
  958. zip_buffer.seek(0)
  959. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  960. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  961. assert response.status_code == 200
  962. result = response.json()
  963. assert result["extracted"] == 1 # Only real_file.txt
  964. assert result["files"][0]["filename"] == "real_file.txt"
  965. @pytest.mark.asyncio
  966. @pytest.mark.integration
  967. async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):
  968. """Verify ZIP extraction creates a folder from the ZIP filename."""
  969. import io
  970. # Create a ZIP file with some files
  971. zip_buffer = io.BytesIO()
  972. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  973. zf.writestr("file1.txt", "Content 1")
  974. zf.writestr("file2.txt", "Content 2")
  975. zip_buffer.seek(0)
  976. files = {"file": ("MyProject.zip", zip_buffer.read(), "application/zip")}
  977. params = {"create_folder_from_zip": "true", "preserve_structure": "false"}
  978. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  979. assert response.status_code == 200
  980. result = response.json()
  981. assert result["extracted"] == 2
  982. assert result["folders_created"] == 1 # MyProject folder created
  983. # Verify the files are in a folder
  984. assert result["files"][0]["folder_id"] is not None
  985. assert result["files"][1]["folder_id"] is not None
  986. # Both files should be in the same folder
  987. assert result["files"][0]["folder_id"] == result["files"][1]["folder_id"]
  988. # Verify the folder was created with the right name
  989. folder_response = await async_client.get(f"/api/v1/library/folders/{result['files'][0]['folder_id']}")
  990. assert folder_response.status_code == 200
  991. folder = folder_response.json()
  992. assert folder["name"] == "MyProject"
  993. class TestLibraryStlThumbnailAPI:
  994. """Integration tests for STL thumbnail generation endpoints."""
  995. @pytest.fixture
  996. async def file_factory(self, db_session):
  997. """Factory to create test files."""
  998. _counter = [0]
  999. async def _create_file(**kwargs):
  1000. from backend.app.models.library import LibraryFile
  1001. _counter[0] += 1
  1002. counter = _counter[0]
  1003. defaults = {
  1004. "filename": f"test_model_{counter}.stl",
  1005. "file_path": f"/test/path/test_model_{counter}.stl",
  1006. "file_size": 1024,
  1007. "file_type": "stl",
  1008. }
  1009. defaults.update(kwargs)
  1010. lib_file = LibraryFile(**defaults)
  1011. db_session.add(lib_file)
  1012. await db_session.commit()
  1013. await db_session.refresh(lib_file)
  1014. return lib_file
  1015. return _create_file
  1016. @pytest.mark.asyncio
  1017. @pytest.mark.integration
  1018. async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
  1019. """Verify batch thumbnail generation with no files."""
  1020. data = {"all_missing": True}
  1021. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1022. assert response.status_code == 200
  1023. result = response.json()
  1024. assert result["processed"] == 0
  1025. assert result["succeeded"] == 0
  1026. assert result["failed"] == 0
  1027. assert result["results"] == []
  1028. @pytest.mark.asyncio
  1029. @pytest.mark.integration
  1030. async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
  1031. """Verify batch thumbnail generation with no criteria returns empty."""
  1032. data = {}
  1033. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1034. assert response.status_code == 200
  1035. result = response.json()
  1036. assert result["processed"] == 0
  1037. @pytest.mark.asyncio
  1038. @pytest.mark.integration
  1039. async def test_batch_generate_thumbnails_file_not_on_disk(
  1040. self, async_client: AsyncClient, file_factory, db_session
  1041. ):
  1042. """Verify batch thumbnail generation handles missing files gracefully."""
  1043. # Create a file in DB but not on disk
  1044. stl_file = await file_factory(
  1045. filename="missing.stl",
  1046. file_path="/nonexistent/path/missing.stl",
  1047. thumbnail_path=None,
  1048. )
  1049. data = {"file_ids": [stl_file.id]}
  1050. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1051. assert response.status_code == 200
  1052. result = response.json()
  1053. assert result["processed"] == 1
  1054. assert result["succeeded"] == 0
  1055. assert result["failed"] == 1
  1056. assert result["results"][0]["success"] is False
  1057. assert "not found" in result["results"][0]["error"].lower()
  1058. @pytest.mark.asyncio
  1059. @pytest.mark.integration
  1060. async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
  1061. """Verify batch thumbnail generation with a real STL file."""
  1062. from backend.app.models.library import LibraryFile
  1063. # Create a simple ASCII STL cube
  1064. stl_content = """solid cube
  1065. facet normal 0 0 -1
  1066. outer loop
  1067. vertex 0 0 0
  1068. vertex 1 0 0
  1069. vertex 1 1 0
  1070. endloop
  1071. endfacet
  1072. facet normal 0 0 1
  1073. outer loop
  1074. vertex 0 0 1
  1075. vertex 1 1 1
  1076. vertex 1 0 1
  1077. endloop
  1078. endfacet
  1079. endsolid cube"""
  1080. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
  1081. f.write(stl_content)
  1082. stl_path = f.name
  1083. try:
  1084. # Create file in DB pointing to real STL
  1085. lib_file = LibraryFile(
  1086. filename="test_cube.stl",
  1087. file_path=stl_path,
  1088. file_size=len(stl_content),
  1089. file_type="stl",
  1090. thumbnail_path=None,
  1091. )
  1092. db_session.add(lib_file)
  1093. await db_session.commit()
  1094. await db_session.refresh(lib_file)
  1095. data = {"file_ids": [lib_file.id]}
  1096. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1097. assert response.status_code == 200
  1098. result = response.json()
  1099. assert result["processed"] == 1
  1100. # Result depends on whether trimesh/matplotlib are installed
  1101. # Either succeeds or fails gracefully
  1102. assert result["succeeded"] + result["failed"] == 1
  1103. finally:
  1104. import os
  1105. if os.path.exists(stl_path):
  1106. os.unlink(stl_path)
  1107. @pytest.mark.asyncio
  1108. @pytest.mark.integration
  1109. async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  1110. """Verify file upload accepts generate_stl_thumbnails parameter."""
  1111. # Create a simple STL file
  1112. stl_content = b"solid test\nendsolid test"
  1113. files = {"file": ("test.stl", stl_content, "application/octet-stream")}
  1114. params = {"generate_stl_thumbnails": "false"}
  1115. response = await async_client.post("/api/v1/library/files", files=files, params=params)
  1116. assert response.status_code == 200
  1117. result = response.json()
  1118. assert result["filename"] == "test.stl"
  1119. assert result["file_type"] == "stl"
  1120. # No thumbnail should be generated when disabled
  1121. assert result["thumbnail_path"] is None
  1122. @pytest.mark.asyncio
  1123. @pytest.mark.integration
  1124. async def test_upload_image_stores_a_thumbnail(self, async_client: AsyncClient, db_session):
  1125. """An uploaded image gets its own grid thumbnail, so PNGs are not blank cards (#2976)."""
  1126. from PIL import Image
  1127. buffer = io.BytesIO()
  1128. Image.new("RGB", (640, 480), (0, 174, 66)).save(buffer, format="PNG")
  1129. files = {"file": ("photo.png", buffer.getvalue(), "image/png")}
  1130. response = await async_client.post("/api/v1/library/files", files=files)
  1131. assert response.status_code == 200
  1132. result = response.json()
  1133. assert result["file_type"] == "png"
  1134. assert result["thumbnail_path"]
  1135. from backend.app.api.routes.library import to_absolute_path
  1136. thumb = to_absolute_path(result["thumbnail_path"])
  1137. assert thumb is not None and thumb.exists()
  1138. with Image.open(thumb) as thumbnail:
  1139. assert thumbnail.format == "PNG"
  1140. assert max(thumbnail.size) <= 256
  1141. @pytest.mark.asyncio
  1142. @pytest.mark.integration
  1143. async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  1144. """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
  1145. # Create a ZIP file containing an STL
  1146. stl_content = b"solid test\nendsolid test"
  1147. zip_buffer = io.BytesIO()
  1148. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1149. zf.writestr("model.stl", stl_content)
  1150. zip_buffer.seek(0)
  1151. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  1152. params = {"generate_stl_thumbnails": "false"}
  1153. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  1154. assert response.status_code == 200
  1155. result = response.json()
  1156. assert result["extracted"] == 1
  1157. assert result["files"][0]["filename"] == "model.stl"
  1158. @pytest.mark.asyncio
  1159. @pytest.mark.integration
  1160. async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
  1161. """Verify batch thumbnail generation can filter by folder."""
  1162. from backend.app.models.library import LibraryFolder
  1163. # Create a folder
  1164. folder = LibraryFolder(name="STL Folder")
  1165. db_session.add(folder)
  1166. await db_session.commit()
  1167. await db_session.refresh(folder)
  1168. # Create STL file in folder (no thumbnail)
  1169. stl_in_folder = await file_factory(
  1170. filename="in_folder.stl",
  1171. folder_id=folder.id,
  1172. thumbnail_path=None,
  1173. )
  1174. # Create STL file at root (no thumbnail)
  1175. _stl_at_root = await file_factory(
  1176. filename="at_root.stl",
  1177. folder_id=None,
  1178. thumbnail_path=None,
  1179. )
  1180. # Request thumbnails only for files in folder
  1181. data = {"folder_id": folder.id, "all_missing": True}
  1182. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1183. assert response.status_code == 200
  1184. result = response.json()
  1185. # Should only process the file in the folder
  1186. assert result["processed"] == 1
  1187. assert result["results"][0]["file_id"] == stl_in_folder.id
  1188. @pytest.mark.asyncio
  1189. @pytest.mark.integration
  1190. async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
  1191. """Verify batch thumbnail generation finds all STL files missing thumbnails."""
  1192. # Create files with and without thumbnails
  1193. _stl_with_thumb = await file_factory(
  1194. filename="with_thumb.stl",
  1195. thumbnail_path="/some/path/thumb.png",
  1196. )
  1197. stl_without_thumb1 = await file_factory(
  1198. filename="without_thumb1.stl",
  1199. thumbnail_path=None,
  1200. )
  1201. stl_without_thumb2 = await file_factory(
  1202. filename="without_thumb2.stl",
  1203. thumbnail_path=None,
  1204. )
  1205. data = {"all_missing": True}
  1206. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  1207. assert response.status_code == 200
  1208. result = response.json()
  1209. # Should only process files without thumbnails
  1210. assert result["processed"] == 2
  1211. file_ids = {r["file_id"] for r in result["results"]}
  1212. assert stl_without_thumb1.id in file_ids
  1213. assert stl_without_thumb2.id in file_ids
  1214. class TestLibraryPathHelpers:
  1215. """Tests for path handling utilities used for backup portability."""
  1216. def test_to_relative_path_converts_absolute(self):
  1217. """Verify absolute paths are converted to relative paths."""
  1218. from backend.app.api.routes.library import to_relative_path
  1219. from backend.app.core.config import settings
  1220. base_dir = str(settings.base_dir)
  1221. abs_path = f"{base_dir}/archive/library/files/test.3mf"
  1222. rel_path = to_relative_path(abs_path)
  1223. assert not rel_path.startswith("/")
  1224. assert rel_path == "archive/library/files/test.3mf"
  1225. def test_to_relative_path_handles_path_object(self):
  1226. """Verify Path objects are handled correctly."""
  1227. from pathlib import Path
  1228. from backend.app.api.routes.library import to_relative_path
  1229. from backend.app.core.config import settings
  1230. abs_path = Path(settings.base_dir) / "archive" / "test.3mf"
  1231. rel_path = to_relative_path(abs_path)
  1232. assert not rel_path.startswith("/")
  1233. assert rel_path == "archive/test.3mf"
  1234. def test_to_relative_path_returns_empty_for_empty_input(self):
  1235. """Verify empty input returns empty string."""
  1236. from backend.app.api.routes.library import to_relative_path
  1237. assert to_relative_path("") == ""
  1238. assert to_relative_path(None) == ""
  1239. def test_to_absolute_path_converts_relative(self):
  1240. """Verify relative paths are converted to absolute paths."""
  1241. from backend.app.api.routes.library import to_absolute_path
  1242. from backend.app.core.config import settings
  1243. rel_path = "archive/library/files/test.3mf"
  1244. abs_path = to_absolute_path(rel_path)
  1245. assert abs_path is not None
  1246. assert abs_path.is_absolute()
  1247. assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf"
  1248. def test_to_absolute_path_handles_already_absolute(self):
  1249. """Verify already absolute paths are returned as-is (for backwards compatibility)."""
  1250. from backend.app.api.routes.library import to_absolute_path
  1251. abs_path_str = "/data/archive/test.3mf"
  1252. result = to_absolute_path(abs_path_str)
  1253. assert result is not None
  1254. assert str(result) == abs_path_str
  1255. def test_to_absolute_path_returns_none_for_empty(self):
  1256. """Verify None/empty input returns None."""
  1257. from backend.app.api.routes.library import to_absolute_path
  1258. assert to_absolute_path(None) is None
  1259. assert to_absolute_path("") is None
  1260. class TestLibraryPermissions:
  1261. """Tests for library permission enforcement."""
  1262. @pytest.fixture
  1263. async def auth_setup(self, db_session):
  1264. """Set up auth with users of different permission levels."""
  1265. from backend.app.core.auth import create_access_token, get_password_hash
  1266. from backend.app.models.group import Group
  1267. from backend.app.models.settings import Settings
  1268. from backend.app.models.user import User
  1269. # Enable auth
  1270. settings = Settings(key="auth_enabled", value="true")
  1271. db_session.add(settings)
  1272. await db_session.commit()
  1273. # Groups are auto-seeded during db init, but we need to commit them
  1274. await db_session.commit()
  1275. # Get groups
  1276. from sqlalchemy import select
  1277. admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
  1278. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  1279. viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  1280. password_hash = get_password_hash("password")
  1281. # Create users
  1282. admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
  1283. admin_user.groups.append(admin_group)
  1284. operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
  1285. operator_user.groups.append(operator_group)
  1286. viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
  1287. viewer_user.groups.append(viewer_group)
  1288. db_session.add_all([admin_user, operator_user, viewer_user])
  1289. await db_session.commit()
  1290. # Create tokens
  1291. admin_token = create_access_token(data={"sub": admin_user.username})
  1292. operator_token = create_access_token(data={"sub": operator_user.username})
  1293. viewer_token = create_access_token(data={"sub": viewer_user.username})
  1294. return {
  1295. "admin_user": admin_user,
  1296. "operator_user": operator_user,
  1297. "viewer_user": viewer_user,
  1298. "admin_token": admin_token,
  1299. "operator_token": operator_token,
  1300. "viewer_token": viewer_token,
  1301. }
  1302. @pytest.fixture
  1303. async def test_file(self, db_session, auth_setup):
  1304. """Create a test file owned by the operator user."""
  1305. from backend.app.models.library import LibraryFile
  1306. operator_user = auth_setup["operator_user"]
  1307. lib_file = LibraryFile(
  1308. filename="test.txt",
  1309. file_path="data/archive/library/files/test.txt",
  1310. file_type="txt",
  1311. file_size=100,
  1312. created_by_id=operator_user.id,
  1313. )
  1314. db_session.add(lib_file)
  1315. await db_session.commit()
  1316. await db_session.refresh(lib_file)
  1317. return lib_file
  1318. @pytest.mark.asyncio
  1319. @pytest.mark.integration
  1320. async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
  1321. """Verify list_files requires library:read permission."""
  1322. viewer_token = auth_setup["viewer_token"]
  1323. # Viewers have library:read, should succeed
  1324. response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
  1325. assert response.status_code == 200
  1326. @pytest.mark.asyncio
  1327. @pytest.mark.integration
  1328. async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
  1329. """Verify list_files denied without auth when auth is enabled."""
  1330. from backend.app.models.settings import Settings
  1331. # Enable auth
  1332. settings = Settings(key="auth_enabled", value="true")
  1333. db_session.add(settings)
  1334. await db_session.commit()
  1335. # Request without token should fail
  1336. response = await async_client.get("/api/v1/library/files")
  1337. assert response.status_code == 401
  1338. @pytest.mark.asyncio
  1339. @pytest.mark.integration
  1340. async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  1341. """Verify operator can delete their own files."""
  1342. from pathlib import Path
  1343. # Create actual file on disk so delete doesn't fail
  1344. from backend.app.core.config import settings as app_settings
  1345. file_path = Path(app_settings.base_dir) / test_file.file_path
  1346. file_path.parent.mkdir(parents=True, exist_ok=True)
  1347. file_path.write_text("test content")
  1348. operator_token = auth_setup["operator_token"]
  1349. response = await async_client.delete(
  1350. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  1351. )
  1352. assert response.status_code == 200
  1353. @pytest.mark.asyncio
  1354. @pytest.mark.integration
  1355. async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
  1356. """Verify operator cannot delete files owned by others."""
  1357. # Create another operator user with a file
  1358. from sqlalchemy import select
  1359. from backend.app.core.auth import create_access_token
  1360. from backend.app.models.group import Group
  1361. from backend.app.models.library import LibraryFile
  1362. from backend.app.models.user import User
  1363. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  1364. from backend.app.core.auth import get_password_hash as get_pw_hash
  1365. other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
  1366. other_user.groups.append(operator_group)
  1367. db_session.add(other_user)
  1368. await db_session.commit()
  1369. await db_session.refresh(other_user)
  1370. # Create file owned by other user
  1371. other_file = LibraryFile(
  1372. filename="other.txt",
  1373. file_path="data/archive/library/files/other.txt",
  1374. file_type="txt",
  1375. file_size=100,
  1376. created_by_id=other_user.id,
  1377. )
  1378. db_session.add(other_file)
  1379. await db_session.commit()
  1380. await db_session.refresh(other_file)
  1381. # Original operator should not be able to delete it
  1382. operator_token = auth_setup["operator_token"]
  1383. response = await async_client.delete(
  1384. f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  1385. )
  1386. assert response.status_code == 403
  1387. assert "your own files" in response.json()["detail"].lower()
  1388. @pytest.mark.asyncio
  1389. @pytest.mark.integration
  1390. async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
  1391. """Verify admin can delete any file."""
  1392. from pathlib import Path
  1393. from backend.app.core.config import settings as app_settings
  1394. from backend.app.models.library import LibraryFile
  1395. # Create file owned by operator
  1396. operator_user = auth_setup["operator_user"]
  1397. lib_file = LibraryFile(
  1398. filename="admin_can_delete.txt",
  1399. file_path="data/archive/library/files/admin_can_delete.txt",
  1400. file_type="txt",
  1401. file_size=100,
  1402. created_by_id=operator_user.id,
  1403. )
  1404. db_session.add(lib_file)
  1405. await db_session.commit()
  1406. await db_session.refresh(lib_file)
  1407. # Create actual file on disk
  1408. file_path = Path(app_settings.base_dir) / lib_file.file_path
  1409. file_path.parent.mkdir(parents=True, exist_ok=True)
  1410. file_path.write_text("test content")
  1411. # Admin should be able to delete it
  1412. admin_token = auth_setup["admin_token"]
  1413. response = await async_client.delete(
  1414. f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
  1415. )
  1416. assert response.status_code == 200
  1417. @pytest.mark.asyncio
  1418. @pytest.mark.integration
  1419. async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  1420. """Verify viewer cannot delete any files."""
  1421. viewer_token = auth_setup["viewer_token"]
  1422. response = await async_client.delete(
  1423. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
  1424. )
  1425. # Viewers don't have delete_own or delete_all permissions
  1426. assert response.status_code == 403
  1427. # ---------- #1832: API-key curation under can_manage_library ----------
  1428. #
  1429. # require_ownership_permission gates API keys on `all_perm`, but the
  1430. # library deliberately split UPDATE_OWN/DELETE_OWN (allowed under
  1431. # can_manage_library) from UPDATE_ALL/DELETE_ALL (previously denied).
  1432. # That made the entire curation surface (DELETE, PUT rename, POST move)
  1433. # unreachable for API keys, including for files the key's owner uploaded.
  1434. # The fix folds UPDATE_ALL/DELETE_ALL into can_manage_library so the
  1435. # checker passes; LIBRARY_PURGE stays admin-only.
  1436. @pytest.fixture
  1437. async def manage_library_key(self, db_session, auth_setup):
  1438. """Mint an API key owned by the admin user with can_manage_library."""
  1439. from backend.app.core.auth import generate_api_key
  1440. from backend.app.models.api_key import APIKey
  1441. admin = auth_setup["admin_user"]
  1442. full_key, key_hash, key_prefix = generate_api_key()
  1443. row = APIKey(
  1444. name="lib-curation",
  1445. key_hash=key_hash,
  1446. key_prefix=key_prefix,
  1447. user_id=admin.id,
  1448. can_manage_library=True,
  1449. )
  1450. db_session.add(row)
  1451. await db_session.commit()
  1452. return full_key
  1453. @pytest.mark.asyncio
  1454. @pytest.mark.integration
  1455. async def test_apikey_with_manage_library_can_delete_file(
  1456. self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
  1457. ):
  1458. """Pre-#1832 this 403'd with "administrative operations" because
  1459. LIBRARY_DELETE_ALL wasn't in _APIKEY_SCOPE_BY_PERMISSION."""
  1460. from pathlib import Path
  1461. from backend.app.core.config import settings as app_settings
  1462. # Materialise the file on disk so the delete handler doesn't 500 on
  1463. # the path it tries to unlink.
  1464. file_path = Path(app_settings.base_dir) / test_file.file_path
  1465. file_path.parent.mkdir(parents=True, exist_ok=True)
  1466. file_path.write_text("test content")
  1467. response = await async_client.delete(
  1468. f"/api/v1/library/files/{test_file.id}",
  1469. headers={"X-API-Key": manage_library_key},
  1470. )
  1471. assert response.status_code == 200, response.text
  1472. @pytest.mark.asyncio
  1473. @pytest.mark.integration
  1474. async def test_apikey_with_manage_library_can_rename_file(
  1475. self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
  1476. ):
  1477. """PUT /library/files/{id} is gated on LIBRARY_UPDATE_ALL/OWN. Same
  1478. #1832 path as delete."""
  1479. response = await async_client.put(
  1480. f"/api/v1/library/files/{test_file.id}",
  1481. headers={"X-API-Key": manage_library_key},
  1482. json={"filename": "renamed.txt"},
  1483. )
  1484. assert response.status_code == 200, response.text
  1485. assert response.json()["filename"] == "renamed.txt"
  1486. @pytest.mark.asyncio
  1487. @pytest.mark.integration
  1488. async def test_apikey_with_manage_library_can_move_file(
  1489. self, async_client: AsyncClient, db_session, auth_setup, test_file, manage_library_key
  1490. ):
  1491. """POST /library/files/move (bulk) is gated on LIBRARY_UPDATE_ALL/OWN
  1492. — same checker, same #1832 path."""
  1493. # Create a target folder the move can land in.
  1494. from backend.app.models.library import LibraryFolder
  1495. folder = LibraryFolder(name="target")
  1496. db_session.add(folder)
  1497. await db_session.commit()
  1498. await db_session.refresh(folder)
  1499. response = await async_client.post(
  1500. "/api/v1/library/files/move",
  1501. headers={"X-API-Key": manage_library_key},
  1502. json={"file_ids": [test_file.id], "folder_id": folder.id},
  1503. )
  1504. assert response.status_code == 200, response.text
  1505. @pytest.mark.asyncio
  1506. @pytest.mark.integration
  1507. async def test_apikey_without_manage_library_still_blocked(
  1508. self, async_client: AsyncClient, db_session, auth_setup, test_file
  1509. ):
  1510. """Regression guard: a key WITHOUT can_manage_library must still get
  1511. 403 — the fix widens the allowed-permission set, it doesn't bypass
  1512. the per-key scope check."""
  1513. from backend.app.core.auth import generate_api_key
  1514. from backend.app.models.api_key import APIKey
  1515. admin = auth_setup["admin_user"]
  1516. full_key, key_hash, key_prefix = generate_api_key()
  1517. row = APIKey(
  1518. name="read-only",
  1519. key_hash=key_hash,
  1520. key_prefix=key_prefix,
  1521. user_id=admin.id,
  1522. can_read_status=True,
  1523. can_manage_library=False,
  1524. )
  1525. db_session.add(row)
  1526. await db_session.commit()
  1527. response = await async_client.delete(
  1528. f"/api/v1/library/files/{test_file.id}",
  1529. headers={"X-API-Key": full_key},
  1530. )
  1531. assert response.status_code == 403
  1532. @pytest.mark.asyncio
  1533. @pytest.mark.integration
  1534. async def test_apikey_with_manage_library_still_cannot_purge(
  1535. self, async_client: AsyncClient, db_session, auth_setup, manage_library_key
  1536. ):
  1537. """LIBRARY_PURGE deliberately stays in _APIKEY_DENIED_PERMISSIONS as
  1538. a genuinely destructive op that bypasses the soft-delete window.
  1539. can_manage_library does NOT grant it."""
  1540. response = await async_client.post(
  1541. "/api/v1/library/purge",
  1542. headers={"X-API-Key": manage_library_key},
  1543. json={"days_in_trash": 30},
  1544. )
  1545. assert response.status_code == 403
  1546. class TestPrintFileUploadValidation:
  1547. """#1401: pre-flight rejection of unprintable uploads at the library +
  1548. archive routes. Smoke tests the shared ``validate_print_file_upload``
  1549. helper through both surfaces a user can reach with a drag-drop."""
  1550. def _valid_3mf_bytes(self, name: str = "Metadata/plate_1.gcode") -> bytes:
  1551. """Build a minimal-but-real zip with the gcode-3mf magic in it so
  1552. the validator's ``startswith(b"PK\\x03\\x04")`` check passes."""
  1553. buf = io.BytesIO()
  1554. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  1555. zf.writestr(name, "; G-code\nG28\n")
  1556. return buf.getvalue()
  1557. @pytest.mark.asyncio
  1558. @pytest.mark.integration
  1559. async def test_library_rejects_raw_gcode_upload(self, async_client: AsyncClient, db_session):
  1560. """``Foo.gcode`` direct uploads are blocked at the library route —
  1561. the dispatcher would otherwise append ``.3mf`` and ship raw gcode
  1562. to the printer as a fake 3MF."""
  1563. files = {"file": ("plate_1.gcode", b"; raw gcode\nG28\n", "application/octet-stream")}
  1564. response = await async_client.post("/api/v1/library/files", files=files)
  1565. assert response.status_code == 400
  1566. # Error message must name the actual remedy, not just say "invalid".
  1567. assert "gcode.3mf" in response.json()["detail"]
  1568. @pytest.mark.asyncio
  1569. @pytest.mark.integration
  1570. async def test_library_rejects_non_zip_3mf_upload(self, async_client: AsyncClient, db_session):
  1571. """A ``.3mf`` upload whose body isn't a zip is rejected — covers
  1572. raw gcode renamed to .3mf, corrupted downloads, etc."""
  1573. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1574. response = await async_client.post("/api/v1/library/files", files=files)
  1575. assert response.status_code == 400
  1576. assert "ZIP container" in response.json()["detail"]
  1577. @pytest.mark.asyncio
  1578. @pytest.mark.integration
  1579. async def test_library_rejects_non_zip_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  1580. """The compound-extension ``.gcode.3mf`` case is gated by the same
  1581. zip-magic check — splitext returns just ``.3mf``, but the suffix
  1582. match covers both."""
  1583. files = {"file": ("plate_1.gcode.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1584. response = await async_client.post("/api/v1/library/files", files=files)
  1585. assert response.status_code == 400
  1586. assert "ZIP container" in response.json()["detail"]
  1587. @pytest.mark.asyncio
  1588. @pytest.mark.integration
  1589. async def test_library_accepts_valid_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  1590. """A real ``.gcode.3mf`` zip uploads successfully — the existing
  1591. happy path is not regressed by the new validation."""
  1592. files = {
  1593. "file": (
  1594. "plate_1.gcode.3mf",
  1595. self._valid_3mf_bytes(),
  1596. "application/zip",
  1597. )
  1598. }
  1599. response = await async_client.post("/api/v1/library/files", files=files)
  1600. assert response.status_code == 200
  1601. result = response.json()
  1602. assert result["filename"] == "plate_1.gcode.3mf"
  1603. @pytest.mark.asyncio
  1604. @pytest.mark.integration
  1605. async def test_library_upload_classifies_gcode_3mf_as_compound(self, async_client: AsyncClient, db_session):
  1606. """#1600 follow-up: upload path used to strip to the trailing
  1607. extension and store ``file_type='3mf'`` for sliced outputs, while
  1608. the external-folder scan stored ``file_type='gcode.3mf'``. Now
  1609. every ingest path goes through ``classify_file_type`` and
  1610. produces the canonical compound name."""
  1611. files = {
  1612. "file": (
  1613. "sliced.gcode.3mf",
  1614. self._valid_3mf_bytes(),
  1615. "application/zip",
  1616. )
  1617. }
  1618. response = await async_client.post("/api/v1/library/files", files=files)
  1619. assert response.status_code == 200
  1620. assert response.json()["file_type"] == "gcode.3mf"
  1621. @pytest.mark.asyncio
  1622. @pytest.mark.integration
  1623. async def test_library_get_gcode_endpoint_accepts_compound_file_type(self, async_client: AsyncClient, db_session):
  1624. """#1600 follow-up: pre-fix, ``GET /files/{id}/gcode`` only handled
  1625. ``file_type`` of ``gcode`` or ``3mf`` and 400'd on a row whose
  1626. ``file_type`` was ``gcode.3mf`` — exactly the rows the external-
  1627. folder scan was creating. The gate now treats both as 3MF and
  1628. unzips the embedded gcode the same way."""
  1629. from backend.app.models.library import LibraryFile
  1630. # Persist a real `.gcode.3mf` zip under file_type='gcode.3mf' so
  1631. # the endpoint hits the new branch.
  1632. with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
  1633. tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
  1634. tmp_path = tmp.name
  1635. lib_file = LibraryFile(
  1636. filename="sliced.gcode.3mf",
  1637. file_path=tmp_path,
  1638. file_type="gcode.3mf",
  1639. file_size=Path(tmp_path).stat().st_size,
  1640. )
  1641. db_session.add(lib_file)
  1642. await db_session.commit()
  1643. await db_session.refresh(lib_file)
  1644. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
  1645. assert response.status_code == 200
  1646. assert b"G28" in response.content
  1647. @pytest.mark.asyncio
  1648. @pytest.mark.integration
  1649. async def test_library_get_gcode_recovers_legacy_gcode_type_for_3mf(self, async_client: AsyncClient, db_session):
  1650. """#1709 regression guard. Before the fix, ``slice_and_persist``
  1651. wrote a `.gcode.3mf` ZIP container to disk but stored the row with
  1652. ``file_type='gcode'`` — the preview endpoint then streamed the
  1653. ZIP body as ``text/plain`` and the embedded G-code viewer saw
  1654. ``PK\\x03\\x04...`` instead of the toolpath. New sliced rows now
  1655. store ``file_type='gcode.3mf'``; rows already written under the
  1656. bug self-heal because the endpoint also detects the ZIP via the
  1657. ``.gcode.3mf`` filename suffix when the column is still legacy."""
  1658. from backend.app.models.library import LibraryFile
  1659. with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
  1660. tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
  1661. tmp_path = tmp.name
  1662. lib_file = LibraryFile(
  1663. filename="legacy-sliced.gcode.3mf",
  1664. file_path=tmp_path,
  1665. file_type="gcode",
  1666. file_size=Path(tmp_path).stat().st_size,
  1667. )
  1668. db_session.add(lib_file)
  1669. await db_session.commit()
  1670. await db_session.refresh(lib_file)
  1671. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
  1672. assert response.status_code == 200
  1673. assert response.headers["content-type"].startswith("text/plain")
  1674. assert b"G28" in response.content
  1675. # The whole point of #1709: must NOT be ZIP bytes shoved at the viewer.
  1676. assert not response.content.startswith(b"PK")
  1677. async def _multi_plate_file(self, db_session):
  1678. """A two-plate `.gcode.3mf` written plate 2 first, as Bambu Studio does.
  1679. The member order is copied from the file this was reported on — taking
  1680. the first `.gcode` in the zip opened plate 2.
  1681. """
  1682. from backend.app.models.library import LibraryFile
  1683. buf = io.BytesIO()
  1684. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  1685. zf.writestr("Metadata/plate_2.gcode", "; plate two\nG28\n")
  1686. zf.writestr("Metadata/plate_1.gcode", "; plate one\nG28\n")
  1687. with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
  1688. tmp.write(buf.getvalue())
  1689. tmp_path = tmp.name
  1690. lib_file = LibraryFile(
  1691. filename="two-plates.gcode.3mf",
  1692. file_path=tmp_path,
  1693. file_type="gcode.3mf",
  1694. file_size=Path(tmp_path).stat().st_size,
  1695. )
  1696. db_session.add(lib_file)
  1697. await db_session.commit()
  1698. await db_session.refresh(lib_file)
  1699. return lib_file
  1700. @pytest.mark.asyncio
  1701. @pytest.mark.integration
  1702. async def test_library_gcode_serves_the_requested_plate(self, async_client: AsyncClient, db_session):
  1703. """The viewer has always sent ``?plate=``; this route took no such
  1704. parameter, and FastAPI drops unknown query parameters without a word —
  1705. so picking a plate did nothing at all."""
  1706. lib_file = await self._multi_plate_file(db_session)
  1707. first = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=1")
  1708. second = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=2")
  1709. assert first.status_code == 200
  1710. assert b"plate one" in first.content
  1711. assert second.status_code == 200
  1712. assert b"plate two" in second.content
  1713. @pytest.mark.asyncio
  1714. @pytest.mark.integration
  1715. async def test_library_gcode_without_a_plate_serves_the_first_plate(self, async_client: AsyncClient, db_session):
  1716. """Not the first member in the zip — that is plate 2 in this file, and
  1717. opening a multi-plate file from the File Manager passes no plate."""
  1718. lib_file = await self._multi_plate_file(db_session)
  1719. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
  1720. assert response.status_code == 200
  1721. assert b"plate one" in response.content
  1722. @pytest.mark.asyncio
  1723. @pytest.mark.integration
  1724. async def test_library_gcode_rejects_a_plate_the_file_does_not_hold(self, async_client: AsyncClient, db_session):
  1725. """404 rather than quietly rendering some other plate."""
  1726. lib_file = await self._multi_plate_file(db_session)
  1727. missing = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=3")
  1728. zeroth = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode?plate=0")
  1729. assert missing.status_code == 404
  1730. assert zeroth.status_code == 400
  1731. @pytest.mark.asyncio
  1732. @pytest.mark.integration
  1733. async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
  1734. """STL / image / other non-print uploads bypass the validator
  1735. entirely — Bambuddy is also a library, not just a print dispatcher."""
  1736. files = {"file": ("model.stl", b"solid test\nendsolid test", "application/octet-stream")}
  1737. response = await async_client.post(
  1738. "/api/v1/library/files", files=files, params={"generate_stl_thumbnails": "false"}
  1739. )
  1740. assert response.status_code == 200
  1741. @pytest.mark.asyncio
  1742. @pytest.mark.integration
  1743. async def test_archive_upload_rejects_non_zip(self, async_client: AsyncClient, db_session):
  1744. """``POST /archives/upload`` shares the same validator — covers the
  1745. manual archive-upload entry point too."""
  1746. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1747. response = await async_client.post("/api/v1/archives/upload", files=files)
  1748. assert response.status_code == 400
  1749. assert "ZIP container" in response.json()["detail"]
  1750. @pytest.mark.asyncio
  1751. @pytest.mark.integration
  1752. async def test_archive_bulk_upload_collects_per_file_errors(self, async_client: AsyncClient, db_session):
  1753. """The bulk-archive route reports validation failures per file and
  1754. continues processing the remaining items — one bad upload in a
  1755. 10-file drag-drop must not abort the whole batch."""
  1756. good = self._valid_3mf_bytes()
  1757. bad = b"; raw gcode\nG28\n"
  1758. # httpx multipart with a list-of-tuples preserves order + same field name.
  1759. files = [
  1760. ("files", ("good.3mf", good, "application/zip")),
  1761. ("files", ("bad.3mf", bad, "application/octet-stream")),
  1762. ]
  1763. response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
  1764. assert response.status_code == 200
  1765. body = response.json()
  1766. # The bulk route's archive_print may still reject the "good" file
  1767. # downstream (no printer match, etc.) — we don't care about that
  1768. # here; what matters is the bad file lands in `errors` with the
  1769. # validator's message and the route didn't 500.
  1770. assert body["failed"] >= 1
  1771. bad_errors = [e for e in body["errors"] if e["filename"] == "bad.3mf"]
  1772. assert bad_errors, body
  1773. assert "ZIP container" in bad_errors[0]["error"]