test_projects_api.py 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888
  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 TestProjectUrlAndCoverImage:
  98. """Tests for #1155 — url field + cover image upload/get/delete."""
  99. @pytest.fixture
  100. async def project_factory(self, db_session):
  101. async def _create(**kwargs):
  102. from backend.app.models.project import Project
  103. defaults = {"name": "URL/Cover Project", "color": "#00ff00"}
  104. defaults.update(kwargs)
  105. project = Project(**defaults)
  106. db_session.add(project)
  107. await db_session.commit()
  108. await db_session.refresh(project)
  109. return project
  110. return _create
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_create_project_accepts_https_url(self, async_client: AsyncClient):
  114. response = await async_client.post(
  115. "/api/v1/projects/",
  116. json={"name": "With URL", "url": "https://makerworld.com/models/12345"},
  117. )
  118. assert response.status_code == 200
  119. body = response.json()
  120. assert body["url"] == "https://makerworld.com/models/12345"
  121. @pytest.mark.asyncio
  122. @pytest.mark.integration
  123. async def test_create_project_rejects_javascript_url(self, async_client: AsyncClient):
  124. # `<a href>` rendering would execute javascript: URLs — schema must reject.
  125. response = await async_client.post(
  126. "/api/v1/projects/",
  127. json={"name": "Hostile", "url": "javascript:alert(1)"},
  128. )
  129. assert response.status_code == 422
  130. @pytest.mark.asyncio
  131. @pytest.mark.integration
  132. async def test_create_project_rejects_data_url(self, async_client: AsyncClient):
  133. response = await async_client.post(
  134. "/api/v1/projects/",
  135. json={"name": "Hostile", "url": "data:text/html,<script>alert(1)</script>"},
  136. )
  137. assert response.status_code == 422
  138. @pytest.mark.asyncio
  139. @pytest.mark.integration
  140. async def test_patch_project_clears_url_when_explicitly_null(self, async_client: AsyncClient, project_factory):
  141. project = await project_factory(url="https://example.com")
  142. response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"url": None})
  143. assert response.status_code == 200
  144. assert response.json()["url"] is None
  145. @pytest.mark.asyncio
  146. @pytest.mark.integration
  147. async def test_upload_cover_image_then_serve_then_delete(self, async_client: AsyncClient, project_factory):
  148. project = await project_factory()
  149. # 1x1 PNG (smallest valid PNG bytes)
  150. png_bytes = bytes.fromhex(
  151. "89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
  152. "890000000d49444154789c63f80f00000100010000000000000049454e44ae42"
  153. "6082"
  154. )
  155. upload = await async_client.post(
  156. f"/api/v1/projects/{project.id}/cover-image",
  157. files={"file": ("cover.png", png_bytes, "image/png")},
  158. )
  159. assert upload.status_code == 200, upload.text
  160. body = upload.json()
  161. assert body["status"] == "success"
  162. assert body["filename"].endswith(".png")
  163. cover_filename = body["filename"]
  164. # GET should serve the bytes back
  165. served = await async_client.get(f"/api/v1/projects/{project.id}/cover-image")
  166. assert served.status_code == 200
  167. assert served.headers["content-type"] == "image/png"
  168. assert served.content == png_bytes
  169. # Project response should reflect the cover_image_filename field
  170. view = await async_client.get(f"/api/v1/projects/{project.id}")
  171. assert view.json()["cover_image_filename"] == cover_filename
  172. # DELETE should clear the field
  173. deleted = await async_client.delete(f"/api/v1/projects/{project.id}/cover-image")
  174. assert deleted.status_code == 200
  175. view2 = await async_client.get(f"/api/v1/projects/{project.id}")
  176. assert view2.json()["cover_image_filename"] is None
  177. # And subsequent GET should 404
  178. served2 = await async_client.get(f"/api/v1/projects/{project.id}/cover-image")
  179. assert served2.status_code == 404
  180. @pytest.mark.asyncio
  181. @pytest.mark.integration
  182. async def test_upload_cover_image_rejects_non_image(self, async_client: AsyncClient, project_factory):
  183. project = await project_factory()
  184. response = await async_client.post(
  185. f"/api/v1/projects/{project.id}/cover-image",
  186. files={"file": ("evil.exe", b"MZ\x00\x00", "application/octet-stream")},
  187. )
  188. assert response.status_code == 400
  189. @pytest.mark.integration
  190. def test_cover_image_get_uses_stream_token_gate(self):
  191. """Regression guard: GET /projects/{id}/cover-image MUST be gated by
  192. ``RequireCameraStreamTokenIfAuthEnabled`` (accepts ``?token=…`` query
  193. string) rather than by the bearer-token gate, because browsers can't
  194. attach an ``Authorization`` header to ``<img src>`` requests. Swapping
  195. back to the bearer gate would silently 401 every cover image when auth
  196. is enabled."""
  197. from fastapi.routing import APIRoute
  198. from backend.app.api.routes.projects import router
  199. # Find the GET cover-image route. The router exposes path/methods/
  200. # dependencies via APIRoute objects.
  201. cover_get = None
  202. for route in router.routes:
  203. if isinstance(route, APIRoute) and route.path.endswith("/cover-image") and "GET" in route.methods:
  204. cover_get = route
  205. break
  206. assert cover_get is not None, "GET cover-image route missing"
  207. # The route's dependant tree includes a Depends(require_camera_stream_token_if_auth_enabled())
  208. # — its `call` is the inner check function returned by that factory.
  209. # Walk the dependant tree and assert one of the dependencies came from
  210. # the stream-token factory, NOT from require_permission_if_auth_enabled.
  211. from backend.app.core.auth import (
  212. require_camera_stream_token_if_auth_enabled,
  213. )
  214. # The factory returns a fresh closure each call; the most reliable
  215. # signature is the qualified name of the function in the closure chain.
  216. expected_qualname = require_camera_stream_token_if_auth_enabled().__qualname__
  217. gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call]
  218. assert expected_qualname in gate_qualnames, (
  219. f"GET cover-image route is not gated by RequireCameraStreamTokenIfAuthEnabled. Found: {gate_qualnames}"
  220. )
  221. class TestProjectPartsTracking:
  222. """Tests for project parts tracking feature."""
  223. @pytest.fixture
  224. async def project_factory(self, db_session):
  225. """Factory to create test projects."""
  226. async def _create_project(**kwargs):
  227. from backend.app.models.project import Project
  228. defaults = {
  229. "name": "Parts Test Project",
  230. "description": "Test project",
  231. "color": "#FF0000",
  232. }
  233. defaults.update(kwargs)
  234. project = Project(**defaults)
  235. db_session.add(project)
  236. await db_session.commit()
  237. await db_session.refresh(project)
  238. return project
  239. return _create_project
  240. @pytest.fixture
  241. async def archive_factory(self, db_session):
  242. """Factory to create test archives."""
  243. async def _create_archive(**kwargs):
  244. from backend.app.models.archive import PrintArchive
  245. defaults = {
  246. "filename": "test.3mf",
  247. "file_path": "test/test.3mf",
  248. "file_size": 1000,
  249. "print_name": "Test Print",
  250. "status": "completed",
  251. "quantity": 1,
  252. }
  253. defaults.update(kwargs)
  254. archive = PrintArchive(**defaults)
  255. db_session.add(archive)
  256. await db_session.commit()
  257. await db_session.refresh(archive)
  258. return archive
  259. return _create_archive
  260. @pytest.mark.asyncio
  261. @pytest.mark.integration
  262. async def test_create_project_with_target_parts_count(self, async_client: AsyncClient):
  263. """Verify project can be created with target_parts_count."""
  264. data = {
  265. "name": "Parts Project",
  266. "target_count": 10, # 10 plates
  267. "target_parts_count": 50, # 50 parts total
  268. }
  269. response = await async_client.post("/api/v1/projects/", json=data)
  270. assert response.status_code == 200
  271. result = response.json()
  272. assert result["target_count"] == 10
  273. assert result["target_parts_count"] == 50
  274. @pytest.mark.asyncio
  275. @pytest.mark.integration
  276. async def test_update_project_target_parts_count(self, async_client: AsyncClient, project_factory, db_session):
  277. """Verify target_parts_count can be updated."""
  278. project = await project_factory()
  279. response = await async_client.patch(
  280. f"/api/v1/projects/{project.id}",
  281. json={"target_parts_count": 100},
  282. )
  283. assert response.status_code == 200
  284. assert response.json()["target_parts_count"] == 100
  285. @pytest.mark.asyncio
  286. @pytest.mark.integration
  287. async def test_project_parts_progress_calculation(
  288. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  289. ):
  290. """Verify parts progress is calculated from archive quantities."""
  291. # Create project with target of 20 parts
  292. project = await project_factory(target_parts_count=20)
  293. # Create archives with different quantities
  294. await archive_factory(project_id=project.id, quantity=3, status="completed") # 3 parts
  295. await archive_factory(project_id=project.id, quantity=5, status="completed") # 5 parts
  296. await archive_factory(project_id=project.id, quantity=2, status="completed") # 2 parts
  297. # Total: 10 parts completed out of 20 = 50%
  298. response = await async_client.get(f"/api/v1/projects/{project.id}")
  299. assert response.status_code == 200
  300. data = response.json()
  301. # Check stats
  302. assert data["stats"]["completed_prints"] == 10 # Sum of quantities
  303. assert data["stats"]["parts_progress_percent"] == 50.0 # 10/20 = 50%
  304. assert data["stats"]["remaining_parts"] == 10 # 20 - 10 = 10
  305. @pytest.mark.asyncio
  306. @pytest.mark.integration
  307. async def test_project_list_shows_parts_count(
  308. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  309. ):
  310. """Verify project list returns correct completed_count (parts sum)."""
  311. project = await project_factory(name="List Parts Project", target_parts_count=100)
  312. # Create archives with quantities
  313. await archive_factory(project_id=project.id, quantity=4, status="completed")
  314. await archive_factory(project_id=project.id, quantity=6, status="completed")
  315. # Total: 10 parts, 2 plates
  316. response = await async_client.get("/api/v1/projects/")
  317. assert response.status_code == 200
  318. data = response.json()
  319. # Find our project
  320. our_project = next((p for p in data if p["name"] == "List Parts Project"), None)
  321. assert our_project is not None
  322. assert our_project["archive_count"] == 2 # 2 plates
  323. assert our_project["completed_count"] == 10 # 10 parts (sum of quantities)
  324. assert our_project["target_parts_count"] == 100
  325. @pytest.mark.asyncio
  326. @pytest.mark.integration
  327. async def test_plates_vs_parts_progress(
  328. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  329. ):
  330. """Verify plates and parts progress are calculated separately."""
  331. # Project needs 5 plates producing 25 parts total (5 parts per plate)
  332. project = await project_factory(target_count=5, target_parts_count=25)
  333. # Complete 2 plates, each with 5 parts
  334. await archive_factory(project_id=project.id, quantity=5, status="completed")
  335. await archive_factory(project_id=project.id, quantity=5, status="completed")
  336. # Plates: 2/5 = 40%, Parts: 10/25 = 40%
  337. response = await async_client.get(f"/api/v1/projects/{project.id}")
  338. assert response.status_code == 200
  339. data = response.json()
  340. assert data["stats"]["total_archives"] == 2 # 2 plates
  341. assert data["stats"]["completed_prints"] == 10 # 10 parts
  342. assert data["stats"]["progress_percent"] == 40.0 # plates: 2/5
  343. assert data["stats"]["parts_progress_percent"] == 40.0 # parts: 10/25
  344. class TestProjectArchivedStatusNotCounted:
  345. """Tests for bug #630: archived files added to a project should not count as printed."""
  346. @pytest.fixture
  347. async def project_factory(self, db_session):
  348. """Factory to create test projects."""
  349. async def _create_project(**kwargs):
  350. from backend.app.models.project import Project
  351. defaults = {
  352. "name": "Archived Status Test",
  353. "description": "Test project",
  354. "color": "#FF0000",
  355. }
  356. defaults.update(kwargs)
  357. project = Project(**defaults)
  358. db_session.add(project)
  359. await db_session.commit()
  360. await db_session.refresh(project)
  361. return project
  362. return _create_project
  363. @pytest.fixture
  364. async def archive_factory(self, db_session):
  365. """Factory to create test archives."""
  366. async def _create_archive(**kwargs):
  367. from backend.app.models.archive import PrintArchive
  368. defaults = {
  369. "filename": "test.3mf",
  370. "file_path": "test/test.3mf",
  371. "file_size": 1000,
  372. "print_name": "Test Print",
  373. "status": "completed",
  374. "quantity": 1,
  375. }
  376. defaults.update(kwargs)
  377. archive = PrintArchive(**defaults)
  378. db_session.add(archive)
  379. await db_session.commit()
  380. await db_session.refresh(archive)
  381. return archive
  382. return _create_archive
  383. @pytest.mark.asyncio
  384. @pytest.mark.integration
  385. async def test_archived_files_not_counted_as_completed(
  386. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  387. ):
  388. """Archived files added to a project should not count in completed_prints stats."""
  389. project = await project_factory(target_parts_count=20)
  390. # 2 actually printed (completed), 3 just archived (not printed yet)
  391. await archive_factory(project_id=project.id, quantity=2, status="completed")
  392. await archive_factory(project_id=project.id, quantity=3, status="archived")
  393. await archive_factory(project_id=project.id, quantity=5, status="archived")
  394. response = await async_client.get(f"/api/v1/projects/{project.id}")
  395. assert response.status_code == 200
  396. data = response.json()
  397. # Only the completed archive should count
  398. assert data["stats"]["completed_prints"] == 2
  399. assert data["stats"]["parts_progress_percent"] == 10.0 # 2/20 = 10%
  400. assert data["stats"]["remaining_parts"] == 18
  401. @pytest.mark.asyncio
  402. @pytest.mark.integration
  403. async def test_archived_files_not_counted_in_project_list(
  404. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  405. ):
  406. """Project list endpoint should not count archived files as completed."""
  407. project = await project_factory(name="List Archived Test", target_parts_count=50)
  408. await archive_factory(project_id=project.id, quantity=4, status="completed")
  409. await archive_factory(project_id=project.id, quantity=6, status="archived")
  410. response = await async_client.get("/api/v1/projects/")
  411. assert response.status_code == 200
  412. data = response.json()
  413. our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
  414. assert our_project is not None
  415. assert our_project["completed_count"] == 4 # Only completed, not archived
  416. assert our_project["archive_count"] == 2 # Both archives exist as plates
  417. @pytest.mark.asyncio
  418. @pytest.mark.integration
  419. async def test_only_completed_status_counts(
  420. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  421. ):
  422. """Only 'completed' status should count in stats, not archived/failed/etc."""
  423. project = await project_factory(target_parts_count=100)
  424. await archive_factory(project_id=project.id, quantity=10, status="completed")
  425. await archive_factory(project_id=project.id, quantity=5, status="archived")
  426. await archive_factory(project_id=project.id, quantity=3, status="failed")
  427. await archive_factory(project_id=project.id, quantity=2, status="aborted")
  428. response = await async_client.get(f"/api/v1/projects/{project.id}")
  429. assert response.status_code == 200
  430. data = response.json()
  431. assert data["stats"]["completed_prints"] == 10 # Only "completed"
  432. assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of archives, not sum)
  433. assert data["stats"]["total_archives"] == 4 # All archives
  434. assert data["stats"]["total_items"] == 20 # Sum of all quantities
  435. class TestProjectArchivesAPI:
  436. """Tests for project-archive relationships."""
  437. @pytest.fixture
  438. async def project_factory(self, db_session):
  439. """Factory to create test projects."""
  440. async def _create_project(**kwargs):
  441. from backend.app.models.project import Project
  442. defaults = {
  443. "name": "Archive Test Project",
  444. "description": "Test project",
  445. "color": "#0000FF",
  446. }
  447. defaults.update(kwargs)
  448. project = Project(**defaults)
  449. db_session.add(project)
  450. await db_session.commit()
  451. await db_session.refresh(project)
  452. return project
  453. return _create_project
  454. @pytest.mark.asyncio
  455. @pytest.mark.integration
  456. async def test_get_project_with_archives(self, async_client: AsyncClient, project_factory, db_session):
  457. """Verify project can be retrieved with archive count."""
  458. project = await project_factory()
  459. response = await async_client.get(f"/api/v1/projects/{project.id}")
  460. assert response.status_code == 200
  461. # Project should have an archive count (may be 0)
  462. data = response.json()
  463. assert "name" in data
  464. class TestProjectExportImport:
  465. """Tests for project export/import functionality."""
  466. @pytest.fixture
  467. async def project_factory(self, db_session):
  468. """Factory to create test projects."""
  469. _counter = [0]
  470. async def _create_project(**kwargs):
  471. from backend.app.models.project import Project
  472. _counter[0] += 1
  473. counter = _counter[0]
  474. defaults = {
  475. "name": f"Export Test Project {counter}",
  476. "description": "Test project for export",
  477. "color": "#00FF00",
  478. }
  479. defaults.update(kwargs)
  480. project = Project(**defaults)
  481. db_session.add(project)
  482. await db_session.commit()
  483. await db_session.refresh(project)
  484. return project
  485. return _create_project
  486. @pytest.fixture
  487. async def bom_item_factory(self, db_session):
  488. """Factory to create test BOM items."""
  489. async def _create_bom_item(project_id: int, **kwargs):
  490. from backend.app.models.project_bom import ProjectBOMItem
  491. defaults = {
  492. "project_id": project_id,
  493. "name": "Test Part",
  494. "quantity_needed": 1,
  495. "quantity_acquired": 0,
  496. "sort_order": 0,
  497. }
  498. defaults.update(kwargs)
  499. item = ProjectBOMItem(**defaults)
  500. db_session.add(item)
  501. await db_session.commit()
  502. await db_session.refresh(item)
  503. return item
  504. return _create_bom_item
  505. @pytest.mark.asyncio
  506. @pytest.mark.integration
  507. async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
  508. """Verify project export includes BOM items."""
  509. project = await project_factory(
  510. name="Export Me",
  511. description="A test project",
  512. target_count=10,
  513. target_parts_count=50,
  514. budget=100.0,
  515. )
  516. # Add BOM items
  517. await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
  518. await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
  519. # Test JSON format export
  520. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  521. assert response.status_code == 200
  522. data = response.json()
  523. assert data["name"] == "Export Me"
  524. assert data["description"] == "A test project"
  525. assert data["target_count"] == 10
  526. assert data["target_parts_count"] == 50
  527. assert data["budget"] == 100.0
  528. assert len(data["bom_items"]) == 2
  529. # Check BOM items
  530. bom_names = [item["name"] for item in data["bom_items"]]
  531. assert "M3x8 Screws" in bom_names
  532. assert "Heat Inserts" in bom_names
  533. # Test ZIP format export (default)
  534. zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  535. assert zip_response.status_code == 200
  536. assert zip_response.headers["content-type"] == "application/zip"
  537. @pytest.mark.asyncio
  538. @pytest.mark.integration
  539. async def test_import_project(self, async_client: AsyncClient):
  540. """Verify project can be imported with BOM items."""
  541. import_data = {
  542. "name": "Imported Project",
  543. "description": "Imported from JSON",
  544. "color": "#FF00FF",
  545. "target_count": 5,
  546. "target_parts_count": 25,
  547. "budget": 50.0,
  548. "bom_items": [
  549. {
  550. "name": "PTFE Tubes",
  551. "quantity_needed": 4,
  552. "quantity_acquired": 0,
  553. "unit_price": 2.50,
  554. "sourcing_url": "https://example.com",
  555. "stl_filename": None,
  556. "remarks": "Need 4mm ID",
  557. },
  558. ],
  559. }
  560. response = await async_client.post("/api/v1/projects/import", json=import_data)
  561. assert response.status_code == 200
  562. data = response.json()
  563. assert data["name"] == "Imported Project"
  564. assert data["description"] == "Imported from JSON"
  565. assert data["target_count"] == 5
  566. assert data["target_parts_count"] == 25
  567. assert data["budget"] == 50.0
  568. assert data["id"] > 0 # Has a valid ID
  569. # BOM stats should show 1 item imported
  570. assert data["stats"]["bom_total_items"] == 1
  571. @pytest.mark.asyncio
  572. @pytest.mark.integration
  573. async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
  574. """Verify project export includes linked folders."""
  575. from backend.app.models.library import LibraryFolder
  576. project = await project_factory(name="Project With Folder")
  577. # Create a linked folder
  578. folder = LibraryFolder(name="Project Files", project_id=project.id)
  579. db_session.add(folder)
  580. await db_session.commit()
  581. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  582. assert response.status_code == 200
  583. data = response.json()
  584. assert data["name"] == "Project With Folder"
  585. assert len(data["linked_folders"]) == 1
  586. assert data["linked_folders"][0]["name"] == "Project Files"
  587. @pytest.mark.asyncio
  588. @pytest.mark.integration
  589. async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
  590. """Verify project import accepts linked folders data."""
  591. import_data = {
  592. "name": "Imported With Folders",
  593. "linked_folders": [
  594. {"name": "STL Files"},
  595. {"name": "Documentation"},
  596. ],
  597. }
  598. # Import should succeed with linked_folders
  599. response = await async_client.post("/api/v1/projects/import", json=import_data)
  600. assert response.status_code == 200
  601. data = response.json()
  602. assert data["name"] == "Imported With Folders"
  603. assert data["id"] > 0
  604. @pytest.mark.asyncio
  605. @pytest.mark.integration
  606. async def test_import_project_from_json_file(self, async_client: AsyncClient):
  607. """Verify project can be imported from JSON file upload."""
  608. import io
  609. import json
  610. project_data = {
  611. "name": "File Uploaded Project",
  612. "description": "Imported from JSON file",
  613. "color": "#123456",
  614. }
  615. # Create a file-like object
  616. file_content = json.dumps(project_data).encode()
  617. files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
  618. response = await async_client.post("/api/v1/projects/import/file", files=files)
  619. assert response.status_code == 200
  620. data = response.json()
  621. assert data["name"] == "File Uploaded Project"
  622. assert data["description"] == "Imported from JSON file"
  623. @pytest.mark.asyncio
  624. @pytest.mark.integration
  625. async def test_import_project_from_zip_file(self, async_client: AsyncClient):
  626. """Verify project can be imported from ZIP file with files."""
  627. import io
  628. import json
  629. import zipfile
  630. project_data = {
  631. "name": "ZIP Imported Project",
  632. "description": "Imported from ZIP",
  633. "linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
  634. }
  635. # Create a ZIP file in memory
  636. zip_buffer = io.BytesIO()
  637. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  638. zf.writestr("project.json", json.dumps(project_data))
  639. zf.writestr("files/TestFolder/test.txt", "Hello World")
  640. zip_buffer.seek(0)
  641. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  642. response = await async_client.post("/api/v1/projects/import/file", files=files)
  643. assert response.status_code == 200
  644. data = response.json()
  645. assert data["name"] == "ZIP Imported Project"
  646. assert data["description"] == "Imported from ZIP"
  647. @pytest.mark.asyncio
  648. @pytest.mark.integration
  649. async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
  650. """Verify ZIP export contains actual files from linked folders."""
  651. import io
  652. import json
  653. import zipfile
  654. from pathlib import Path
  655. from backend.app.api.routes.library import get_library_dir
  656. from backend.app.models.library import LibraryFile, LibraryFolder
  657. project = await project_factory(name="Project With Files")
  658. # Create a linked folder with is_external fields
  659. folder = LibraryFolder(
  660. name="TestExportFolder",
  661. project_id=project.id,
  662. is_external=False,
  663. external_readonly=False,
  664. external_show_hidden=False,
  665. )
  666. db_session.add(folder)
  667. await db_session.flush()
  668. # Create a test file on disk
  669. library_dir = get_library_dir()
  670. folder_path = library_dir / "TestExportFolder"
  671. folder_path.mkdir(parents=True, exist_ok=True)
  672. test_file_path = folder_path / "test_export.txt"
  673. test_file_path.write_text("Export test content")
  674. # Create library file record
  675. lib_file = LibraryFile(
  676. folder_id=folder.id,
  677. filename="test_export.txt",
  678. file_path="TestExportFolder/test_export.txt",
  679. file_type="other",
  680. file_size=19,
  681. is_external=False,
  682. )
  683. db_session.add(lib_file)
  684. await db_session.commit()
  685. # Export as ZIP
  686. response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  687. assert response.status_code == 200
  688. assert response.headers["content-type"] == "application/zip"
  689. # Verify ZIP contents
  690. zip_buffer = io.BytesIO(response.content)
  691. with zipfile.ZipFile(zip_buffer, "r") as zf:
  692. assert "project.json" in zf.namelist()
  693. assert "files/TestExportFolder/test_export.txt" in zf.namelist()
  694. # Verify file content
  695. file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
  696. assert file_content == "Export test content"
  697. # Verify project.json
  698. project_data = json.loads(zf.read("project.json"))
  699. assert project_data["name"] == "Project With Files"
  700. # Cleanup
  701. test_file_path.unlink(missing_ok=True)
  702. folder_path.rmdir()
  703. @pytest.mark.asyncio
  704. @pytest.mark.integration
  705. async def test_import_invalid_file_type(self, async_client: AsyncClient):
  706. """Verify import rejects invalid file types."""
  707. import io
  708. files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
  709. response = await async_client.post("/api/v1/projects/import/file", files=files)
  710. assert response.status_code == 400
  711. assert "must be .zip or .json" in response.json()["detail"]
  712. @pytest.mark.asyncio
  713. @pytest.mark.integration
  714. async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
  715. """Verify import rejects ZIP without project.json."""
  716. import io
  717. import zipfile
  718. zip_buffer = io.BytesIO()
  719. with zipfile.ZipFile(zip_buffer, "w") as zf:
  720. zf.writestr("other.txt", "no project.json here")
  721. zip_buffer.seek(0)
  722. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  723. response = await async_client.post("/api/v1/projects/import/file", files=files)
  724. assert response.status_code == 400
  725. assert "project.json" in response.json()["detail"]
  726. @pytest.mark.asyncio
  727. @pytest.mark.integration
  728. async def test_import_invalid_json(self, async_client: AsyncClient):
  729. """Verify import rejects invalid JSON content."""
  730. import io
  731. files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
  732. response = await async_client.post("/api/v1/projects/import/file", files=files)
  733. assert response.status_code == 400
  734. assert "Invalid JSON" in response.json()["detail"]