test_library_api.py 59 KB

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