test_library_api.py 73 KB

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