test_library_api.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224
  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_get_file(self, async_client: AsyncClient, file_factory, db_session):
  202. """Verify single file can be retrieved."""
  203. lib_file = await file_factory(filename="test.3mf")
  204. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  205. assert response.status_code == 200
  206. result = response.json()
  207. assert result["id"] == lib_file.id
  208. assert result["filename"] == "test.3mf"
  209. @pytest.mark.asyncio
  210. @pytest.mark.integration
  211. async def test_get_file_not_found(self, async_client: AsyncClient, db_session):
  212. """Verify 404 for non-existent file."""
  213. response = await async_client.get("/api/v1/library/files/9999")
  214. assert response.status_code == 404
  215. @pytest.mark.asyncio
  216. @pytest.mark.integration
  217. async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session):
  218. """Verify file can be deleted."""
  219. lib_file = await file_factory()
  220. response = await async_client.delete(f"/api/v1/library/files/{lib_file.id}")
  221. assert response.status_code == 200
  222. result = response.json()
  223. assert result.get("message") or result.get("success", True)
  224. @pytest.mark.asyncio
  225. @pytest.mark.integration
  226. async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
  227. """Verify file can be renamed."""
  228. lib_file = await file_factory(filename="old_name.3mf")
  229. data = {"filename": "new_name.3mf"}
  230. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  231. assert response.status_code == 200
  232. result = response.json()
  233. assert result["filename"] == "new_name.3mf"
  234. @pytest.mark.asyncio
  235. @pytest.mark.integration
  236. async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
  237. """Verify file rename fails with path separators."""
  238. lib_file = await file_factory(filename="test.3mf")
  239. data = {"filename": "path/to/file.3mf"}
  240. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  241. assert response.status_code == 400
  242. assert "path separator" in response.json()["detail"].lower()
  243. @pytest.mark.asyncio
  244. @pytest.mark.integration
  245. async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
  246. """Verify file rename fails with backslash."""
  247. lib_file = await file_factory(filename="test.3mf")
  248. data = {"filename": "path\\to\\file.3mf"}
  249. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  250. assert response.status_code == 400
  251. assert "path separator" in response.json()["detail"].lower()
  252. @pytest.mark.asyncio
  253. @pytest.mark.integration
  254. async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  255. """Verify library stats endpoint returns counts."""
  256. await folder_factory()
  257. await folder_factory()
  258. await file_factory()
  259. response = await async_client.get("/api/v1/library/stats")
  260. assert response.status_code == 200
  261. result = response.json()
  262. assert result["total_folders"] == 2
  263. assert result["total_files"] == 1
  264. @pytest.mark.asyncio
  265. @pytest.mark.integration
  266. async def test_file_list_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  267. """Verify file list response includes user tracking fields (Issue #206)."""
  268. lib_file = await file_factory(filename="test.3mf")
  269. response = await async_client.get("/api/v1/library/files?include_root=false")
  270. assert response.status_code == 200
  271. result = response.json()
  272. assert len(result) >= 1
  273. # Find our test file
  274. test_file = next((f for f in result if f["id"] == lib_file.id), None)
  275. assert test_file is not None
  276. # User tracking fields should be present (even if null)
  277. assert "created_by_id" in test_file
  278. assert "created_by_username" in test_file
  279. @pytest.mark.asyncio
  280. @pytest.mark.integration
  281. async def test_file_detail_includes_user_tracking_fields(self, async_client: AsyncClient, file_factory, db_session):
  282. """Verify file detail response includes user tracking fields (Issue #206)."""
  283. lib_file = await file_factory(filename="test_detail.3mf")
  284. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  285. assert response.status_code == 200
  286. result = response.json()
  287. # User tracking fields should be present (even if null)
  288. assert "created_by_id" in result
  289. assert "created_by_username" in result
  290. @pytest.mark.asyncio
  291. @pytest.mark.integration
  292. async def test_file_with_user_tracking(self, async_client: AsyncClient, db_session):
  293. """Verify file created with user shows username in response (Issue #206)."""
  294. from backend.app.models.library import LibraryFile
  295. from backend.app.models.user import User
  296. # Create a test user
  297. user = User(username="testuploader", password_hash="fakehash", role="user")
  298. db_session.add(user)
  299. await db_session.flush()
  300. # Create a file with created_by_id set
  301. lib_file = LibraryFile(
  302. filename="user_uploaded.3mf",
  303. file_path="/test/user_uploaded.3mf",
  304. file_size=2048,
  305. file_type="3mf",
  306. created_by_id=user.id,
  307. )
  308. db_session.add(lib_file)
  309. await db_session.commit()
  310. await db_session.refresh(lib_file)
  311. # Verify file detail shows username
  312. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  313. assert response.status_code == 200
  314. result = response.json()
  315. assert result["created_by_id"] == user.id
  316. assert result["created_by_username"] == "testuploader"
  317. # Verify file list also shows username
  318. response = await async_client.get("/api/v1/library/files?include_root=false")
  319. assert response.status_code == 200
  320. files = response.json()
  321. test_file = next((f for f in files if f["id"] == lib_file.id), None)
  322. assert test_file is not None
  323. assert test_file["created_by_id"] == user.id
  324. assert test_file["created_by_username"] == "testuploader"
  325. class TestLibraryAddToQueueAPI:
  326. """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
  327. @pytest.fixture
  328. async def printer_factory(self, db_session):
  329. """Factory to create test printers."""
  330. _counter = [0]
  331. async def _create_printer(**kwargs):
  332. from backend.app.models.printer import Printer
  333. _counter[0] += 1
  334. counter = _counter[0]
  335. defaults = {
  336. "name": f"Test Printer {counter}",
  337. "ip_address": f"192.168.1.{100 + counter}",
  338. "serial_number": f"TESTSERIAL{counter:04d}",
  339. "access_code": "12345678",
  340. "model": "X1C",
  341. }
  342. defaults.update(kwargs)
  343. printer = Printer(**defaults)
  344. db_session.add(printer)
  345. await db_session.commit()
  346. await db_session.refresh(printer)
  347. return printer
  348. return _create_printer
  349. @pytest.fixture
  350. async def library_file_factory(self, db_session):
  351. """Factory to create test library files."""
  352. _counter = [0]
  353. async def _create_library_file(**kwargs):
  354. from backend.app.models.library import LibraryFile
  355. _counter[0] += 1
  356. counter = _counter[0]
  357. defaults = {
  358. "filename": f"test_file_{counter}.gcode.3mf",
  359. "file_path": f"/test/path/test_file_{counter}.gcode.3mf",
  360. "file_size": 1024,
  361. "file_type": "3mf",
  362. }
  363. defaults.update(kwargs)
  364. lib_file = LibraryFile(**defaults)
  365. db_session.add(lib_file)
  366. await db_session.commit()
  367. await db_session.refresh(lib_file)
  368. return lib_file
  369. return _create_library_file
  370. @pytest.mark.asyncio
  371. @pytest.mark.integration
  372. async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
  373. """Verify error for non-existent file."""
  374. await printer_factory()
  375. data = {"file_ids": [9999]}
  376. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  377. assert response.status_code == 200
  378. result = response.json()
  379. assert len(result["added"]) == 0
  380. assert len(result["errors"]) == 1
  381. assert result["errors"][0]["file_id"] == 9999
  382. @pytest.mark.asyncio
  383. @pytest.mark.integration
  384. async def test_add_non_sliced_file_to_queue_fails(
  385. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  386. ):
  387. """Verify non-sliced file cannot be added to queue."""
  388. await printer_factory()
  389. lib_file = await library_file_factory(
  390. filename="model.stl",
  391. file_path="/test/path/model.stl",
  392. file_type="stl",
  393. )
  394. data = {"file_ids": [lib_file.id]}
  395. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  396. assert response.status_code == 200
  397. result = response.json()
  398. assert len(result["added"]) == 0
  399. assert len(result["errors"]) == 1
  400. assert "sliced" in result["errors"][0]["error"].lower()
  401. class TestLibraryZipExtractAPI:
  402. """Integration tests for ZIP extraction endpoint."""
  403. @pytest.mark.asyncio
  404. @pytest.mark.integration
  405. async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
  406. """Verify non-ZIP files are rejected."""
  407. # Create a fake file that's not a ZIP
  408. files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
  409. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  410. assert response.status_code == 400
  411. assert "ZIP" in response.json()["detail"]
  412. @pytest.mark.asyncio
  413. @pytest.mark.integration
  414. async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
  415. """Verify basic ZIP extraction works."""
  416. import io
  417. # Create a simple ZIP file in memory
  418. zip_buffer = io.BytesIO()
  419. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  420. zf.writestr("test1.txt", "Content of file 1")
  421. zf.writestr("test2.txt", "Content of file 2")
  422. zip_buffer.seek(0)
  423. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  424. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  425. assert response.status_code == 200
  426. result = response.json()
  427. assert result["extracted"] == 2
  428. assert len(result["files"]) == 2
  429. assert len(result["errors"]) == 0
  430. @pytest.mark.asyncio
  431. @pytest.mark.integration
  432. async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
  433. """Verify ZIP extraction preserves folder structure."""
  434. import io
  435. # Create a ZIP file with folder structure
  436. zip_buffer = io.BytesIO()
  437. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  438. zf.writestr("folder1/file1.txt", "Content 1")
  439. zf.writestr("folder1/subfolder/file2.txt", "Content 2")
  440. zf.writestr("folder2/file3.txt", "Content 3")
  441. zip_buffer.seek(0)
  442. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  443. params = {"preserve_structure": "true"}
  444. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  445. assert response.status_code == 200
  446. result = response.json()
  447. assert result["extracted"] == 3
  448. assert result["folders_created"] >= 3 # folder1, folder1/subfolder, folder2
  449. @pytest.mark.asyncio
  450. @pytest.mark.integration
  451. async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
  452. """Verify ZIP extraction can extract flat (no folders)."""
  453. import io
  454. # Create a ZIP file with folder structure
  455. zip_buffer = io.BytesIO()
  456. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  457. zf.writestr("folder/file1.txt", "Content 1")
  458. zf.writestr("folder/file2.txt", "Content 2")
  459. zip_buffer.seek(0)
  460. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  461. params = {"preserve_structure": "false"}
  462. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  463. assert response.status_code == 200
  464. result = response.json()
  465. assert result["extracted"] == 2
  466. assert result["folders_created"] == 0 # No folders created when flat
  467. @pytest.mark.asyncio
  468. @pytest.mark.integration
  469. async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
  470. """Verify ZIP extraction skips __MACOSX and hidden files."""
  471. import io
  472. # Create a ZIP file with macOS junk files
  473. zip_buffer = io.BytesIO()
  474. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  475. zf.writestr("real_file.txt", "Real content")
  476. zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
  477. zf.writestr(".hidden_file", "Hidden content")
  478. zip_buffer.seek(0)
  479. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  480. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  481. assert response.status_code == 200
  482. result = response.json()
  483. assert result["extracted"] == 1 # Only real_file.txt
  484. assert result["files"][0]["filename"] == "real_file.txt"
  485. @pytest.mark.asyncio
  486. @pytest.mark.integration
  487. async def test_extract_zip_create_folder_from_zip(self, async_client: AsyncClient, db_session):
  488. """Verify ZIP extraction creates a folder from the ZIP filename."""
  489. import io
  490. # Create a ZIP file with some files
  491. zip_buffer = io.BytesIO()
  492. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  493. zf.writestr("file1.txt", "Content 1")
  494. zf.writestr("file2.txt", "Content 2")
  495. zip_buffer.seek(0)
  496. files = {"file": ("MyProject.zip", zip_buffer.read(), "application/zip")}
  497. params = {"create_folder_from_zip": "true", "preserve_structure": "false"}
  498. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  499. assert response.status_code == 200
  500. result = response.json()
  501. assert result["extracted"] == 2
  502. assert result["folders_created"] == 1 # MyProject folder created
  503. # Verify the files are in a folder
  504. assert result["files"][0]["folder_id"] is not None
  505. assert result["files"][1]["folder_id"] is not None
  506. # Both files should be in the same folder
  507. assert result["files"][0]["folder_id"] == result["files"][1]["folder_id"]
  508. # Verify the folder was created with the right name
  509. folder_response = await async_client.get(f"/api/v1/library/folders/{result['files'][0]['folder_id']}")
  510. assert folder_response.status_code == 200
  511. folder = folder_response.json()
  512. assert folder["name"] == "MyProject"
  513. class TestLibraryStlThumbnailAPI:
  514. """Integration tests for STL thumbnail generation endpoints."""
  515. @pytest.fixture
  516. async def file_factory(self, db_session):
  517. """Factory to create test files."""
  518. _counter = [0]
  519. async def _create_file(**kwargs):
  520. from backend.app.models.library import LibraryFile
  521. _counter[0] += 1
  522. counter = _counter[0]
  523. defaults = {
  524. "filename": f"test_model_{counter}.stl",
  525. "file_path": f"/test/path/test_model_{counter}.stl",
  526. "file_size": 1024,
  527. "file_type": "stl",
  528. }
  529. defaults.update(kwargs)
  530. lib_file = LibraryFile(**defaults)
  531. db_session.add(lib_file)
  532. await db_session.commit()
  533. await db_session.refresh(lib_file)
  534. return lib_file
  535. return _create_file
  536. @pytest.mark.asyncio
  537. @pytest.mark.integration
  538. async def test_batch_generate_thumbnails_empty(self, async_client: AsyncClient, db_session):
  539. """Verify batch thumbnail generation with no files."""
  540. data = {"all_missing": True}
  541. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  542. assert response.status_code == 200
  543. result = response.json()
  544. assert result["processed"] == 0
  545. assert result["succeeded"] == 0
  546. assert result["failed"] == 0
  547. assert result["results"] == []
  548. @pytest.mark.asyncio
  549. @pytest.mark.integration
  550. async def test_batch_generate_thumbnails_no_criteria(self, async_client: AsyncClient, db_session):
  551. """Verify batch thumbnail generation with no criteria returns empty."""
  552. data = {}
  553. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  554. assert response.status_code == 200
  555. result = response.json()
  556. assert result["processed"] == 0
  557. @pytest.mark.asyncio
  558. @pytest.mark.integration
  559. async def test_batch_generate_thumbnails_file_not_on_disk(
  560. self, async_client: AsyncClient, file_factory, db_session
  561. ):
  562. """Verify batch thumbnail generation handles missing files gracefully."""
  563. # Create a file in DB but not on disk
  564. stl_file = await file_factory(
  565. filename="missing.stl",
  566. file_path="/nonexistent/path/missing.stl",
  567. thumbnail_path=None,
  568. )
  569. data = {"file_ids": [stl_file.id]}
  570. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  571. assert response.status_code == 200
  572. result = response.json()
  573. assert result["processed"] == 1
  574. assert result["succeeded"] == 0
  575. assert result["failed"] == 1
  576. assert result["results"][0]["success"] is False
  577. assert "not found" in result["results"][0]["error"].lower()
  578. @pytest.mark.asyncio
  579. @pytest.mark.integration
  580. async def test_batch_generate_thumbnails_with_real_stl(self, async_client: AsyncClient, db_session):
  581. """Verify batch thumbnail generation with a real STL file."""
  582. from backend.app.models.library import LibraryFile
  583. # Create a simple ASCII STL cube
  584. stl_content = """solid cube
  585. facet normal 0 0 -1
  586. outer loop
  587. vertex 0 0 0
  588. vertex 1 0 0
  589. vertex 1 1 0
  590. endloop
  591. endfacet
  592. facet normal 0 0 1
  593. outer loop
  594. vertex 0 0 1
  595. vertex 1 1 1
  596. vertex 1 0 1
  597. endloop
  598. endfacet
  599. endsolid cube"""
  600. with tempfile.NamedTemporaryFile(suffix=".stl", delete=False, mode="w") as f:
  601. f.write(stl_content)
  602. stl_path = f.name
  603. try:
  604. # Create file in DB pointing to real STL
  605. lib_file = LibraryFile(
  606. filename="test_cube.stl",
  607. file_path=stl_path,
  608. file_size=len(stl_content),
  609. file_type="stl",
  610. thumbnail_path=None,
  611. )
  612. db_session.add(lib_file)
  613. await db_session.commit()
  614. await db_session.refresh(lib_file)
  615. data = {"file_ids": [lib_file.id]}
  616. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  617. assert response.status_code == 200
  618. result = response.json()
  619. assert result["processed"] == 1
  620. # Result depends on whether trimesh/matplotlib are installed
  621. # Either succeeds or fails gracefully
  622. assert result["succeeded"] + result["failed"] == 1
  623. finally:
  624. import os
  625. if os.path.exists(stl_path):
  626. os.unlink(stl_path)
  627. @pytest.mark.asyncio
  628. @pytest.mark.integration
  629. async def test_upload_file_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  630. """Verify file upload accepts generate_stl_thumbnails parameter."""
  631. # Create a simple STL file
  632. stl_content = b"solid test\nendsolid test"
  633. files = {"file": ("test.stl", stl_content, "application/octet-stream")}
  634. params = {"generate_stl_thumbnails": "false"}
  635. response = await async_client.post("/api/v1/library/files", files=files, params=params)
  636. assert response.status_code == 200
  637. result = response.json()
  638. assert result["filename"] == "test.stl"
  639. assert result["file_type"] == "stl"
  640. # No thumbnail should be generated when disabled
  641. assert result["thumbnail_path"] is None
  642. @pytest.mark.asyncio
  643. @pytest.mark.integration
  644. async def test_extract_zip_with_stl_thumbnail_param(self, async_client: AsyncClient, db_session):
  645. """Verify ZIP extraction accepts generate_stl_thumbnails parameter."""
  646. # Create a ZIP file containing an STL
  647. stl_content = b"solid test\nendsolid test"
  648. zip_buffer = io.BytesIO()
  649. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  650. zf.writestr("model.stl", stl_content)
  651. zip_buffer.seek(0)
  652. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  653. params = {"generate_stl_thumbnails": "false"}
  654. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  655. assert response.status_code == 200
  656. result = response.json()
  657. assert result["extracted"] == 1
  658. assert result["files"][0]["filename"] == "model.stl"
  659. @pytest.mark.asyncio
  660. @pytest.mark.integration
  661. async def test_batch_generate_thumbnails_by_folder(self, async_client: AsyncClient, file_factory, db_session):
  662. """Verify batch thumbnail generation can filter by folder."""
  663. from backend.app.models.library import LibraryFolder
  664. # Create a folder
  665. folder = LibraryFolder(name="STL Folder")
  666. db_session.add(folder)
  667. await db_session.commit()
  668. await db_session.refresh(folder)
  669. # Create STL file in folder (no thumbnail)
  670. stl_in_folder = await file_factory(
  671. filename="in_folder.stl",
  672. folder_id=folder.id,
  673. thumbnail_path=None,
  674. )
  675. # Create STL file at root (no thumbnail)
  676. _stl_at_root = await file_factory(
  677. filename="at_root.stl",
  678. folder_id=None,
  679. thumbnail_path=None,
  680. )
  681. # Request thumbnails only for files in folder
  682. data = {"folder_id": folder.id, "all_missing": True}
  683. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  684. assert response.status_code == 200
  685. result = response.json()
  686. # Should only process the file in the folder
  687. assert result["processed"] == 1
  688. assert result["results"][0]["file_id"] == stl_in_folder.id
  689. @pytest.mark.asyncio
  690. @pytest.mark.integration
  691. async def test_batch_generate_thumbnails_all_missing(self, async_client: AsyncClient, file_factory, db_session):
  692. """Verify batch thumbnail generation finds all STL files missing thumbnails."""
  693. # Create files with and without thumbnails
  694. _stl_with_thumb = await file_factory(
  695. filename="with_thumb.stl",
  696. thumbnail_path="/some/path/thumb.png",
  697. )
  698. stl_without_thumb1 = await file_factory(
  699. filename="without_thumb1.stl",
  700. thumbnail_path=None,
  701. )
  702. stl_without_thumb2 = await file_factory(
  703. filename="without_thumb2.stl",
  704. thumbnail_path=None,
  705. )
  706. data = {"all_missing": True}
  707. response = await async_client.post("/api/v1/library/generate-stl-thumbnails", json=data)
  708. assert response.status_code == 200
  709. result = response.json()
  710. # Should only process files without thumbnails
  711. assert result["processed"] == 2
  712. file_ids = {r["file_id"] for r in result["results"]}
  713. assert stl_without_thumb1.id in file_ids
  714. assert stl_without_thumb2.id in file_ids
  715. class TestLibraryPathHelpers:
  716. """Tests for path handling utilities used for backup portability."""
  717. def test_to_relative_path_converts_absolute(self):
  718. """Verify absolute paths are converted to relative paths."""
  719. from backend.app.api.routes.library import to_relative_path
  720. from backend.app.core.config import settings
  721. base_dir = str(settings.base_dir)
  722. abs_path = f"{base_dir}/archive/library/files/test.3mf"
  723. rel_path = to_relative_path(abs_path)
  724. assert not rel_path.startswith("/")
  725. assert rel_path == "archive/library/files/test.3mf"
  726. def test_to_relative_path_handles_path_object(self):
  727. """Verify Path objects are handled correctly."""
  728. from pathlib import Path
  729. from backend.app.api.routes.library import to_relative_path
  730. from backend.app.core.config import settings
  731. abs_path = Path(settings.base_dir) / "archive" / "test.3mf"
  732. rel_path = to_relative_path(abs_path)
  733. assert not rel_path.startswith("/")
  734. assert rel_path == "archive/test.3mf"
  735. def test_to_relative_path_returns_empty_for_empty_input(self):
  736. """Verify empty input returns empty string."""
  737. from backend.app.api.routes.library import to_relative_path
  738. assert to_relative_path("") == ""
  739. assert to_relative_path(None) == ""
  740. def test_to_absolute_path_converts_relative(self):
  741. """Verify relative paths are converted to absolute paths."""
  742. from backend.app.api.routes.library import to_absolute_path
  743. from backend.app.core.config import settings
  744. rel_path = "archive/library/files/test.3mf"
  745. abs_path = to_absolute_path(rel_path)
  746. assert abs_path is not None
  747. assert abs_path.is_absolute()
  748. assert str(abs_path) == f"{settings.base_dir}/archive/library/files/test.3mf"
  749. def test_to_absolute_path_handles_already_absolute(self):
  750. """Verify already absolute paths are returned as-is (for backwards compatibility)."""
  751. from backend.app.api.routes.library import to_absolute_path
  752. abs_path_str = "/data/archive/test.3mf"
  753. result = to_absolute_path(abs_path_str)
  754. assert result is not None
  755. assert str(result) == abs_path_str
  756. def test_to_absolute_path_returns_none_for_empty(self):
  757. """Verify None/empty input returns None."""
  758. from backend.app.api.routes.library import to_absolute_path
  759. assert to_absolute_path(None) is None
  760. assert to_absolute_path("") is None
  761. class TestLibraryPermissions:
  762. """Tests for library permission enforcement."""
  763. @pytest.fixture
  764. async def auth_setup(self, db_session):
  765. """Set up auth with users of different permission levels."""
  766. from backend.app.core.auth import create_access_token, get_password_hash
  767. from backend.app.models.group import Group
  768. from backend.app.models.settings import Settings
  769. from backend.app.models.user import User
  770. # Enable auth
  771. settings = Settings(key="auth_enabled", value="true")
  772. db_session.add(settings)
  773. await db_session.commit()
  774. # Groups are auto-seeded during db init, but we need to commit them
  775. await db_session.commit()
  776. # Get groups
  777. from sqlalchemy import select
  778. admin_group = (await db_session.execute(select(Group).where(Group.name == "Administrators"))).scalar_one()
  779. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  780. viewer_group = (await db_session.execute(select(Group).where(Group.name == "Viewers"))).scalar_one()
  781. password_hash = get_password_hash("password")
  782. # Create users
  783. admin_user = User(username="admin_lib", password_hash=password_hash, role="admin", is_active=True)
  784. admin_user.groups.append(admin_group)
  785. operator_user = User(username="operator_lib", password_hash=password_hash, is_active=True)
  786. operator_user.groups.append(operator_group)
  787. viewer_user = User(username="viewer_lib", password_hash=password_hash, is_active=True)
  788. viewer_user.groups.append(viewer_group)
  789. db_session.add_all([admin_user, operator_user, viewer_user])
  790. await db_session.commit()
  791. # Create tokens
  792. admin_token = create_access_token(data={"sub": admin_user.username})
  793. operator_token = create_access_token(data={"sub": operator_user.username})
  794. viewer_token = create_access_token(data={"sub": viewer_user.username})
  795. return {
  796. "admin_user": admin_user,
  797. "operator_user": operator_user,
  798. "viewer_user": viewer_user,
  799. "admin_token": admin_token,
  800. "operator_token": operator_token,
  801. "viewer_token": viewer_token,
  802. }
  803. @pytest.fixture
  804. async def test_file(self, db_session, auth_setup):
  805. """Create a test file owned by the operator user."""
  806. from backend.app.models.library import LibraryFile
  807. operator_user = auth_setup["operator_user"]
  808. lib_file = LibraryFile(
  809. filename="test.txt",
  810. file_path="data/archive/library/files/test.txt",
  811. file_type="txt",
  812. file_size=100,
  813. created_by_id=operator_user.id,
  814. )
  815. db_session.add(lib_file)
  816. await db_session.commit()
  817. await db_session.refresh(lib_file)
  818. return lib_file
  819. @pytest.mark.asyncio
  820. @pytest.mark.integration
  821. async def test_list_files_requires_library_read(self, async_client: AsyncClient, db_session, auth_setup):
  822. """Verify list_files requires library:read permission."""
  823. viewer_token = auth_setup["viewer_token"]
  824. # Viewers have library:read, should succeed
  825. response = await async_client.get("/api/v1/library/files", headers={"Authorization": f"Bearer {viewer_token}"})
  826. assert response.status_code == 200
  827. @pytest.mark.asyncio
  828. @pytest.mark.integration
  829. async def test_list_files_denied_without_permission(self, async_client: AsyncClient, db_session):
  830. """Verify list_files denied without auth when auth is enabled."""
  831. from backend.app.models.settings import Settings
  832. # Enable auth
  833. settings = Settings(key="auth_enabled", value="true")
  834. db_session.add(settings)
  835. await db_session.commit()
  836. # Request without token should fail
  837. response = await async_client.get("/api/v1/library/files")
  838. assert response.status_code == 401
  839. @pytest.mark.asyncio
  840. @pytest.mark.integration
  841. async def test_delete_file_own_by_owner(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  842. """Verify operator can delete their own files."""
  843. from pathlib import Path
  844. # Create actual file on disk so delete doesn't fail
  845. from backend.app.core.config import settings as app_settings
  846. file_path = Path(app_settings.base_dir) / test_file.file_path
  847. file_path.parent.mkdir(parents=True, exist_ok=True)
  848. file_path.write_text("test content")
  849. operator_token = auth_setup["operator_token"]
  850. response = await async_client.delete(
  851. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  852. )
  853. assert response.status_code == 200
  854. @pytest.mark.asyncio
  855. @pytest.mark.integration
  856. async def test_delete_file_own_denied_for_others_file(self, async_client: AsyncClient, db_session, auth_setup):
  857. """Verify operator cannot delete files owned by others."""
  858. # Create another operator user with a file
  859. from sqlalchemy import select
  860. from backend.app.core.auth import create_access_token
  861. from backend.app.models.group import Group
  862. from backend.app.models.library import LibraryFile
  863. from backend.app.models.user import User
  864. operator_group = (await db_session.execute(select(Group).where(Group.name == "Operators"))).scalar_one()
  865. from backend.app.core.auth import get_password_hash as get_pw_hash
  866. other_user = User(username="other_op", password_hash=get_pw_hash("password"), is_active=True)
  867. other_user.groups.append(operator_group)
  868. db_session.add(other_user)
  869. await db_session.commit()
  870. await db_session.refresh(other_user)
  871. # Create file owned by other user
  872. other_file = LibraryFile(
  873. filename="other.txt",
  874. file_path="data/archive/library/files/other.txt",
  875. file_type="txt",
  876. file_size=100,
  877. created_by_id=other_user.id,
  878. )
  879. db_session.add(other_file)
  880. await db_session.commit()
  881. await db_session.refresh(other_file)
  882. # Original operator should not be able to delete it
  883. operator_token = auth_setup["operator_token"]
  884. response = await async_client.delete(
  885. f"/api/v1/library/files/{other_file.id}", headers={"Authorization": f"Bearer {operator_token}"}
  886. )
  887. assert response.status_code == 403
  888. assert "your own files" in response.json()["detail"].lower()
  889. @pytest.mark.asyncio
  890. @pytest.mark.integration
  891. async def test_delete_file_admin_can_delete_any(self, async_client: AsyncClient, db_session, auth_setup):
  892. """Verify admin can delete any file."""
  893. from pathlib import Path
  894. from backend.app.core.config import settings as app_settings
  895. from backend.app.models.library import LibraryFile
  896. # Create file owned by operator
  897. operator_user = auth_setup["operator_user"]
  898. lib_file = LibraryFile(
  899. filename="admin_can_delete.txt",
  900. file_path="data/archive/library/files/admin_can_delete.txt",
  901. file_type="txt",
  902. file_size=100,
  903. created_by_id=operator_user.id,
  904. )
  905. db_session.add(lib_file)
  906. await db_session.commit()
  907. await db_session.refresh(lib_file)
  908. # Create actual file on disk
  909. file_path = Path(app_settings.base_dir) / lib_file.file_path
  910. file_path.parent.mkdir(parents=True, exist_ok=True)
  911. file_path.write_text("test content")
  912. # Admin should be able to delete it
  913. admin_token = auth_setup["admin_token"]
  914. response = await async_client.delete(
  915. f"/api/v1/library/files/{lib_file.id}", headers={"Authorization": f"Bearer {admin_token}"}
  916. )
  917. assert response.status_code == 200
  918. @pytest.mark.asyncio
  919. @pytest.mark.integration
  920. async def test_viewer_cannot_delete_files(self, async_client: AsyncClient, db_session, auth_setup, test_file):
  921. """Verify viewer cannot delete any files."""
  922. viewer_token = auth_setup["viewer_token"]
  923. response = await async_client.delete(
  924. f"/api/v1/library/files/{test_file.id}", headers={"Authorization": f"Bearer {viewer_token}"}
  925. )
  926. # Viewers don't have delete_own or delete_all permissions
  927. assert response.status_code == 403
  928. class TestPrintFileUploadValidation:
  929. """#1401: pre-flight rejection of unprintable uploads at the library +
  930. archive routes. Smoke tests the shared ``validate_print_file_upload``
  931. helper through both surfaces a user can reach with a drag-drop."""
  932. def _valid_3mf_bytes(self, name: str = "Metadata/plate_1.gcode") -> bytes:
  933. """Build a minimal-but-real zip with the gcode-3mf magic in it so
  934. the validator's ``startswith(b"PK\\x03\\x04")`` check passes."""
  935. buf = io.BytesIO()
  936. with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
  937. zf.writestr(name, "; G-code\nG28\n")
  938. return buf.getvalue()
  939. @pytest.mark.asyncio
  940. @pytest.mark.integration
  941. async def test_library_rejects_raw_gcode_upload(self, async_client: AsyncClient, db_session):
  942. """``Foo.gcode`` direct uploads are blocked at the library route —
  943. the dispatcher would otherwise append ``.3mf`` and ship raw gcode
  944. to the printer as a fake 3MF."""
  945. files = {"file": ("plate_1.gcode", b"; raw gcode\nG28\n", "application/octet-stream")}
  946. response = await async_client.post("/api/v1/library/files", files=files)
  947. assert response.status_code == 400
  948. # Error message must name the actual remedy, not just say "invalid".
  949. assert "gcode.3mf" in response.json()["detail"]
  950. @pytest.mark.asyncio
  951. @pytest.mark.integration
  952. async def test_library_rejects_non_zip_3mf_upload(self, async_client: AsyncClient, db_session):
  953. """A ``.3mf`` upload whose body isn't a zip is rejected — covers
  954. raw gcode renamed to .3mf, corrupted downloads, etc."""
  955. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  956. response = await async_client.post("/api/v1/library/files", files=files)
  957. assert response.status_code == 400
  958. assert "ZIP container" in response.json()["detail"]
  959. @pytest.mark.asyncio
  960. @pytest.mark.integration
  961. async def test_library_rejects_non_zip_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  962. """The compound-extension ``.gcode.3mf`` case is gated by the same
  963. zip-magic check — splitext returns just ``.3mf``, but the suffix
  964. match covers both."""
  965. files = {"file": ("plate_1.gcode.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  966. response = await async_client.post("/api/v1/library/files", files=files)
  967. assert response.status_code == 400
  968. assert "ZIP container" in response.json()["detail"]
  969. @pytest.mark.asyncio
  970. @pytest.mark.integration
  971. async def test_library_accepts_valid_gcode_3mf_upload(self, async_client: AsyncClient, db_session):
  972. """A real ``.gcode.3mf`` zip uploads successfully — the existing
  973. happy path is not regressed by the new validation."""
  974. files = {
  975. "file": (
  976. "plate_1.gcode.3mf",
  977. self._valid_3mf_bytes(),
  978. "application/zip",
  979. )
  980. }
  981. response = await async_client.post("/api/v1/library/files", files=files)
  982. assert response.status_code == 200
  983. result = response.json()
  984. assert result["filename"] == "plate_1.gcode.3mf"
  985. @pytest.mark.asyncio
  986. @pytest.mark.integration
  987. async def test_library_still_accepts_non_print_extensions(self, async_client: AsyncClient, db_session):
  988. """STL / image / other non-print uploads bypass the validator
  989. entirely — Bambuddy is also a library, not just a print dispatcher."""
  990. files = {"file": ("model.stl", b"solid test\nendsolid test", "application/octet-stream")}
  991. response = await async_client.post(
  992. "/api/v1/library/files", files=files, params={"generate_stl_thumbnails": "false"}
  993. )
  994. assert response.status_code == 200
  995. @pytest.mark.asyncio
  996. @pytest.mark.integration
  997. async def test_archive_upload_rejects_non_zip(self, async_client: AsyncClient, db_session):
  998. """``POST /archives/upload`` shares the same validator — covers the
  999. manual archive-upload entry point too."""
  1000. files = {"file": ("model.3mf", b"; raw gcode\nG28\n", "application/octet-stream")}
  1001. response = await async_client.post("/api/v1/archives/upload", files=files)
  1002. assert response.status_code == 400
  1003. assert "ZIP container" in response.json()["detail"]
  1004. @pytest.mark.asyncio
  1005. @pytest.mark.integration
  1006. async def test_archive_bulk_upload_collects_per_file_errors(self, async_client: AsyncClient, db_session):
  1007. """The bulk-archive route reports validation failures per file and
  1008. continues processing the remaining items — one bad upload in a
  1009. 10-file drag-drop must not abort the whole batch."""
  1010. good = self._valid_3mf_bytes()
  1011. bad = b"; raw gcode\nG28\n"
  1012. # httpx multipart with a list-of-tuples preserves order + same field name.
  1013. files = [
  1014. ("files", ("good.3mf", good, "application/zip")),
  1015. ("files", ("bad.3mf", bad, "application/octet-stream")),
  1016. ]
  1017. response = await async_client.post("/api/v1/archives/upload-bulk", files=files)
  1018. assert response.status_code == 200
  1019. body = response.json()
  1020. # The bulk route's archive_print may still reject the "good" file
  1021. # downstream (no printer match, etc.) — we don't care about that
  1022. # here; what matters is the bad file lands in `errors` with the
  1023. # validator's message and the route didn't 500.
  1024. assert body["failed"] >= 1
  1025. bad_errors = [e for e in body["errors"] if e["filename"] == "bad.3mf"]
  1026. assert bad_errors, body
  1027. assert "ZIP container" in bad_errors[0]["error"]