test_projects_api.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741
  1. """Integration tests for Projects API endpoints."""
  2. import pytest
  3. from httpx import AsyncClient
  4. class TestProjectsAPI:
  5. """Integration tests for /api/v1/projects endpoints."""
  6. @pytest.fixture
  7. async def project_factory(self, db_session):
  8. """Factory to create test projects."""
  9. _counter = [0]
  10. async def _create_project(**kwargs):
  11. from backend.app.models.project import Project
  12. _counter[0] += 1
  13. counter = _counter[0]
  14. defaults = {
  15. "name": f"Test Project {counter}",
  16. "description": "Test project description",
  17. "color": "#FF0000",
  18. }
  19. defaults.update(kwargs)
  20. project = Project(**defaults)
  21. db_session.add(project)
  22. await db_session.commit()
  23. await db_session.refresh(project)
  24. return project
  25. return _create_project
  26. @pytest.mark.asyncio
  27. @pytest.mark.integration
  28. async def test_list_projects_empty(self, async_client: AsyncClient):
  29. """Verify empty list when no projects exist."""
  30. response = await async_client.get("/api/v1/projects/")
  31. assert response.status_code == 200
  32. assert isinstance(response.json(), list)
  33. @pytest.mark.asyncio
  34. @pytest.mark.integration
  35. async def test_list_projects_with_data(self, async_client: AsyncClient, project_factory, db_session):
  36. """Verify list returns existing projects."""
  37. await project_factory(name="My Project")
  38. response = await async_client.get("/api/v1/projects/")
  39. assert response.status_code == 200
  40. data = response.json()
  41. assert any(p["name"] == "My Project" for p in data)
  42. @pytest.mark.asyncio
  43. @pytest.mark.integration
  44. async def test_create_project(self, async_client: AsyncClient):
  45. """Verify project can be created."""
  46. data = {
  47. "name": "New Project",
  48. "description": "A new project",
  49. "color": "#00FF00",
  50. }
  51. response = await async_client.post("/api/v1/projects/", json=data)
  52. assert response.status_code == 200
  53. result = response.json()
  54. assert result["name"] == "New Project"
  55. assert result["color"] == "#00FF00"
  56. @pytest.mark.asyncio
  57. @pytest.mark.integration
  58. async def test_get_project(self, async_client: AsyncClient, project_factory, db_session):
  59. """Verify single project can be retrieved."""
  60. project = await project_factory(name="Get Test Project")
  61. response = await async_client.get(f"/api/v1/projects/{project.id}")
  62. assert response.status_code == 200
  63. assert response.json()["name"] == "Get Test Project"
  64. @pytest.mark.asyncio
  65. @pytest.mark.integration
  66. async def test_get_project_not_found(self, async_client: AsyncClient):
  67. """Verify 404 for non-existent project."""
  68. response = await async_client.get("/api/v1/projects/9999")
  69. assert response.status_code == 404
  70. @pytest.mark.asyncio
  71. @pytest.mark.integration
  72. async def test_update_project(self, async_client: AsyncClient, project_factory, db_session):
  73. """Verify project can be updated."""
  74. project = await project_factory(name="Original")
  75. response = await async_client.patch(
  76. f"/api/v1/projects/{project.id}", json={"name": "Updated", "description": "Updated description"}
  77. )
  78. assert response.status_code == 200
  79. result = response.json()
  80. assert result["name"] == "Updated"
  81. assert result["description"] == "Updated description"
  82. @pytest.mark.asyncio
  83. @pytest.mark.integration
  84. async def test_delete_project(self, async_client: AsyncClient, project_factory, db_session):
  85. """Verify project can be deleted."""
  86. project = await project_factory()
  87. response = await async_client.delete(f"/api/v1/projects/{project.id}")
  88. assert response.status_code == 200
  89. data = response.json()
  90. assert data["message"] == "Project deleted"
  91. @pytest.mark.asyncio
  92. @pytest.mark.integration
  93. async def test_delete_project_not_found(self, async_client: AsyncClient):
  94. """Verify 404 for deleting non-existent project."""
  95. response = await async_client.delete("/api/v1/projects/9999")
  96. assert response.status_code == 404
  97. class TestProjectPartsTracking:
  98. """Tests for project parts tracking feature."""
  99. @pytest.fixture
  100. async def project_factory(self, db_session):
  101. """Factory to create test projects."""
  102. async def _create_project(**kwargs):
  103. from backend.app.models.project import Project
  104. defaults = {
  105. "name": "Parts Test Project",
  106. "description": "Test project",
  107. "color": "#FF0000",
  108. }
  109. defaults.update(kwargs)
  110. project = Project(**defaults)
  111. db_session.add(project)
  112. await db_session.commit()
  113. await db_session.refresh(project)
  114. return project
  115. return _create_project
  116. @pytest.fixture
  117. async def archive_factory(self, db_session):
  118. """Factory to create test archives."""
  119. async def _create_archive(**kwargs):
  120. from backend.app.models.archive import PrintArchive
  121. defaults = {
  122. "filename": "test.3mf",
  123. "file_path": "test/test.3mf",
  124. "file_size": 1000,
  125. "print_name": "Test Print",
  126. "status": "completed",
  127. "quantity": 1,
  128. }
  129. defaults.update(kwargs)
  130. archive = PrintArchive(**defaults)
  131. db_session.add(archive)
  132. await db_session.commit()
  133. await db_session.refresh(archive)
  134. return archive
  135. return _create_archive
  136. @pytest.mark.asyncio
  137. @pytest.mark.integration
  138. async def test_create_project_with_target_parts_count(self, async_client: AsyncClient):
  139. """Verify project can be created with target_parts_count."""
  140. data = {
  141. "name": "Parts Project",
  142. "target_count": 10, # 10 plates
  143. "target_parts_count": 50, # 50 parts total
  144. }
  145. response = await async_client.post("/api/v1/projects/", json=data)
  146. assert response.status_code == 200
  147. result = response.json()
  148. assert result["target_count"] == 10
  149. assert result["target_parts_count"] == 50
  150. @pytest.mark.asyncio
  151. @pytest.mark.integration
  152. async def test_update_project_target_parts_count(self, async_client: AsyncClient, project_factory, db_session):
  153. """Verify target_parts_count can be updated."""
  154. project = await project_factory()
  155. response = await async_client.patch(
  156. f"/api/v1/projects/{project.id}",
  157. json={"target_parts_count": 100},
  158. )
  159. assert response.status_code == 200
  160. assert response.json()["target_parts_count"] == 100
  161. @pytest.mark.asyncio
  162. @pytest.mark.integration
  163. async def test_project_parts_progress_calculation(
  164. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  165. ):
  166. """Verify parts progress is calculated from archive quantities."""
  167. # Create project with target of 20 parts
  168. project = await project_factory(target_parts_count=20)
  169. # Create archives with different quantities
  170. await archive_factory(project_id=project.id, quantity=3, status="completed") # 3 parts
  171. await archive_factory(project_id=project.id, quantity=5, status="completed") # 5 parts
  172. await archive_factory(project_id=project.id, quantity=2, status="completed") # 2 parts
  173. # Total: 10 parts completed out of 20 = 50%
  174. response = await async_client.get(f"/api/v1/projects/{project.id}")
  175. assert response.status_code == 200
  176. data = response.json()
  177. # Check stats
  178. assert data["stats"]["completed_prints"] == 10 # Sum of quantities
  179. assert data["stats"]["parts_progress_percent"] == 50.0 # 10/20 = 50%
  180. assert data["stats"]["remaining_parts"] == 10 # 20 - 10 = 10
  181. @pytest.mark.asyncio
  182. @pytest.mark.integration
  183. async def test_project_list_shows_parts_count(
  184. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  185. ):
  186. """Verify project list returns correct completed_count (parts sum)."""
  187. project = await project_factory(name="List Parts Project", target_parts_count=100)
  188. # Create archives with quantities
  189. await archive_factory(project_id=project.id, quantity=4, status="completed")
  190. await archive_factory(project_id=project.id, quantity=6, status="completed")
  191. # Total: 10 parts, 2 plates
  192. response = await async_client.get("/api/v1/projects/")
  193. assert response.status_code == 200
  194. data = response.json()
  195. # Find our project
  196. our_project = next((p for p in data if p["name"] == "List Parts Project"), None)
  197. assert our_project is not None
  198. assert our_project["archive_count"] == 2 # 2 plates
  199. assert our_project["completed_count"] == 10 # 10 parts (sum of quantities)
  200. assert our_project["target_parts_count"] == 100
  201. @pytest.mark.asyncio
  202. @pytest.mark.integration
  203. async def test_plates_vs_parts_progress(
  204. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  205. ):
  206. """Verify plates and parts progress are calculated separately."""
  207. # Project needs 5 plates producing 25 parts total (5 parts per plate)
  208. project = await project_factory(target_count=5, target_parts_count=25)
  209. # Complete 2 plates, each with 5 parts
  210. await archive_factory(project_id=project.id, quantity=5, status="completed")
  211. await archive_factory(project_id=project.id, quantity=5, status="completed")
  212. # Plates: 2/5 = 40%, Parts: 10/25 = 40%
  213. response = await async_client.get(f"/api/v1/projects/{project.id}")
  214. assert response.status_code == 200
  215. data = response.json()
  216. assert data["stats"]["total_archives"] == 2 # 2 plates
  217. assert data["stats"]["completed_prints"] == 10 # 10 parts
  218. assert data["stats"]["progress_percent"] == 40.0 # plates: 2/5
  219. assert data["stats"]["parts_progress_percent"] == 40.0 # parts: 10/25
  220. class TestProjectArchivedStatusNotCounted:
  221. """Tests for bug #630: archived files added to a project should not count as printed."""
  222. @pytest.fixture
  223. async def project_factory(self, db_session):
  224. """Factory to create test projects."""
  225. async def _create_project(**kwargs):
  226. from backend.app.models.project import Project
  227. defaults = {
  228. "name": "Archived Status Test",
  229. "description": "Test project",
  230. "color": "#FF0000",
  231. }
  232. defaults.update(kwargs)
  233. project = Project(**defaults)
  234. db_session.add(project)
  235. await db_session.commit()
  236. await db_session.refresh(project)
  237. return project
  238. return _create_project
  239. @pytest.fixture
  240. async def archive_factory(self, db_session):
  241. """Factory to create test archives."""
  242. async def _create_archive(**kwargs):
  243. from backend.app.models.archive import PrintArchive
  244. defaults = {
  245. "filename": "test.3mf",
  246. "file_path": "test/test.3mf",
  247. "file_size": 1000,
  248. "print_name": "Test Print",
  249. "status": "completed",
  250. "quantity": 1,
  251. }
  252. defaults.update(kwargs)
  253. archive = PrintArchive(**defaults)
  254. db_session.add(archive)
  255. await db_session.commit()
  256. await db_session.refresh(archive)
  257. return archive
  258. return _create_archive
  259. @pytest.mark.asyncio
  260. @pytest.mark.integration
  261. async def test_archived_files_not_counted_as_completed(
  262. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  263. ):
  264. """Archived files added to a project should not count in completed_prints stats."""
  265. project = await project_factory(target_parts_count=20)
  266. # 2 actually printed (completed), 3 just archived (not printed yet)
  267. await archive_factory(project_id=project.id, quantity=2, status="completed")
  268. await archive_factory(project_id=project.id, quantity=3, status="archived")
  269. await archive_factory(project_id=project.id, quantity=5, status="archived")
  270. response = await async_client.get(f"/api/v1/projects/{project.id}")
  271. assert response.status_code == 200
  272. data = response.json()
  273. # Only the completed archive should count
  274. assert data["stats"]["completed_prints"] == 2
  275. assert data["stats"]["parts_progress_percent"] == 10.0 # 2/20 = 10%
  276. assert data["stats"]["remaining_parts"] == 18
  277. @pytest.mark.asyncio
  278. @pytest.mark.integration
  279. async def test_archived_files_not_counted_in_project_list(
  280. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  281. ):
  282. """Project list endpoint should not count archived files as completed."""
  283. project = await project_factory(name="List Archived Test", target_parts_count=50)
  284. await archive_factory(project_id=project.id, quantity=4, status="completed")
  285. await archive_factory(project_id=project.id, quantity=6, status="archived")
  286. response = await async_client.get("/api/v1/projects/")
  287. assert response.status_code == 200
  288. data = response.json()
  289. our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
  290. assert our_project is not None
  291. assert our_project["completed_count"] == 4 # Only completed, not archived
  292. assert our_project["archive_count"] == 2 # Both archives exist as plates
  293. @pytest.mark.asyncio
  294. @pytest.mark.integration
  295. async def test_only_completed_status_counts(
  296. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  297. ):
  298. """Only 'completed' status should count in stats, not archived/failed/etc."""
  299. project = await project_factory(target_parts_count=100)
  300. await archive_factory(project_id=project.id, quantity=10, status="completed")
  301. await archive_factory(project_id=project.id, quantity=5, status="archived")
  302. await archive_factory(project_id=project.id, quantity=3, status="failed")
  303. await archive_factory(project_id=project.id, quantity=2, status="aborted")
  304. response = await async_client.get(f"/api/v1/projects/{project.id}")
  305. assert response.status_code == 200
  306. data = response.json()
  307. assert data["stats"]["completed_prints"] == 10 # Only "completed"
  308. assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of archives, not sum)
  309. assert data["stats"]["total_archives"] == 4 # All archives
  310. assert data["stats"]["total_items"] == 20 # Sum of all quantities
  311. class TestProjectArchivesAPI:
  312. """Tests for project-archive relationships."""
  313. @pytest.fixture
  314. async def project_factory(self, db_session):
  315. """Factory to create test projects."""
  316. async def _create_project(**kwargs):
  317. from backend.app.models.project import Project
  318. defaults = {
  319. "name": "Archive Test Project",
  320. "description": "Test project",
  321. "color": "#0000FF",
  322. }
  323. defaults.update(kwargs)
  324. project = Project(**defaults)
  325. db_session.add(project)
  326. await db_session.commit()
  327. await db_session.refresh(project)
  328. return project
  329. return _create_project
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_get_project_with_archives(self, async_client: AsyncClient, project_factory, db_session):
  333. """Verify project can be retrieved with archive count."""
  334. project = await project_factory()
  335. response = await async_client.get(f"/api/v1/projects/{project.id}")
  336. assert response.status_code == 200
  337. # Project should have an archive count (may be 0)
  338. data = response.json()
  339. assert "name" in data
  340. class TestProjectExportImport:
  341. """Tests for project export/import functionality."""
  342. @pytest.fixture
  343. async def project_factory(self, db_session):
  344. """Factory to create test projects."""
  345. _counter = [0]
  346. async def _create_project(**kwargs):
  347. from backend.app.models.project import Project
  348. _counter[0] += 1
  349. counter = _counter[0]
  350. defaults = {
  351. "name": f"Export Test Project {counter}",
  352. "description": "Test project for export",
  353. "color": "#00FF00",
  354. }
  355. defaults.update(kwargs)
  356. project = Project(**defaults)
  357. db_session.add(project)
  358. await db_session.commit()
  359. await db_session.refresh(project)
  360. return project
  361. return _create_project
  362. @pytest.fixture
  363. async def bom_item_factory(self, db_session):
  364. """Factory to create test BOM items."""
  365. async def _create_bom_item(project_id: int, **kwargs):
  366. from backend.app.models.project_bom import ProjectBOMItem
  367. defaults = {
  368. "project_id": project_id,
  369. "name": "Test Part",
  370. "quantity_needed": 1,
  371. "quantity_acquired": 0,
  372. "sort_order": 0,
  373. }
  374. defaults.update(kwargs)
  375. item = ProjectBOMItem(**defaults)
  376. db_session.add(item)
  377. await db_session.commit()
  378. await db_session.refresh(item)
  379. return item
  380. return _create_bom_item
  381. @pytest.mark.asyncio
  382. @pytest.mark.integration
  383. async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
  384. """Verify project export includes BOM items."""
  385. project = await project_factory(
  386. name="Export Me",
  387. description="A test project",
  388. target_count=10,
  389. target_parts_count=50,
  390. budget=100.0,
  391. )
  392. # Add BOM items
  393. await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
  394. await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
  395. # Test JSON format export
  396. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  397. assert response.status_code == 200
  398. data = response.json()
  399. assert data["name"] == "Export Me"
  400. assert data["description"] == "A test project"
  401. assert data["target_count"] == 10
  402. assert data["target_parts_count"] == 50
  403. assert data["budget"] == 100.0
  404. assert len(data["bom_items"]) == 2
  405. # Check BOM items
  406. bom_names = [item["name"] for item in data["bom_items"]]
  407. assert "M3x8 Screws" in bom_names
  408. assert "Heat Inserts" in bom_names
  409. # Test ZIP format export (default)
  410. zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  411. assert zip_response.status_code == 200
  412. assert zip_response.headers["content-type"] == "application/zip"
  413. @pytest.mark.asyncio
  414. @pytest.mark.integration
  415. async def test_import_project(self, async_client: AsyncClient):
  416. """Verify project can be imported with BOM items."""
  417. import_data = {
  418. "name": "Imported Project",
  419. "description": "Imported from JSON",
  420. "color": "#FF00FF",
  421. "target_count": 5,
  422. "target_parts_count": 25,
  423. "budget": 50.0,
  424. "bom_items": [
  425. {
  426. "name": "PTFE Tubes",
  427. "quantity_needed": 4,
  428. "quantity_acquired": 0,
  429. "unit_price": 2.50,
  430. "sourcing_url": "https://example.com",
  431. "stl_filename": None,
  432. "remarks": "Need 4mm ID",
  433. },
  434. ],
  435. }
  436. response = await async_client.post("/api/v1/projects/import", json=import_data)
  437. assert response.status_code == 200
  438. data = response.json()
  439. assert data["name"] == "Imported Project"
  440. assert data["description"] == "Imported from JSON"
  441. assert data["target_count"] == 5
  442. assert data["target_parts_count"] == 25
  443. assert data["budget"] == 50.0
  444. assert data["id"] > 0 # Has a valid ID
  445. # BOM stats should show 1 item imported
  446. assert data["stats"]["bom_total_items"] == 1
  447. @pytest.mark.asyncio
  448. @pytest.mark.integration
  449. async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
  450. """Verify project export includes linked folders."""
  451. from backend.app.models.library import LibraryFolder
  452. project = await project_factory(name="Project With Folder")
  453. # Create a linked folder
  454. folder = LibraryFolder(name="Project Files", project_id=project.id)
  455. db_session.add(folder)
  456. await db_session.commit()
  457. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  458. assert response.status_code == 200
  459. data = response.json()
  460. assert data["name"] == "Project With Folder"
  461. assert len(data["linked_folders"]) == 1
  462. assert data["linked_folders"][0]["name"] == "Project Files"
  463. @pytest.mark.asyncio
  464. @pytest.mark.integration
  465. async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
  466. """Verify project import accepts linked folders data."""
  467. import_data = {
  468. "name": "Imported With Folders",
  469. "linked_folders": [
  470. {"name": "STL Files"},
  471. {"name": "Documentation"},
  472. ],
  473. }
  474. # Import should succeed with linked_folders
  475. response = await async_client.post("/api/v1/projects/import", json=import_data)
  476. assert response.status_code == 200
  477. data = response.json()
  478. assert data["name"] == "Imported With Folders"
  479. assert data["id"] > 0
  480. @pytest.mark.asyncio
  481. @pytest.mark.integration
  482. async def test_import_project_from_json_file(self, async_client: AsyncClient):
  483. """Verify project can be imported from JSON file upload."""
  484. import io
  485. import json
  486. project_data = {
  487. "name": "File Uploaded Project",
  488. "description": "Imported from JSON file",
  489. "color": "#123456",
  490. }
  491. # Create a file-like object
  492. file_content = json.dumps(project_data).encode()
  493. files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
  494. response = await async_client.post("/api/v1/projects/import/file", files=files)
  495. assert response.status_code == 200
  496. data = response.json()
  497. assert data["name"] == "File Uploaded Project"
  498. assert data["description"] == "Imported from JSON file"
  499. @pytest.mark.asyncio
  500. @pytest.mark.integration
  501. async def test_import_project_from_zip_file(self, async_client: AsyncClient):
  502. """Verify project can be imported from ZIP file with files."""
  503. import io
  504. import json
  505. import zipfile
  506. project_data = {
  507. "name": "ZIP Imported Project",
  508. "description": "Imported from ZIP",
  509. "linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
  510. }
  511. # Create a ZIP file in memory
  512. zip_buffer = io.BytesIO()
  513. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  514. zf.writestr("project.json", json.dumps(project_data))
  515. zf.writestr("files/TestFolder/test.txt", "Hello World")
  516. zip_buffer.seek(0)
  517. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  518. response = await async_client.post("/api/v1/projects/import/file", files=files)
  519. assert response.status_code == 200
  520. data = response.json()
  521. assert data["name"] == "ZIP Imported Project"
  522. assert data["description"] == "Imported from ZIP"
  523. @pytest.mark.asyncio
  524. @pytest.mark.integration
  525. async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
  526. """Verify ZIP export contains actual files from linked folders."""
  527. import io
  528. import json
  529. import zipfile
  530. from pathlib import Path
  531. from backend.app.api.routes.library import get_library_dir
  532. from backend.app.models.library import LibraryFile, LibraryFolder
  533. project = await project_factory(name="Project With Files")
  534. # Create a linked folder with is_external fields
  535. folder = LibraryFolder(
  536. name="TestExportFolder",
  537. project_id=project.id,
  538. is_external=False,
  539. external_readonly=False,
  540. external_show_hidden=False,
  541. )
  542. db_session.add(folder)
  543. await db_session.flush()
  544. # Create a test file on disk
  545. library_dir = get_library_dir()
  546. folder_path = library_dir / "TestExportFolder"
  547. folder_path.mkdir(parents=True, exist_ok=True)
  548. test_file_path = folder_path / "test_export.txt"
  549. test_file_path.write_text("Export test content")
  550. # Create library file record
  551. lib_file = LibraryFile(
  552. folder_id=folder.id,
  553. filename="test_export.txt",
  554. file_path="TestExportFolder/test_export.txt",
  555. file_type="other",
  556. file_size=19,
  557. is_external=False,
  558. )
  559. db_session.add(lib_file)
  560. await db_session.commit()
  561. # Export as ZIP
  562. response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  563. assert response.status_code == 200
  564. assert response.headers["content-type"] == "application/zip"
  565. # Verify ZIP contents
  566. zip_buffer = io.BytesIO(response.content)
  567. with zipfile.ZipFile(zip_buffer, "r") as zf:
  568. assert "project.json" in zf.namelist()
  569. assert "files/TestExportFolder/test_export.txt" in zf.namelist()
  570. # Verify file content
  571. file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
  572. assert file_content == "Export test content"
  573. # Verify project.json
  574. project_data = json.loads(zf.read("project.json"))
  575. assert project_data["name"] == "Project With Files"
  576. # Cleanup
  577. test_file_path.unlink(missing_ok=True)
  578. folder_path.rmdir()
  579. @pytest.mark.asyncio
  580. @pytest.mark.integration
  581. async def test_import_invalid_file_type(self, async_client: AsyncClient):
  582. """Verify import rejects invalid file types."""
  583. import io
  584. files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
  585. response = await async_client.post("/api/v1/projects/import/file", files=files)
  586. assert response.status_code == 400
  587. assert "must be .zip or .json" in response.json()["detail"]
  588. @pytest.mark.asyncio
  589. @pytest.mark.integration
  590. async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
  591. """Verify import rejects ZIP without project.json."""
  592. import io
  593. import zipfile
  594. zip_buffer = io.BytesIO()
  595. with zipfile.ZipFile(zip_buffer, "w") as zf:
  596. zf.writestr("other.txt", "no project.json here")
  597. zip_buffer.seek(0)
  598. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  599. response = await async_client.post("/api/v1/projects/import/file", files=files)
  600. assert response.status_code == 400
  601. assert "project.json" in response.json()["detail"]
  602. @pytest.mark.asyncio
  603. @pytest.mark.integration
  604. async def test_import_invalid_json(self, async_client: AsyncClient):
  605. """Verify import rejects invalid JSON content."""
  606. import io
  607. files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
  608. response = await async_client.post("/api/v1/projects/import/file", files=files)
  609. assert response.status_code == 400
  610. assert "Invalid JSON" in response.json()["detail"]