test_library_api.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. """Integration tests for Library API endpoints."""
  2. import pytest
  3. from httpx import AsyncClient
  4. class TestLibraryFoldersAPI:
  5. """Integration tests for library folders endpoints."""
  6. @pytest.fixture
  7. async def folder_factory(self, db_session):
  8. """Factory to create test folders."""
  9. _counter = [0]
  10. async def _create_folder(**kwargs):
  11. from backend.app.models.library import LibraryFolder
  12. _counter[0] += 1
  13. counter = _counter[0]
  14. defaults = {
  15. "name": f"Test Folder {counter}",
  16. }
  17. defaults.update(kwargs)
  18. folder = LibraryFolder(**defaults)
  19. db_session.add(folder)
  20. await db_session.commit()
  21. await db_session.refresh(folder)
  22. return folder
  23. return _create_folder
  24. @pytest.mark.asyncio
  25. @pytest.mark.integration
  26. async def test_list_folders_empty(self, async_client: AsyncClient, db_session):
  27. """Verify empty folder list returns empty array."""
  28. response = await async_client.get("/api/v1/library/folders")
  29. assert response.status_code == 200
  30. assert response.json() == []
  31. @pytest.mark.asyncio
  32. @pytest.mark.integration
  33. async def test_create_folder(self, async_client: AsyncClient, db_session):
  34. """Verify folder can be created."""
  35. data = {"name": "New Folder"}
  36. response = await async_client.post("/api/v1/library/folders", json=data)
  37. assert response.status_code == 200
  38. result = response.json()
  39. assert result["name"] == "New Folder"
  40. assert result["id"] is not None
  41. @pytest.mark.asyncio
  42. @pytest.mark.integration
  43. async def test_create_nested_folder(self, async_client: AsyncClient, folder_factory, db_session):
  44. """Verify nested folder can be created."""
  45. parent = await folder_factory(name="Parent")
  46. data = {"name": "Child", "parent_id": parent.id}
  47. response = await async_client.post("/api/v1/library/folders", json=data)
  48. assert response.status_code == 200
  49. result = response.json()
  50. assert result["name"] == "Child"
  51. assert result["parent_id"] == parent.id
  52. @pytest.mark.asyncio
  53. @pytest.mark.integration
  54. async def test_get_folder(self, async_client: AsyncClient, folder_factory, db_session):
  55. """Verify single folder can be retrieved."""
  56. folder = await folder_factory(name="Test Folder")
  57. response = await async_client.get(f"/api/v1/library/folders/{folder.id}")
  58. assert response.status_code == 200
  59. result = response.json()
  60. assert result["id"] == folder.id
  61. assert result["name"] == "Test Folder"
  62. @pytest.mark.asyncio
  63. @pytest.mark.integration
  64. async def test_get_folder_not_found(self, async_client: AsyncClient, db_session):
  65. """Verify 404 for non-existent folder."""
  66. response = await async_client.get("/api/v1/library/folders/9999")
  67. assert response.status_code == 404
  68. @pytest.mark.asyncio
  69. @pytest.mark.integration
  70. async def test_update_folder(self, async_client: AsyncClient, folder_factory, db_session):
  71. """Verify folder can be updated."""
  72. folder = await folder_factory(name="Old Name")
  73. data = {"name": "New Name"}
  74. response = await async_client.put(f"/api/v1/library/folders/{folder.id}", json=data)
  75. assert response.status_code == 200
  76. result = response.json()
  77. assert result["name"] == "New Name"
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_delete_folder(self, async_client: AsyncClient, folder_factory, db_session):
  81. """Verify folder can be deleted."""
  82. folder = await folder_factory()
  83. response = await async_client.delete(f"/api/v1/library/folders/{folder.id}")
  84. assert response.status_code == 200
  85. result = response.json()
  86. assert result.get("message") or result.get("success", True)
  87. class TestLibraryFilesAPI:
  88. """Integration tests for library files endpoints."""
  89. @pytest.fixture
  90. async def folder_factory(self, db_session):
  91. """Factory to create test folders."""
  92. _counter = [0]
  93. async def _create_folder(**kwargs):
  94. from backend.app.models.library import LibraryFolder
  95. _counter[0] += 1
  96. counter = _counter[0]
  97. defaults = {"name": f"Test Folder {counter}"}
  98. defaults.update(kwargs)
  99. folder = LibraryFolder(**defaults)
  100. db_session.add(folder)
  101. await db_session.commit()
  102. await db_session.refresh(folder)
  103. return folder
  104. return _create_folder
  105. @pytest.fixture
  106. async def file_factory(self, db_session):
  107. """Factory to create test files."""
  108. _counter = [0]
  109. async def _create_file(**kwargs):
  110. from backend.app.models.library import LibraryFile
  111. _counter[0] += 1
  112. counter = _counter[0]
  113. defaults = {
  114. "filename": f"test_file_{counter}.3mf",
  115. "file_path": f"/test/path/test_file_{counter}.3mf",
  116. "file_size": 1024,
  117. "file_type": "3mf",
  118. }
  119. defaults.update(kwargs)
  120. lib_file = LibraryFile(**defaults)
  121. db_session.add(lib_file)
  122. await db_session.commit()
  123. await db_session.refresh(lib_file)
  124. return lib_file
  125. return _create_file
  126. @pytest.mark.asyncio
  127. @pytest.mark.integration
  128. async def test_list_files_empty(self, async_client: AsyncClient, db_session):
  129. """Verify empty file list returns empty array."""
  130. response = await async_client.get("/api/v1/library/files")
  131. assert response.status_code == 200
  132. assert response.json() == []
  133. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_list_files_in_folder(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  136. """Verify files can be filtered by folder."""
  137. folder = await folder_factory()
  138. file1 = await file_factory(folder_id=folder.id)
  139. await file_factory() # File in root (no folder)
  140. response = await async_client.get(f"/api/v1/library/files?folder_id={folder.id}")
  141. assert response.status_code == 200
  142. result = response.json()
  143. assert len(result) == 1
  144. assert result[0]["id"] == file1.id
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_get_file(self, async_client: AsyncClient, file_factory, db_session):
  148. """Verify single file can be retrieved."""
  149. lib_file = await file_factory(filename="test.3mf")
  150. response = await async_client.get(f"/api/v1/library/files/{lib_file.id}")
  151. assert response.status_code == 200
  152. result = response.json()
  153. assert result["id"] == lib_file.id
  154. assert result["filename"] == "test.3mf"
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_get_file_not_found(self, async_client: AsyncClient, db_session):
  158. """Verify 404 for non-existent file."""
  159. response = await async_client.get("/api/v1/library/files/9999")
  160. assert response.status_code == 404
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_delete_file(self, async_client: AsyncClient, file_factory, db_session):
  164. """Verify file can be deleted."""
  165. lib_file = await file_factory()
  166. response = await async_client.delete(f"/api/v1/library/files/{lib_file.id}")
  167. assert response.status_code == 200
  168. result = response.json()
  169. assert result.get("message") or result.get("success", True)
  170. @pytest.mark.asyncio
  171. @pytest.mark.integration
  172. async def test_rename_file(self, async_client: AsyncClient, file_factory, db_session):
  173. """Verify file can be renamed."""
  174. lib_file = await file_factory(filename="old_name.3mf")
  175. data = {"filename": "new_name.3mf"}
  176. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  177. assert response.status_code == 200
  178. result = response.json()
  179. assert result["filename"] == "new_name.3mf"
  180. @pytest.mark.asyncio
  181. @pytest.mark.integration
  182. async def test_rename_file_invalid_path_separator(self, async_client: AsyncClient, file_factory, db_session):
  183. """Verify file rename fails with path separators."""
  184. lib_file = await file_factory(filename="test.3mf")
  185. data = {"filename": "path/to/file.3mf"}
  186. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  187. assert response.status_code == 400
  188. assert "path separator" in response.json()["detail"].lower()
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_rename_file_invalid_backslash(self, async_client: AsyncClient, file_factory, db_session):
  192. """Verify file rename fails with backslash."""
  193. lib_file = await file_factory(filename="test.3mf")
  194. data = {"filename": "path\\to\\file.3mf"}
  195. response = await async_client.put(f"/api/v1/library/files/{lib_file.id}", json=data)
  196. assert response.status_code == 400
  197. assert "path separator" in response.json()["detail"].lower()
  198. @pytest.mark.asyncio
  199. @pytest.mark.integration
  200. async def test_library_stats(self, async_client: AsyncClient, folder_factory, file_factory, db_session):
  201. """Verify library stats endpoint returns counts."""
  202. await folder_factory()
  203. await folder_factory()
  204. await file_factory()
  205. response = await async_client.get("/api/v1/library/stats")
  206. assert response.status_code == 200
  207. result = response.json()
  208. assert result["total_folders"] == 2
  209. assert result["total_files"] == 1
  210. class TestLibraryAddToQueueAPI:
  211. """Integration tests for /api/v1/library/files/add-to-queue endpoint."""
  212. @pytest.fixture
  213. async def printer_factory(self, db_session):
  214. """Factory to create test printers."""
  215. _counter = [0]
  216. async def _create_printer(**kwargs):
  217. from backend.app.models.printer import Printer
  218. _counter[0] += 1
  219. counter = _counter[0]
  220. defaults = {
  221. "name": f"Test Printer {counter}",
  222. "ip_address": f"192.168.1.{100 + counter}",
  223. "serial_number": f"TESTSERIAL{counter:04d}",
  224. "access_code": "12345678",
  225. "model": "X1C",
  226. }
  227. defaults.update(kwargs)
  228. printer = Printer(**defaults)
  229. db_session.add(printer)
  230. await db_session.commit()
  231. await db_session.refresh(printer)
  232. return printer
  233. return _create_printer
  234. @pytest.fixture
  235. async def library_file_factory(self, db_session):
  236. """Factory to create test library files."""
  237. _counter = [0]
  238. async def _create_library_file(**kwargs):
  239. from backend.app.models.library import LibraryFile
  240. _counter[0] += 1
  241. counter = _counter[0]
  242. defaults = {
  243. "filename": f"test_file_{counter}.gcode.3mf",
  244. "file_path": f"/test/path/test_file_{counter}.gcode.3mf",
  245. "file_size": 1024,
  246. "file_type": "3mf",
  247. }
  248. defaults.update(kwargs)
  249. lib_file = LibraryFile(**defaults)
  250. db_session.add(lib_file)
  251. await db_session.commit()
  252. await db_session.refresh(lib_file)
  253. return lib_file
  254. return _create_library_file
  255. @pytest.mark.asyncio
  256. @pytest.mark.integration
  257. async def test_add_to_queue_file_not_found(self, async_client: AsyncClient, printer_factory, db_session):
  258. """Verify error for non-existent file."""
  259. await printer_factory()
  260. data = {"file_ids": [9999]}
  261. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  262. assert response.status_code == 200
  263. result = response.json()
  264. assert len(result["added"]) == 0
  265. assert len(result["errors"]) == 1
  266. assert result["errors"][0]["file_id"] == 9999
  267. @pytest.mark.asyncio
  268. @pytest.mark.integration
  269. async def test_add_non_sliced_file_to_queue_fails(
  270. self, async_client: AsyncClient, printer_factory, library_file_factory, db_session
  271. ):
  272. """Verify non-sliced file cannot be added to queue."""
  273. await printer_factory()
  274. lib_file = await library_file_factory(
  275. filename="model.stl",
  276. file_path="/test/path/model.stl",
  277. file_type="stl",
  278. )
  279. data = {"file_ids": [lib_file.id]}
  280. response = await async_client.post("/api/v1/library/files/add-to-queue", json=data)
  281. assert response.status_code == 200
  282. result = response.json()
  283. assert len(result["added"]) == 0
  284. assert len(result["errors"]) == 1
  285. assert "sliced" in result["errors"][0]["error"].lower()
  286. class TestLibraryZipExtractAPI:
  287. """Integration tests for ZIP extraction endpoint."""
  288. @pytest.mark.asyncio
  289. @pytest.mark.integration
  290. async def test_extract_zip_invalid_file_type(self, async_client: AsyncClient, db_session):
  291. """Verify non-ZIP files are rejected."""
  292. # Create a fake file that's not a ZIP
  293. files = {"file": ("test.txt", b"This is not a zip file", "text/plain")}
  294. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  295. assert response.status_code == 400
  296. assert "ZIP" in response.json()["detail"]
  297. @pytest.mark.asyncio
  298. @pytest.mark.integration
  299. async def test_extract_zip_basic(self, async_client: AsyncClient, db_session):
  300. """Verify basic ZIP extraction works."""
  301. import io
  302. import zipfile
  303. # Create a simple ZIP file in memory
  304. zip_buffer = io.BytesIO()
  305. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  306. zf.writestr("test1.txt", "Content of file 1")
  307. zf.writestr("test2.txt", "Content of file 2")
  308. zip_buffer.seek(0)
  309. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  310. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  311. assert response.status_code == 200
  312. result = response.json()
  313. assert result["extracted"] == 2
  314. assert len(result["files"]) == 2
  315. assert len(result["errors"]) == 0
  316. @pytest.mark.asyncio
  317. @pytest.mark.integration
  318. async def test_extract_zip_with_folders(self, async_client: AsyncClient, db_session):
  319. """Verify ZIP extraction preserves folder structure."""
  320. import io
  321. import zipfile
  322. # Create a ZIP file with folder structure
  323. zip_buffer = io.BytesIO()
  324. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  325. zf.writestr("folder1/file1.txt", "Content 1")
  326. zf.writestr("folder1/subfolder/file2.txt", "Content 2")
  327. zf.writestr("folder2/file3.txt", "Content 3")
  328. zip_buffer.seek(0)
  329. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  330. params = {"preserve_structure": "true"}
  331. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  332. assert response.status_code == 200
  333. result = response.json()
  334. assert result["extracted"] == 3
  335. assert result["folders_created"] >= 3 # folder1, folder1/subfolder, folder2
  336. @pytest.mark.asyncio
  337. @pytest.mark.integration
  338. async def test_extract_zip_flat(self, async_client: AsyncClient, db_session):
  339. """Verify ZIP extraction can extract flat (no folders)."""
  340. import io
  341. import zipfile
  342. # Create a ZIP file with folder structure
  343. zip_buffer = io.BytesIO()
  344. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  345. zf.writestr("folder/file1.txt", "Content 1")
  346. zf.writestr("folder/file2.txt", "Content 2")
  347. zip_buffer.seek(0)
  348. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  349. params = {"preserve_structure": "false"}
  350. response = await async_client.post("/api/v1/library/files/extract-zip", files=files, params=params)
  351. assert response.status_code == 200
  352. result = response.json()
  353. assert result["extracted"] == 2
  354. assert result["folders_created"] == 0 # No folders created when flat
  355. @pytest.mark.asyncio
  356. @pytest.mark.integration
  357. async def test_extract_zip_skips_macos_files(self, async_client: AsyncClient, db_session):
  358. """Verify ZIP extraction skips __MACOSX and hidden files."""
  359. import io
  360. import zipfile
  361. # Create a ZIP file with macOS junk files
  362. zip_buffer = io.BytesIO()
  363. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  364. zf.writestr("real_file.txt", "Real content")
  365. zf.writestr("__MACOSX/._real_file.txt", "macOS metadata")
  366. zf.writestr(".hidden_file", "Hidden content")
  367. zip_buffer.seek(0)
  368. files = {"file": ("test.zip", zip_buffer.read(), "application/zip")}
  369. response = await async_client.post("/api/v1/library/files/extract-zip", files=files)
  370. assert response.status_code == 200
  371. result = response.json()
  372. assert result["extracted"] == 1 # Only real_file.txt
  373. assert result["files"][0]["filename"] == "real_file.txt"