test_projects_api.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626
  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 TestProjectArchivesAPI:
  221. """Tests for project-archive relationships."""
  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": "Archive Test Project",
  229. "description": "Test project",
  230. "color": "#0000FF",
  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.mark.asyncio
  240. @pytest.mark.integration
  241. async def test_get_project_with_archives(self, async_client: AsyncClient, project_factory, db_session):
  242. """Verify project can be retrieved with archive count."""
  243. project = await project_factory()
  244. response = await async_client.get(f"/api/v1/projects/{project.id}")
  245. assert response.status_code == 200
  246. # Project should have an archive count (may be 0)
  247. data = response.json()
  248. assert "name" in data
  249. class TestProjectExportImport:
  250. """Tests for project export/import functionality."""
  251. @pytest.fixture
  252. async def project_factory(self, db_session):
  253. """Factory to create test projects."""
  254. _counter = [0]
  255. async def _create_project(**kwargs):
  256. from backend.app.models.project import Project
  257. _counter[0] += 1
  258. counter = _counter[0]
  259. defaults = {
  260. "name": f"Export Test Project {counter}",
  261. "description": "Test project for export",
  262. "color": "#00FF00",
  263. }
  264. defaults.update(kwargs)
  265. project = Project(**defaults)
  266. db_session.add(project)
  267. await db_session.commit()
  268. await db_session.refresh(project)
  269. return project
  270. return _create_project
  271. @pytest.fixture
  272. async def bom_item_factory(self, db_session):
  273. """Factory to create test BOM items."""
  274. async def _create_bom_item(project_id: int, **kwargs):
  275. from backend.app.models.project_bom import ProjectBOMItem
  276. defaults = {
  277. "project_id": project_id,
  278. "name": "Test Part",
  279. "quantity_needed": 1,
  280. "quantity_acquired": 0,
  281. "sort_order": 0,
  282. }
  283. defaults.update(kwargs)
  284. item = ProjectBOMItem(**defaults)
  285. db_session.add(item)
  286. await db_session.commit()
  287. await db_session.refresh(item)
  288. return item
  289. return _create_bom_item
  290. @pytest.mark.asyncio
  291. @pytest.mark.integration
  292. async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
  293. """Verify project export includes BOM items."""
  294. project = await project_factory(
  295. name="Export Me",
  296. description="A test project",
  297. target_count=10,
  298. target_parts_count=50,
  299. budget=100.0,
  300. )
  301. # Add BOM items
  302. await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
  303. await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
  304. # Test JSON format export
  305. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  306. assert response.status_code == 200
  307. data = response.json()
  308. assert data["name"] == "Export Me"
  309. assert data["description"] == "A test project"
  310. assert data["target_count"] == 10
  311. assert data["target_parts_count"] == 50
  312. assert data["budget"] == 100.0
  313. assert len(data["bom_items"]) == 2
  314. # Check BOM items
  315. bom_names = [item["name"] for item in data["bom_items"]]
  316. assert "M3x8 Screws" in bom_names
  317. assert "Heat Inserts" in bom_names
  318. # Test ZIP format export (default)
  319. zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  320. assert zip_response.status_code == 200
  321. assert zip_response.headers["content-type"] == "application/zip"
  322. @pytest.mark.asyncio
  323. @pytest.mark.integration
  324. async def test_import_project(self, async_client: AsyncClient):
  325. """Verify project can be imported with BOM items."""
  326. import_data = {
  327. "name": "Imported Project",
  328. "description": "Imported from JSON",
  329. "color": "#FF00FF",
  330. "target_count": 5,
  331. "target_parts_count": 25,
  332. "budget": 50.0,
  333. "bom_items": [
  334. {
  335. "name": "PTFE Tubes",
  336. "quantity_needed": 4,
  337. "quantity_acquired": 0,
  338. "unit_price": 2.50,
  339. "sourcing_url": "https://example.com",
  340. "stl_filename": None,
  341. "remarks": "Need 4mm ID",
  342. },
  343. ],
  344. }
  345. response = await async_client.post("/api/v1/projects/import", json=import_data)
  346. assert response.status_code == 200
  347. data = response.json()
  348. assert data["name"] == "Imported Project"
  349. assert data["description"] == "Imported from JSON"
  350. assert data["target_count"] == 5
  351. assert data["target_parts_count"] == 25
  352. assert data["budget"] == 50.0
  353. assert data["id"] > 0 # Has a valid ID
  354. # BOM stats should show 1 item imported
  355. assert data["stats"]["bom_total_items"] == 1
  356. @pytest.mark.asyncio
  357. @pytest.mark.integration
  358. async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
  359. """Verify project export includes linked folders."""
  360. from backend.app.models.library import LibraryFolder
  361. project = await project_factory(name="Project With Folder")
  362. # Create a linked folder
  363. folder = LibraryFolder(name="Project Files", project_id=project.id)
  364. db_session.add(folder)
  365. await db_session.commit()
  366. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  367. assert response.status_code == 200
  368. data = response.json()
  369. assert data["name"] == "Project With Folder"
  370. assert len(data["linked_folders"]) == 1
  371. assert data["linked_folders"][0]["name"] == "Project Files"
  372. @pytest.mark.asyncio
  373. @pytest.mark.integration
  374. async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
  375. """Verify project import accepts linked folders data."""
  376. import_data = {
  377. "name": "Imported With Folders",
  378. "linked_folders": [
  379. {"name": "STL Files"},
  380. {"name": "Documentation"},
  381. ],
  382. }
  383. # Import should succeed with linked_folders
  384. response = await async_client.post("/api/v1/projects/import", json=import_data)
  385. assert response.status_code == 200
  386. data = response.json()
  387. assert data["name"] == "Imported With Folders"
  388. assert data["id"] > 0
  389. @pytest.mark.asyncio
  390. @pytest.mark.integration
  391. async def test_import_project_from_json_file(self, async_client: AsyncClient):
  392. """Verify project can be imported from JSON file upload."""
  393. import io
  394. import json
  395. project_data = {
  396. "name": "File Uploaded Project",
  397. "description": "Imported from JSON file",
  398. "color": "#123456",
  399. }
  400. # Create a file-like object
  401. file_content = json.dumps(project_data).encode()
  402. files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
  403. response = await async_client.post("/api/v1/projects/import/file", files=files)
  404. assert response.status_code == 200
  405. data = response.json()
  406. assert data["name"] == "File Uploaded Project"
  407. assert data["description"] == "Imported from JSON file"
  408. @pytest.mark.asyncio
  409. @pytest.mark.integration
  410. async def test_import_project_from_zip_file(self, async_client: AsyncClient):
  411. """Verify project can be imported from ZIP file with files."""
  412. import io
  413. import json
  414. import zipfile
  415. project_data = {
  416. "name": "ZIP Imported Project",
  417. "description": "Imported from ZIP",
  418. "linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
  419. }
  420. # Create a ZIP file in memory
  421. zip_buffer = io.BytesIO()
  422. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  423. zf.writestr("project.json", json.dumps(project_data))
  424. zf.writestr("files/TestFolder/test.txt", "Hello World")
  425. zip_buffer.seek(0)
  426. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  427. response = await async_client.post("/api/v1/projects/import/file", files=files)
  428. assert response.status_code == 200
  429. data = response.json()
  430. assert data["name"] == "ZIP Imported Project"
  431. assert data["description"] == "Imported from ZIP"
  432. @pytest.mark.asyncio
  433. @pytest.mark.integration
  434. async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
  435. """Verify ZIP export contains actual files from linked folders."""
  436. import io
  437. import json
  438. import zipfile
  439. from pathlib import Path
  440. from backend.app.api.routes.library import get_library_dir
  441. from backend.app.models.library import LibraryFile, LibraryFolder
  442. project = await project_factory(name="Project With Files")
  443. # Create a linked folder with is_external fields
  444. folder = LibraryFolder(
  445. name="TestExportFolder",
  446. project_id=project.id,
  447. is_external=False,
  448. external_readonly=False,
  449. external_show_hidden=False,
  450. )
  451. db_session.add(folder)
  452. await db_session.flush()
  453. # Create a test file on disk
  454. library_dir = get_library_dir()
  455. folder_path = library_dir / "TestExportFolder"
  456. folder_path.mkdir(parents=True, exist_ok=True)
  457. test_file_path = folder_path / "test_export.txt"
  458. test_file_path.write_text("Export test content")
  459. # Create library file record
  460. lib_file = LibraryFile(
  461. folder_id=folder.id,
  462. filename="test_export.txt",
  463. file_path="TestExportFolder/test_export.txt",
  464. file_type="other",
  465. file_size=19,
  466. is_external=False,
  467. )
  468. db_session.add(lib_file)
  469. await db_session.commit()
  470. # Export as ZIP
  471. response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  472. assert response.status_code == 200
  473. assert response.headers["content-type"] == "application/zip"
  474. # Verify ZIP contents
  475. zip_buffer = io.BytesIO(response.content)
  476. with zipfile.ZipFile(zip_buffer, "r") as zf:
  477. assert "project.json" in zf.namelist()
  478. assert "files/TestExportFolder/test_export.txt" in zf.namelist()
  479. # Verify file content
  480. file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
  481. assert file_content == "Export test content"
  482. # Verify project.json
  483. project_data = json.loads(zf.read("project.json"))
  484. assert project_data["name"] == "Project With Files"
  485. # Cleanup
  486. test_file_path.unlink(missing_ok=True)
  487. folder_path.rmdir()
  488. @pytest.mark.asyncio
  489. @pytest.mark.integration
  490. async def test_import_invalid_file_type(self, async_client: AsyncClient):
  491. """Verify import rejects invalid file types."""
  492. import io
  493. files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
  494. response = await async_client.post("/api/v1/projects/import/file", files=files)
  495. assert response.status_code == 400
  496. assert "must be .zip or .json" in response.json()["detail"]
  497. @pytest.mark.asyncio
  498. @pytest.mark.integration
  499. async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
  500. """Verify import rejects ZIP without project.json."""
  501. import io
  502. import zipfile
  503. zip_buffer = io.BytesIO()
  504. with zipfile.ZipFile(zip_buffer, "w") as zf:
  505. zf.writestr("other.txt", "no project.json here")
  506. zip_buffer.seek(0)
  507. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  508. response = await async_client.post("/api/v1/projects/import/file", files=files)
  509. assert response.status_code == 400
  510. assert "project.json" in response.json()["detail"]
  511. @pytest.mark.asyncio
  512. @pytest.mark.integration
  513. async def test_import_invalid_json(self, async_client: AsyncClient):
  514. """Verify import rejects invalid JSON content."""
  515. import io
  516. files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
  517. response = await async_client.post("/api/v1/projects/import/file", files=files)
  518. assert response.status_code == 400
  519. assert "Invalid JSON" in response.json()["detail"]