test_library_api.py 55 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321
  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_create_folder(self, async_client: AsyncClient, db_session):
  38. """Verify folder can be created."""
  39. data = {"name": "New Folder"}
  40. response = await async_client.post("/api/v1/library/folders", json=data)
  41. assert response.status_code == 200
  42. result = response.json()
  43. assert result["name"] == "New Folder"
  44. assert result["id"] is not None
  45. @pytest.mark.asyncio
  46. @pytest.mark.integration
  47. async def test_create_nested_folder(self, async_client: AsyncClient, folder_factory, db_session):
  48. """Verify nested folder can be created."""
  49. parent = await folder_factory(name="Parent")
  50. data = {"name": "Child", "parent_id": parent.id}
  51. response = await async_client.post("/api/v1/library/folders", json=data)
  52. assert response.status_code == 200
  53. result = response.json()
  54. assert result["name"] == "Child"
  55. assert result["parent_id"] == parent.id
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_get_folder(self, async_client: AsyncClient, folder_factory, db_session):
  59. """Verify single folder can be retrieved."""
  60. folder = await folder_factory(name="Test Folder")
  61. response = await async_client.get(f"/api/v1/library/folders/{folder.id}")
  62. assert response.status_code == 200
  63. result = response.json()
  64. assert result["id"] == folder.id
  65. assert result["name"] == "Test Folder"
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_get_folder_not_found(self, async_client: AsyncClient, db_session):
  69. """Verify 404 for non-existent folder."""
  70. response = await async_client.get("/api/v1/library/folders/9999")
  71. assert response.status_code == 404
  72. @pytest.mark.asyncio
  73. @pytest.mark.integration
  74. async def test_update_folder(self, async_client: AsyncClient, folder_factory, db_session):
  75. """Verify folder can be updated."""
  76. folder = await folder_factory(name="Old Name")
  77. data = {"name": "New Name"}
  78. response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json=data)
  79. assert response.status_code == 200
  80. result = response.json()
  81. assert result["name"] == "New Name"
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session):
  85. """Verify folder can be deleted."""
  86. folder = await folder_factory()
  87. response = await async_client.delete(f"/api/v1/library/folders/{folder.id}")
  88. assert response.status_code == 200
  89. result = response.json()
  90. assert result.get("message") or result.get("success", True)
  91. class TestLibraryFilesAPI:
  92. """Integration tests for library files endpoints."""
  93. @pytest.fixture
  94. async def folder_factory(self, db_session):
  95. """Factory to create test folders."""
  96. _counter = [0]
  97. async def _create_folder(**kwargs):
  98. from backend.app.models.library import LibraryFolder
  99. _counter[0] += 1
  100. counter = _counter[0]
  101. defaults = {"name": f"Test Folder {counter}"}
  102. defaults.update(kwargs)
  103. folder = LibraryFolder(**defaults)
  104. db_session.add(folder)
  105. await db_session.commit()
  106. await db_session.refresh(folder)
  107. return folder
  108. return _create_folder
  109. @pytest.fixture
  110. async def file_factory(self, db_session):
  111. """Factory to create test files."""
  112. _counter = [0]
  113. async def _create_file(**kwargs):
  114. from backend.app.models.library import LibraryFile
  115. _counter[0] += 1
  116. counter = _counter[0]
  117. defaults = {
  118. "filename": f"test_file_{counter}.3mf",
  119. "file_path": f"/test/path/test_file_{counter}.3mf",
  120. "file_size": 1024,
  121. "file_type": "3mf",
  122. }
  123. defaults.update(kwargs)
  124. lib_file = LibraryFile(**defaults)
  125. db_session.add(lib_file)
  126. await db_session.commit()
  127. await db_session.refresh(lib_file)
  128. return lib_file
  129. return _create_file
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_list_files_empty(self, async_client: AsyncClient, db_session):
  133. """Verify empty file list returns empty array."""
  134. response = await async_client.get("/api/v1/library/files")
  135. assert response.status_code == 200
  136. assert response.json() == []
  137. @pytest.mark.asyncio
  138. @pytest.mark.integration
  139. async def test_list_files_in_folder(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  140. """Verify files can be filtered by folder."""
  141. folder = await folder_factory()
  142. file1 = await file_factory(folder_id=folder.id)
  143. await file_factory() # File in root (no folder)
  144. response = await async_client.get(f"/api/v1/library/files?folder_id={folder.id}")
  145. assert response.status_code == 200
  146. result = response.json()
  147. assert len(result) == 1
  148. assert result[0]["id"] == file1.id
  149. @pytest.mark.asyncio
  150. @pytest.mark.integration
  151. async def test_list_files_by_project_id(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  152. """#932: project_id filter returns files across all folders linked to the project.
  153. Replaces the prior N+1 pattern where the frontend fired one request per
  154. linked folder. A single JOIN query must return every file in folders whose
  155. project_id matches, while excluding files from unlinked folders.
  156. """
  157. from backend.app.models.project import Project
  158. project = Project(name="Test Project for Files", color="#00ff00")
  159. db_session.add(project)
  160. await db_session.commit()
  161. await db_session.refresh(project)
  162. folder_a = await folder_factory(name="Folder A", project_id=project.id)
  163. folder_b = await folder_factory(name="Folder B", project_id=project.id)
  164. other_folder = await folder_factory(name="Unlinked")
  165. linked_a = await file_factory(folder_id=folder_a.id, filename="a.3mf")
  166. linked_b = await file_factory(folder_id=folder_b.id, filename="b.3mf")
  167. await file_factory(folder_id=other_folder.id, filename="unlinked.3mf")
  168. await file_factory(filename="root.3mf") # no folder → not part of any project
  169. response = await async_client.get(f"/api/v1/library/files?project_id={project.id}")
  170. assert response.status_code == 200
  171. result = response.json()
  172. ids = {f["id"] for f in result}
  173. assert ids == {linked_a.id, linked_b.id}
  174. @pytest.mark.asyncio
  175. @pytest.mark.integration
  176. async def test_list_files_folder_id_takes_precedence_over_project_id(
  177. self, async_client: AsyncClient, folder_factory, file_factory, db_session
  178. ):
  179. """When both folder_id and project_id are passed, folder_id wins.
  180. Documented precedence in list_files(): folder_id > project_id > include_root.
  181. This guards the behavior so a future refactor can't silently flip it.
  182. """
  183. from backend.app.models.project import Project
  184. project = Project(name="Precedence Project")
  185. db_session.add(project)
  186. await db_session.commit()
  187. await db_session.refresh(project)
  188. folder_linked = await folder_factory(name="Linked", project_id=project.id)
  189. folder_other = await folder_factory(name="Other")
  190. await file_factory(folder_id=folder_linked.id, filename="linked.3mf")
  191. other_file = await file_factory(folder_id=folder_other.id, filename="other.3mf")
  192. # folder_id points at a folder that is NOT in the project — must return
  193. # that folder's contents and ignore project_id entirely.
  194. response = await async_client.get(f"/api/v1/library/files?folder_id={folder_other.id}&project_id={project.id}")
  195. assert response.status_code == 200
  196. result = response.json()
  197. assert len(result) == 1
  198. assert result[0]["id"] == other_file.id
  199. @pytest.mark.asyncio
  200. @pytest.mark.integration
  201. async def test_list_files_internal_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  202. """#1621: `internal_only=true` restricts the listing to files in managed
  203. storage (`is_external=False`) so a linked NAS with hundreds of files
  204. doesn't drown the user's own uploads in the "All Files" sidebar view."""
  205. internal_folder = await folder_factory(name="My uploads")
  206. external_folder = await folder_factory(name="NAS", is_external=True, external_path="/mnt/nas")
  207. internal_file = await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
  208. await file_factory(folder_id=external_folder.id, filename="nas.3mf", is_external=True)
  209. root_file = await file_factory(filename="root.3mf", is_external=False) # Root-uploaded is always internal.
  210. response = await async_client.get("/api/v1/library/files?include_root=false&internal_only=true")
  211. assert response.status_code == 200
  212. ids = {f["id"] for f in response.json()}
  213. assert ids == {internal_file.id, root_file.id}
  214. @pytest.mark.asyncio
  215. @pytest.mark.integration
  216. async def test_list_files_external_only(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  217. """#1621 symmetric: `external_only=true` returns the combined view
  218. across every linked external folder so users with several mounts can
  219. see all external content in one place without clicking each folder."""
  220. internal_folder = await folder_factory(name="My uploads")
  221. nas_a = await folder_factory(name="NAS A", is_external=True, external_path="/mnt/a")
  222. nas_b = await folder_factory(name="NAS B", is_external=True, external_path="/mnt/b")
  223. await file_factory(folder_id=internal_folder.id, filename="mine.3mf", is_external=False)
  224. ext_a = await file_factory(folder_id=nas_a.id, filename="a.3mf", is_external=True)
  225. ext_b = await file_factory(folder_id=nas_b.id, filename="b.3mf", is_external=True)
  226. response = await async_client.get("/api/v1/library/files?include_root=false&external_only=true")
  227. assert response.status_code == 200
  228. ids = {f["id"] for f in response.json()}
  229. assert ids == {ext_a.id, ext_b.id}
  230. @pytest.mark.asyncio
  231. @pytest.mark.integration
  232. async def test_list_files_internal_and_external_mutually_exclusive(self, async_client: AsyncClient, db_session):
  233. """Both flags together is a caller bug — fail loud (400) rather than
  234. silently picking one, so a frontend regression is caught immediately."""
  235. response = await async_client.get("/api/v1/library/files?internal_only=true&external_only=true")
  236. assert response.status_code == 400
  237. assert "mutually exclusive" in response.json()["detail"]
  238. @pytest.mark.asyncio
  239. @pytest.mark.integration
  240. async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):
  241. """Verify single file can be retrieved."""
  242. lib_file = await file_factory(filename="test.3mf")
  243. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  244. assert response.status_code == 200
  245. result = response.json()
  246. assert result["id"] == lib_file.id
  247. assert result["filename"] == "test.3mf"
  248. @pytest.mark.asyncio
  249. @pytest.mark.integration
  250. async def test_get_file_not_found(self, async_client: AsyncClient, db_session):
  251. """Verify 404 for non-existent file."""
  252. response = await async_client.get("/api/v1/library/files/9999")
  253. assert response.status_code == 404
  254. @pytest.mark.asyncio
  255. @pytest.mark.integration
  256. async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session):
  257. """Verify file can be deleted."""
  258. lib_file = await file_factory()
  259. response = await async_client.delete(f"/api/v1/library/files/{lib_file.id}")
  260. assert response.status_code == 200
  261. result = response.json()
  262. assert result.get("message") or result.get("success", True)
  263. @pytest.mark.asyncio
  264. @pytest.mark.integration
  265. async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
  266. """Verify file can be renamed."""
  267. lib_file = await file_factory(filename="old_name.3mf")
  268. data = {"filename": "new_name.3mf"}
  269. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  270. assert response.status_code == 200
  271. result = response.json()
  272. assert result["filename"] == "new_name.3mf"
  273. @pytest.mark.asyncio
  274. @pytest.mark.integration
  275. async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
  276. """Verify file rename fails with a forward slash (FAT32-illegal, #1540)."""
  277. lib_file = await file_factory(filename="test.3mf")
  278. data = {"filename": "path/to/file.3mf"}
  279. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  280. assert response.status_code == 400
  281. assert "invalid character" in response.json()["detail"].lower()
  282. assert "/" in response.json()["detail"]
  283. @pytest.mark.asyncio
  284. @pytest.mark.integration
  285. async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
  286. """Verify file rename fails with a backslash (FAT32-illegal, #1540)."""
  287. lib_file = await file_factory(filename="test.3mf")
  288. data = {"filename": "path\\to\\file.3mf"}
  289. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  290. assert response.status_code == 400
  291. assert "invalid character" in response.json()["detail"].lower()
  292. assert "\\" in response.json()["detail"]
  293. @pytest.mark.asyncio
  294. @pytest.mark.integration
  295. async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  296. """Verify library stats endpoint returns counts."""
  297. await folder_factory()
  298. await folder_factory()
  299. await file_factory()
  300. response = await async_client.get("/api/v1/library/stats")
  301. assert response.status_code == 200
  302. result = response.json()
  303. assert result["total_folders"] == 2
  304. assert result["total_files"] == 1
  305. @pytest.mark.asyncio
  306. @pytest.mark.integration
  307. async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  308. """Verify file list response includes user tracking fields (Issue #206)."""
  309. lib_file = await file_factory(filename="test.3mf")
  310. response = await async_client.get("/api/v1/library/files?include_root=false")
  311. assert response.status_code == 200
  312. result = response.json()
  313. assert len(result) >= 1
  314. # Find our test file
  315. test_file = next((f for f in result if f["id"] == lib_file.id), None)
  316. assert test_file is not None
  317. # User tracking fields should be present (even if null)
  318. assert "created_by_id" in test_file
  319. assert "created_by_username" in test_file
  320. @pytest.mark.asyncio
  321. @pytest.mark.integration
  322. async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  323. """Verify file detail response includes user tracking fields (Issue #206)."""
  324. lib_file = await file_factory(filename="test_detail.3mf")
  325. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  326. assert response.status_code == 200
  327. result = response.json()
  328. # User tracking fields should be present (even if null)
  329. assert "created_by_id" in result
  330. assert "created_by_username" in result
  331. @pytest.mark.asyncio
  332. @pytest.mark.integration
  333. async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
  334. """Verify file created with user shows username in response (Issue #206)."""
  335. from backend.app.models.library import LibraryFile
  336. from backend.app.models.user import User
  337. # Create a test user
  338. user = User(username="testuploader", password_hash="fakehash", role="user")
  339. db_session.add(user)
  340. await db_session.flush()
  341. # Create a file with created_by_id set
  342. lib_file = LibraryFile(
  343. filename="user_uploaded.3mf",
  344. file_path="/test/user_uploaded.3mf",
  345. file_size=2048,
  346. file_type="3mf",
  347. created_by_id=user.id,
  348. )
  349. db_session.add(lib_file)
  350. await db_session.commit()
  351. await db_session.refresh(lib_file)
  352. # Verify file detail shows username
  353. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  354. assert response.status_code == 200
  355. result = response.json()
  356. assert result["created_by_id"] == user.id
  357. assert result["created_by_username"] == "testuploader"
  358. # Verify file list also shows username
  359. response = await async_client.get("/api/v1/library/files?include_root=false")
  360. assert response.status_code == 200
  361. files = response.json()
  362. test_file = next((f for f in files if f["id"] == lib_file.id), None)
  363. assert test_file is not None
  364. assert test_file["created_by_id"] == user.id
  365. assert test_file["created_by_username"] == "testuploader"
  366. class TestLibraryAddToQueueAPI:
  367. """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
  368. @pytest.fixture
  369. async def printer_factory(self, db_session):
  370. """Factory to create test printers."""
  371. _counter = [0]
  372. async def _create_printer(**kwargs):
  373. from backend.app.models.printer import Printer
  374. _counter[0] += 1
  375. counter = _counter[0]
  376. defaults = {
  377. "name": f"Test Printer {counter}",
  378. "ip_address": f"192.168.1.{100 + counter}",
  379. "serial_number": f"TESTSERIAL{counter:04d}",
  380. "access_code": "12345678",
  381. "model": "X1C",
  382. }
  383. defaults.update(kwargs)
  384. printer = Printer(**defaults)
  385. db_session.add(printer)
  386. await db_session.commit()
  387. await db_session.refresh(printer)
  388. return printer
  389. return _create_printer
  390. @pytest.fixture
  391. async def library_file_factory(self, db_session):
  392. """Factory to create test library files."""
  393. _counter = [0]
  394. async def _create_library_file(**kwargs):
  395. from backend.app.models.library import LibraryFile
  396. _counter[0] += 1
  397. counter = _counter[0]
  398. defaults = {
  399. "filename": f"test_file_{counter}.gcode.3mf",
  400. "file_path": f"/test/path/test_file_{counter}.gcode.3mf",
  401. "file_size": 1024,
  402. "file_type": "3mf",
  403. }
  404. defaults.update(kwargs)
  405. lib_file = LibraryFile(**defaults)
  406. db_session.add(lib_file)
  407. await db_session.commit()
  408. await db_session.refresh(lib_file)
  409. return lib_file
  410. return _create_library_file
  411. @pytest.mark.asyncio
  412. @pytest.mark.integration
  413. async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
  414. """Verify error for non-existent file."""
  415. await printer_factory()
  416. data = {"file_ids": [9999]}
  417. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  418. assert response.status_code == 200
  419. result = response.json()
  420. assert len(result["added"]) == 0
  421. assert len(result["errors"]) == 1
  422. assert result["errors"][0]["file_id"] == 9999
  423. @pytest.mark.asyncio
  424. @pytest.mark.integration
  425. async def test_add_non_sliced_file_to_queue_fails(
  426. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  427. ):
  428. """Verify non-sliced file cannot be added to queue."""
  429. await printer_factory()
  430. lib_file = await library_file_factory(
  431. filename="model.stl",
  432. file_path="/test/path/model.stl",
  433. file_type="stl",
  434. )
  435. data = {"file_ids": [lib_file.id]}
  436. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  437. assert response.status_code == 200
  438. result = response.json()
  439. assert len(result["added"]) == 0
  440. assert len(result["errors"]) == 1
  441. assert "sliced" in result["errors"][0]["error"].lower()
  442. class TestLibraryZipExtractAPI:
  443. """Integration tests for ZIP extraction endpoint."""
  444. @pytest.mark.asyncio
  445. @pytest.mark.integration
  446. async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
  447. """Verify non-ZIP files are rejected."""
  448. # Create a fake file that's not a ZIP
  449. files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
  450. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  451. assert response.status_code == 400
  452. assert "ZIP" in response.json()["detail"]
  453. @pytest.mark.asyncio
  454. @pytest.mark.integration
  455. async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
  456. """Verify basic ZIP extraction works."""
  457. import io
  458. # Create a simple ZIP file in memory
  459. zip_buffer = io.BytesIO()
  460. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  461. zf.writestr("test1.txt", "Content of file 1")
  462. zf.writestr("test2.txt", "Content of file 2")
  463. zip_buffer.seek(0)
  464. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  465. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  466. assert response.status_code == 200
  467. result = response.json()
  468. assert result["extracted"] == 2
  469. assert len(result["files"]) == 2
  470. assert len(result["errors"]) == 0
  471. @pytest.mark.asyncio
  472. @pytest.mark.integration
  473. async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
  474. """Verify ZIP extraction preserves folder structure."""
  475. import io
  476. # Create a ZIP file with folder structure
  477. zip_buffer = io.BytesIO()
  478. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  479. zf.writestr("folder1/file1.txt", "Content 1")
  480. zf.writestr("folder1/subfolder/file2.txt", "Content 2")
  481. zf.writestr("folder2/file3.txt", "Content 3")
  482. zip_buffer.seek(0)
  483. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  484. params = {"preserve_structure": "true"}
  485. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  486. assert response.status_code == 200
  487. result = response.json()
  488. assert result["extracted"] == 3
  489. assert result["folders_created"] >= 3 # folder1, folder1/subfolder, folder2
  490. @pytest.mark.asyncio
  491. @pytest.mark.integration
  492. async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
  493. """Verify ZIP extraction can extract flat (no folders)."""
  494. import io
  495. # Create a ZIP file with folder structure
  496. zip_buffer = io.BytesIO()
  497. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  498. zf.writestr("folder/file1.txt", "Content 1")
  499. zf.writestr("folder/file2.txt", "Content 2")
  500. zip_buffer.seek(0)
  501. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  502. params = {"preserve_structure": "false"}
  503. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  504. assert response.status_code == 200
  505. result = response.json()
  506. assert result["extracted"] == 2
  507. assert result["folders_created"] == 0 # No folders created when flat
  508. @pytest.mark.asyncio
  509. @pytest.mark.integration
  510. async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
  511. """Verify ZIP extraction skips __MACOSX and hidden files."""
  512. import io
  513. # Create a ZIP file with macOS junk files
  514. zip_buffer = io.BytesIO()
  515. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  516. zf.writestr("real_file.txt", "Real content")
  517. zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
  518. zf.writestr(".hidden_file", "Hidden content")
  519. zip_buffer.seek(0)
  520. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  521. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  522. assert response.status_code == 200
  523. result = response.json()
  524. assert result["extracted"] == 1 # Only real_file.txt
  525. assert result["files"][0]["filename"] == "real_file.txt"
  526. @pytest.mark.asyncio
  527. @pytest.mark.integration
  528. async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):
  529. """Verify ZIP extraction creates a folder from the ZIP filename."""
  530. import io
  531. # Create a ZIP file with some files
  532. zip_buffer = io.BytesIO()
  533. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  534. zf.writestr("file1.txt", "Content 1")
  535. zf.writestr("file2.txt", "Content 2")
  536. zip_buffer.seek(0)
  537. files = {"file": ("MyProject.zip", zip_buffer.read(), "application/zip")}
  538. params = {"create_folder_from_zip": "true", "preserve_structure": "false"}
  539. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  540. assert response.status_code == 200
  541. result = response.json()
  542. assert result["extracted"] == 2
  543. assert result["folders_created"] == 1 # MyProject folder created
  544. # Verify the files are in a folder
  545. assert result["files"][0]["folder_id"] is not None
  546. assert result["files"][1]["folder_id"] is not None
  547. # Both files should be in the same folder
  548. assert result["files"][0]["folder_id"] == result["files"][1]["folder_id"]
  549. # Verify the folder was created with the right name
  550. folder_response = await async_client.get(f"/api/v1/library/folders/{result['files'][0]['folder_id']}")
  551. assert folder_response.status_code == 200
  552. folder = folder_response.json()
  553. assert folder["name"] == "MyProject"
  554. class TestLibraryStlThumbnailAPI:
  555. """Integration tests for STL thumbnail generation endpoints."""
  556. @pytest.fixture
  557. async def file_factory(self, db_session):
  558. """Factory to create test files."""
  559. _counter = [0]
  560. async def _create_file(**kwargs):
  561. from backend.app.models.library import LibraryFile
  562. _counter[0] += 1
  563. counter = _counter[0]
  564. defaults = {
  565. "filename": f"test_model_{counter}.stl",
  566. "file_path": f"/test/path/test_model_{counter}.stl",
  567. "file_size": 1024,
  568. "file_type": "stl",
  569. }
  570. defaults.update(kwargs)
  571. lib_file = LibraryFile(**defaults)
  572. db_session.add(lib_file)
  573. await db_session.commit()
  574. await db_session.refresh(lib_file)
  575. return lib_file
  576. return _create_file
  577. @pytest.mark.asyncio
  578. @pytest.mark.integration
  579. async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
  580. """Verify batch thumbnail generation with no files."""
  581. data = {"all_missing": True}
  582. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  583. assert response.status_code == 200
  584. result = response.json()
  585. assert result["processed"] == 0
  586. assert result["succeeded"] == 0
  587. assert result["failed"] == 0
  588. assert result["results"] == []
  589. @pytest.mark.asyncio
  590. @pytest.mark.integration
  591. async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
  592. """Verify batch thumbnail generation with no criteria returns empty."""
  593. data = {}
  594. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  595. assert response.status_code == 200
  596. result = response.json()
  597. assert result["processed"] == 0
  598. @pytest.mark.asyncio
  599. @pytest.mark.integration
  600. async def test_batch_generate_thumbnails_file_not_on_disk(
  601. self, async_client: AsyncClient, file_factory, db_session
  602. ):
  603. """Verify batch thumbnail generation handles missing files gracefully."""
  604. # Create a file in DB but not on disk
  605. stl_file = await file_factory(
  606. filename="missing.stl",
  607. file_path="/nonexistent/path/missing.stl",
  608. thumbnail_path=None,
  609. )
  610. data = {"file_ids": [stl_file.id]}
  611. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  612. assert response.status_code == 200
  613. result = response.json()
  614. assert result["processed"] == 1
  615. assert result["succeeded"] == 0
  616. assert result["failed"] == 1
  617. assert result["results"][0]["success"] is False
  618. assert "not found" in result["results"][0]["error"].lower()
  619. @pytest.mark.asyncio
  620. @pytest.mark.integration
  621. async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
  622. """Verify batch thumbnail generation with a real STL file."""
  623. from backend.app.models.library import LibraryFile
  624. # Create a simple ASCII STL cube
  625. stl_content = """solid cube
  626. facet normal 0 0 -1
  627. outer loop
  628. vertex 0 0 0
  629. vertex 1 0 0
  630. vertex 1 1 0
  631. endloop
  632. endfacet
  633. facet normal 0 0 1
  634. outer loop
  635. vertex 0 0 1
  636. vertex 1 1 1
  637. vertex 1 0 1
  638. endloop
  639. endfacet
  640. endsolid cube"""
  641. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
  642. f.write(stl_content)
  643. stl_path = f.name
  644. try:
  645. # Create file in DB pointing to real STL
  646. lib_file = LibraryFile(
  647. filename="test_cube.stl",
  648. file_path=stl_path,
  649. file_size=len(stl_content),
  650. file_type="stl",
  651. thumbnail_path=None,
  652. )
  653. db_session.add(lib_file)
  654. await db_session.commit()
  655. await db_session.refresh(lib_file)
  656. data = {"file_ids": [lib_file.id]}
  657. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  658. assert response.status_code == 200
  659. result = response.json()
  660. assert result["processed"] == 1
  661. # Result depends on whether trimesh/matplotlib are installed
  662. # Either succeeds or fails gracefully
  663. assert result["succeeded"] + result["failed"] == 1
  664. finally:
  665. import os
  666. if os.path.exists(stl_path):
  667. os.unlink(stl_path)
  668. @pytest.mark.asyncio
  669. @pytest.mark.integration
  670. async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  671. """Verify file upload accepts generate_stl_thumbnails parameter."""
  672. # Create a simple STL file
  673. stl_content = b"solid test\nendsolid test"
  674. files = {"file": ("test.stl", stl_content, "application/octet-stream")}
  675. params = {"generate_stl_thumbnails": "false"}
  676. response = await async_client.post("/api/v1/library/files", files=files, params=params)
  677. assert response.status_code == 200
  678. result = response.json()
  679. assert result["filename"] == "test.stl"
  680. assert result["file_type"] == "stl"
  681. # No thumbnail should be generated when disabled
  682. assert result["thumbnail_path"] is None
  683. @pytest.mark.asyncio
  684. @pytest.mark.integration
  685. async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  686. """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
  687. # Create a ZIP file containing an STL
  688. stl_content = b"solid test\nendsolid test"
  689. zip_buffer = io.BytesIO()
  690. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  691. zf.writestr("model.stl", stl_content)
  692. zip_buffer.seek(0)
  693. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  694. params = {"generate_stl_thumbnails": "false"}
  695. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  696. assert response.status_code == 200
  697. result = response.json()
  698. assert result["extracted"] == 1
  699. assert result["files"][0]["filename"] == "model.stl"
  700. @pytest.mark.asyncio
  701. @pytest.mark.integration
  702. async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
  703. """Verify batch thumbnail generation can filter by folder."""
  704. from backend.app.models.library import LibraryFolder
  705. # Create a folder
  706. folder = LibraryFolder(name="STL Folder")
  707. db_session.add(folder)
  708. await db_session.commit()
  709. await db_session.refresh(folder)
  710. # Create STL file in folder (no thumbnail)
  711. stl_in_folder = await file_factory(
  712. filename="in_folder.stl",
  713. folder_id=folder.id,
  714. thumbnail_path=None,
  715. )
  716. # Create STL file at root (no thumbnail)
  717. _stl_at_root = await file_factory(
  718. filename="at_root.stl",
  719. folder_id=None,
  720. thumbnail_path=None,
  721. )
  722. # Request thumbnails only for files in folder
  723. data = {"folder_id": folder.id, "all_missing": True}
  724. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  725. assert response.status_code == 200
  726. result = response.json()
  727. # Should only process the file in the folder
  728. assert result["processed"] == 1
  729. assert result["results"][0]["file_id"] == stl_in_folder.id
  730. @pytest.mark.asyncio
  731. @pytest.mark.integration
  732. async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
  733. """Verify batch thumbnail generation finds all STL files missing thumbnails."""
  734. # Create files with and without thumbnails
  735. _stl_with_thumb = await file_factory(
  736. filename="with_thumb.stl",
  737. thumbnail_path="/some/path/thumb.png",
  738. )
  739. stl_without_thumb1 = await file_factory(
  740. filename="without_thumb1.stl",
  741. thumbnail_path=None,
  742. )
  743. stl_without_thumb2 = await file_factory(
  744. filename="without_thumb2.stl",
  745. thumbnail_path=None,
  746. )
  747. data = {"all_missing": True}
  748. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  749. assert response.status_code == 200
  750. result = response.json()
  751. # Should only process files without thumbnails
  752. assert result["processed"] == 2
  753. file_ids = {r["file_id"] for r in result["results"]}
  754. assert stl_without_thumb1.id in file_ids
  755. assert stl_without_thumb2.id in file_ids
  756. class TestLibraryPathHelpers:
  757. """Tests for path handling utilities used for backup portability."""
  758. def test_to_relative_path_converts_absolute(self):
  759. """Verify absolute paths are converted to relative paths."""
  760. from backend.app.api.routes.library import to_relative_path
  761. from backend.app.core.config import settings
  762. base_dir = str(settings.base_dir)
  763. abs_path = f"{base_dir}/archive/library/files/test.3mf"
  764. rel_path = to_relative_path(abs_path)
  765. assert not rel_path.startswith("/")
  766. assert rel_path == "archive/library/files/test.3mf"
  767. def test_to_relative_path_handles_path_object(self):
  768. """Verify Path objects are handled correctly."""
  769. from pathlib import Path
  770. from backend.app.api.routes.library import to_relative_path
  771. from backend.app.core.config import settings
  772. abs_path = Path(settings.base_dir) / "archive" / "test.3mf"
  773. rel_path = to_relative_path(abs_path)
  774. assert not rel_path.startswith("/")
  775. assert rel_path == "archive/test.3mf"
  776. def test_to_relative_path_returns_empty_for_empty_input(self):
  777. """Verify empty input returns empty string."""
  778. from backend.app.api.routes.library import to_relative_path
  779. assert to_relative_path("") == ""
  780. assert to_relative_path(None) == ""
  781. def test_to_absolute_path_converts_relative(self):
  782. """Verify relative paths are converted to absolute paths."""
  783. from backend.app.api.routes.library import to_absolute_path
  784. from backend.app.core.config import settings
  785. rel_path = "archive/library/files/test.3mf"
  786. abs_path = to_absolute_path(rel_path)
  787. assert abs_path is not None
  788. assert abs_path.is_absolute()
  789. assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf"
  790. def test_to_absolute_path_handles_already_absolute(self):
  791. """Verify already absolute paths are returned as-is (for backwards compatibility)."""
  792. from backend.app.api.routes.library import to_absolute_path
  793. abs_path_str = "/data/archive/test.3mf"
  794. result = to_absolute_path(abs_path_str)
  795. assert result is not None
  796. assert str(result) == abs_path_str
  797. def test_to_absolute_path_returns_none_for_empty(self):
  798. """Verify None/empty input returns None."""
  799. from backend.app.api.routes.library import to_absolute_path
  800. assert to_absolute_path(None) is None
  801. assert to_absolute_path("") is None
  802. class TestLibraryPermissions:
  803. """Tests for library permission enforcement."""
  804. @pytest.fixture
  805. async def auth_setup(self, db_session):
  806. """Set up auth with users of different permission levels."""
  807. from backend.app.core.auth import create_access_token, get_password_hash
  808. from backend.app.models.group import Group
  809. from backend.app.models.settings import Settings
  810. from backend.app.models.user import User
  811. # Enable auth
  812. settings = Settings(key="auth_enabled", value="true")
  813. db_session.add(settings)
  814. await db_session.commit()
  815. # Groups are auto-seeded during db init, but we need to commit them
  816. await db_session.commit()
  817. # Get groups
  818. from sqlalchemy import select
  819. admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
  820. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  821. viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  822. password_hash = get_password_hash("password")
  823. # Create users
  824. admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
  825. admin_user.groups.append(admin_group)
  826. operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
  827. operator_user.groups.append(operator_group)
  828. viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
  829. viewer_user.groups.append(viewer_group)
  830. db_session.add_all([admin_user, operator_user, viewer_user])
  831. await db_session.commit()
  832. # Create tokens
  833. admin_token = create_access_token(data={"sub": admin_user.username})
  834. operator_token = create_access_token(data={"sub": operator_user.username})
  835. viewer_token = create_access_token(data={"sub": viewer_user.username})
  836. return {
  837. "admin_user": admin_user,
  838. "operator_user": operator_user,
  839. "viewer_user": viewer_user,
  840. "admin_token": admin_token,
  841. "operator_token": operator_token,
  842. "viewer_token": viewer_token,
  843. }
  844. @pytest.fixture
  845. async def test_file(self, db_session, auth_setup):
  846. """Create a test file owned by the operator user."""
  847. from backend.app.models.library import LibraryFile
  848. operator_user = auth_setup["operator_user"]
  849. lib_file = LibraryFile(
  850. filename="test.txt",
  851. file_path="data/archive/library/files/test.txt",
  852. file_type="txt",
  853. file_size=100,
  854. created_by_id=operator_user.id,
  855. )
  856. db_session.add(lib_file)
  857. await db_session.commit()
  858. await db_session.refresh(lib_file)
  859. return lib_file
  860. @pytest.mark.asyncio
  861. @pytest.mark.integration
  862. async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
  863. """Verify list_files requires library:read permission."""
  864. viewer_token = auth_setup["viewer_token"]
  865. # Viewers have library:read, should succeed
  866. response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
  867. assert response.status_code == 200
  868. @pytest.mark.asyncio
  869. @pytest.mark.integration
  870. async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
  871. """Verify list_files denied without auth when auth is enabled."""
  872. from backend.app.models.settings import Settings
  873. # Enable auth
  874. settings = Settings(key="auth_enabled", value="true")
  875. db_session.add(settings)
  876. await db_session.commit()
  877. # Request without token should fail
  878. response = await async_client.get("/api/v1/library/files")
  879. assert response.status_code == 401
  880. @pytest.mark.asyncio
  881. @pytest.mark.integration
  882. async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  883. """Verify operator can delete their own files."""
  884. from pathlib import Path
  885. # Create actual file on disk so delete doesn't fail
  886. from backend.app.core.config import settings as app_settings
  887. file_path = Path(app_settings.base_dir) / test_file.file_path
  888. file_path.parent.mkdir(parents=True, exist_ok=True)
  889. file_path.write_text("test content")
  890. operator_token = auth_setup["operator_token"]
  891. response = await async_client.delete(
  892. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  893. )
  894. assert response.status_code == 200
  895. @pytest.mark.asyncio
  896. @pytest.mark.integration
  897. async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
  898. """Verify operator cannot delete files owned by others."""
  899. # Create another operator user with a file
  900. from sqlalchemy import select
  901. from backend.app.core.auth import create_access_token
  902. from backend.app.models.group import Group
  903. from backend.app.models.library import LibraryFile
  904. from backend.app.models.user import User
  905. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  906. from backend.app.core.auth import get_password_hash as get_pw_hash
  907. other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
  908. other_user.groups.append(operator_group)
  909. db_session.add(other_user)
  910. await db_session.commit()
  911. await db_session.refresh(other_user)
  912. # Create file owned by other user
  913. other_file = LibraryFile(
  914. filename="other.txt",
  915. file_path="data/archive/library/files/other.txt",
  916. file_type="txt",
  917. file_size=100,
  918. created_by_id=other_user.id,
  919. )
  920. db_session.add(other_file)
  921. await db_session.commit()
  922. await db_session.refresh(other_file)
  923. # Original operator should not be able to delete it
  924. operator_token = auth_setup["operator_token"]
  925. response = await async_client.delete(
  926. f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  927. )
  928. assert response.status_code == 403
  929. assert "your own files" in response.json()["detail"].lower()
  930. @pytest.mark.asyncio
  931. @pytest.mark.integration
  932. async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
  933. """Verify admin can delete any file."""
  934. from pathlib import Path
  935. from backend.app.core.config import settings as app_settings
  936. from backend.app.models.library import LibraryFile
  937. # Create file owned by operator
  938. operator_user = auth_setup["operator_user"]
  939. lib_file = LibraryFile(
  940. filename="admin_can_delete.txt",
  941. file_path="data/archive/library/files/admin_can_delete.txt",
  942. file_type="txt",
  943. file_size=100,
  944. created_by_id=operator_user.id,
  945. )
  946. db_session.add(lib_file)
  947. await db_session.commit()
  948. await db_session.refresh(lib_file)
  949. # Create actual file on disk
  950. file_path = Path(app_settings.base_dir) / lib_file.file_path
  951. file_path.parent.mkdir(parents=True, exist_ok=True)
  952. file_path.write_text("test content")
  953. # Admin should be able to delete it
  954. admin_token = auth_setup["admin_token"]
  955. response = await async_client.delete(
  956. f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
  957. )
  958. assert response.status_code == 200
  959. @pytest.mark.asyncio
  960. @pytest.mark.integration
  961. async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  962. """Verify viewer cannot delete any files."""
  963. viewer_token = auth_setup["viewer_token"]
  964. response = await async_client.delete(
  965. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
  966. )
  967. # Viewers don't have delete_own or delete_all permissions
  968. assert response.status_code == 403
  969. class TestPrintFileUploadValidation:
  970. """#1401: pre-flight rejection of unprintable uploads at the library +
  971. archive routes. Smoke tests the shared ``validate_print_file_upload``
  972. helper through both surfaces a user can reach with a drag-drop."""
  973. def _valid_3mf_bytes(self, name: str = "Metadata/plate_1.gcode") -> bytes:
  974. """Build a minimal-but-real zip with the gcode-3mf magic in it so
  975. the validator's ``startswith(b"PK\\x03\\x04")`` check passes."""
  976. buf = io.BytesIO()
  977. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  978. zf.writestr(name, "; G-code\nG28\n")
  979. return buf.getvalue()
  980. @pytest.mark.asyncio
  981. @pytest.mark.integration
  982. async def test_library_rejects_raw_gcode_upload(self, async_client: AsyncClient, db_session):
  983. """``Foo.gcode`` direct uploads are blocked at the library route —
  984. the dispatcher would otherwise append ``.3mf`` and ship raw gcode
  985. to the printer as a fake 3MF."""
  986. files = {"file": ("plate_1.gcode", b"; raw gcode\nG28\n", "application/octet-stream")}
  987. response = await async_client.post("/api/v1/library/files", files=files)
  988. assert response.status_code == 400
  989. # Error message must name the actual remedy, not just say "invalid".
  990. assert "gcode.3mf" in response.json()["detail"]
  991. @pytest.mark.asyncio
  992. @pytest.mark.integration
  993. async def test_library_rejects_non_zip_3mf_upload(self, async_client: AsyncClient, db_session):
  994. """A ``.3mf`` upload whose body isn't a zip is rejected — covers
  995. raw gcode renamed to .3mf, corrupted downloads, etc."""
  996. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  997. response = await async_client.post("/api/v1/library/files", files=files)
  998. assert response.status_code == 400
  999. assert "ZIP container" in response.json()["detail"]
  1000. @pytest.mark.asyncio
  1001. @pytest.mark.integration
  1002. async def test_library_rejects_non_zip_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  1003. """The compound-extension ``.gcode.3mf`` case is gated by the same
  1004. zip-magic check — splitext returns just ``.3mf``, but the suffix
  1005. match covers both."""
  1006. files = {"file": ("plate_1.gcode.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1007. response = await async_client.post("/api/v1/library/files", files=files)
  1008. assert response.status_code == 400
  1009. assert "ZIP container" in response.json()["detail"]
  1010. @pytest.mark.asyncio
  1011. @pytest.mark.integration
  1012. async def test_library_accepts_valid_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  1013. """A real ``.gcode.3mf`` zip uploads successfully — the existing
  1014. happy path is not regressed by the new validation."""
  1015. files = {
  1016. "file": (
  1017. "plate_1.gcode.3mf",
  1018. self._valid_3mf_bytes(),
  1019. "application/zip",
  1020. )
  1021. }
  1022. response = await async_client.post("/api/v1/library/files", files=files)
  1023. assert response.status_code == 200
  1024. result = response.json()
  1025. assert result["filename"] == "plate_1.gcode.3mf"
  1026. @pytest.mark.asyncio
  1027. @pytest.mark.integration
  1028. async def test_library_upload_classifies_gcode_3mf_as_compound(self, async_client: AsyncClient, db_session):
  1029. """#1600 follow-up: upload path used to strip to the trailing
  1030. extension and store ``file_type='3mf'`` for sliced outputs, while
  1031. the external-folder scan stored ``file_type='gcode.3mf'``. Now
  1032. every ingest path goes through ``classify_file_type`` and
  1033. produces the canonical compound name."""
  1034. files = {
  1035. "file": (
  1036. "sliced.gcode.3mf",
  1037. self._valid_3mf_bytes(),
  1038. "application/zip",
  1039. )
  1040. }
  1041. response = await async_client.post("/api/v1/library/files", files=files)
  1042. assert response.status_code == 200
  1043. assert response.json()["file_type"] == "gcode.3mf"
  1044. @pytest.mark.asyncio
  1045. @pytest.mark.integration
  1046. async def test_library_get_gcode_endpoint_accepts_compound_file_type(self, async_client: AsyncClient, db_session):
  1047. """#1600 follow-up: pre-fix, ``GET /files/{id}/gcode`` only handled
  1048. ``file_type`` of ``gcode`` or ``3mf`` and 400'd on a row whose
  1049. ``file_type`` was ``gcode.3mf`` — exactly the rows the external-
  1050. folder scan was creating. The gate now treats both as 3MF and
  1051. unzips the embedded gcode the same way."""
  1052. from backend.app.models.library import LibraryFile
  1053. # Persist a real `.gcode.3mf` zip under file_type='gcode.3mf' so
  1054. # the endpoint hits the new branch.
  1055. with tempfile.NamedTemporaryFile(suffix=".gcode.3mf", delete=False) as tmp:
  1056. tmp.write(self._valid_3mf_bytes(name="Metadata/plate_1.gcode"))
  1057. tmp_path = tmp.name
  1058. lib_file = LibraryFile(
  1059. filename="sliced.gcode.3mf",
  1060. file_path=tmp_path,
  1061. file_type="gcode.3mf",
  1062. file_size=Path(tmp_path).stat().st_size,
  1063. )
  1064. db_session.add(lib_file)
  1065. await db_session.commit()
  1066. await db_session.refresh(lib_file)
  1067. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}/gcode")
  1068. assert response.status_code == 200
  1069. assert b"G28" in response.content
  1070. @pytest.mark.asyncio
  1071. @pytest.mark.integration
  1072. async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
  1073. """STL / image / other non-print uploads bypass the validator
  1074. entirely — Bambuddy is also a library, not just a print dispatcher."""
  1075. files = {"file": ("model.stl", b"solid test\nendsolid test", "application/octet-stream")}
  1076. response = await async_client.post(
  1077. "/api/v1/library/files", files=files, params={"generate_stl_thumbnails": "false"}
  1078. )
  1079. assert response.status_code == 200
  1080. @pytest.mark.asyncio
  1081. @pytest.mark.integration
  1082. async def test_archive_upload_rejects_non_zip(self, async_client: AsyncClient, db_session):
  1083. """``POST /archives/upload`` shares the same validator — covers the
  1084. manual archive-upload entry point too."""
  1085. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1086. response = await async_client.post("/api/v1/archives/upload", files=files)
  1087. assert response.status_code == 400
  1088. assert "ZIP container" in response.json()["detail"]
  1089. @pytest.mark.asyncio
  1090. @pytest.mark.integration
  1091. async def test_archive_bulk_upload_collects_per_file_errors(self, async_client: AsyncClient, db_session):
  1092. """The bulk-archive route reports validation failures per file and
  1093. continues processing the remaining items — one bad upload in a
  1094. 10-file drag-drop must not abort the whole batch."""
  1095. good = self._valid_3mf_bytes()
  1096. bad = b"; raw gcode\nG28\n"
  1097. # httpx multipart with a list-of-tuples preserves order + same field name.
  1098. files = [
  1099. ("files", ("good.3mf", good, "application/zip")),
  1100. ("files", ("bad.3mf", bad, "application/octet-stream")),
  1101. ]
  1102. response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
  1103. assert response.status_code == 200
  1104. body = response.json()
  1105. # The bulk route's archive_print may still reject the "good" file
  1106. # downstream (no printer match, etc.) — we don't care about that
  1107. # here; what matters is the bad file lands in `errors` with the
  1108. # validator's message and the route didn't 500.
  1109. assert body["failed"] >= 1
  1110. bad_errors = [e for e in body["errors"] if e["filename"] == "bad.3mf"]
  1111. assert bad_errors, body
  1112. assert "ZIP container" in bad_errors[0]["error"]