test_projects_api.py 86 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138
  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_query_token_gate(self):
  191. """Regression guard: GET /projects/{id}/cover-image MUST be gated by a
  192. dependency that accepts ``?token=…`` in the query string rather than by
  193. a header-only bearer gate, because browsers can't attach an
  194. ``Authorization`` header to ``<img src>`` requests. Swapping to a
  195. header-only gate would silently 401 every cover image when auth is
  196. enabled.
  197. The token type changed in #3025 -- the route took the camera-stream
  198. token until then, which made ``camera:view`` a prerequisite for seeing
  199. a project cover -- so this pins the media gate. What it is really
  200. asserting is unchanged: the credential has to fit in a URL."""
  201. from fastapi.routing import APIRoute
  202. from backend.app.api.routes.projects import router
  203. # Find the GET cover-image route. The router exposes path/methods/
  204. # dependencies via APIRoute objects.
  205. cover_get = None
  206. for route in router.routes:
  207. if isinstance(route, APIRoute) and route.path.endswith("/cover-image") and "GET" in route.methods:
  208. cover_get = route
  209. break
  210. assert cover_get is not None, "GET cover-image route missing"
  211. # The route's dependant tree includes a Depends(require_media_token_permission(...))
  212. # — its `call` is the inner check function returned by that factory.
  213. # Walk the dependant tree and assert one of the dependencies came from
  214. # the media-token factory, NOT from require_permission_if_auth_enabled.
  215. from backend.app.core.auth import require_media_token_permission
  216. from backend.app.core.permissions import Permission
  217. # The factory returns a fresh closure each call; the most reliable
  218. # signature is the qualified name of the function in the closure chain.
  219. expected_qualname = require_media_token_permission(Permission.PROJECTS_READ).__qualname__
  220. gate_qualnames = [dep.call.__qualname__ for dep in cover_get.dependant.dependencies if dep.call]
  221. assert expected_qualname in gate_qualnames, (
  222. f"GET cover-image route is not gated by a media-token dependency. Found: {gate_qualnames}"
  223. )
  224. class TestProjectPartsTracking:
  225. """Tests for project parts tracking feature."""
  226. @pytest.fixture
  227. async def project_factory(self, db_session):
  228. """Factory to create test projects."""
  229. async def _create_project(**kwargs):
  230. from backend.app.models.project import Project
  231. defaults = {
  232. "name": "Parts Test Project",
  233. "description": "Test project",
  234. "color": "#FF0000",
  235. }
  236. defaults.update(kwargs)
  237. project = Project(**defaults)
  238. db_session.add(project)
  239. await db_session.commit()
  240. await db_session.refresh(project)
  241. return project
  242. return _create_project
  243. @pytest.fixture
  244. async def archive_factory(self, db_session):
  245. """Factory to create a test archive plus a matching PrintLogEntry.
  246. Project stats aggregate from ``print_log_entries`` (#1593), so a
  247. test that only writes archives wouldn't exercise the production
  248. path — production always writes one log entry per run. The
  249. factory mirrors that: every archive whose status is anything other
  250. than ``"archived"`` (file shelved without printing) gets a log
  251. entry whose status matches the archive.
  252. """
  253. async def _create_archive(**kwargs):
  254. from backend.app.models.archive import PrintArchive
  255. from backend.app.models.print_log import PrintLogEntry
  256. defaults = {
  257. "filename": "test.3mf",
  258. "file_path": "test/test.3mf",
  259. "file_size": 1000,
  260. "print_name": "Test Print",
  261. "status": "completed",
  262. "quantity": 1,
  263. }
  264. defaults.update(kwargs)
  265. archive = PrintArchive(**defaults)
  266. db_session.add(archive)
  267. await db_session.commit()
  268. await db_session.refresh(archive)
  269. if archive.status != "archived":
  270. db_session.add(
  271. PrintLogEntry(
  272. archive_id=archive.id,
  273. print_name=archive.print_name,
  274. status=archive.status,
  275. )
  276. )
  277. await db_session.commit()
  278. return archive
  279. return _create_archive
  280. @pytest.mark.asyncio
  281. @pytest.mark.integration
  282. async def test_create_project_with_target_parts_count(self, async_client: AsyncClient):
  283. """Verify project can be created with target_parts_count."""
  284. data = {
  285. "name": "Parts Project",
  286. "target_count": 10, # 10 plates
  287. "target_parts_count": 50, # 50 parts total
  288. }
  289. response = await async_client.post("/api/v1/projects/", json=data)
  290. assert response.status_code == 200
  291. result = response.json()
  292. assert result["target_count"] == 10
  293. assert result["target_parts_count"] == 50
  294. @pytest.mark.asyncio
  295. @pytest.mark.integration
  296. async def test_update_project_target_parts_count(self, async_client: AsyncClient, project_factory, db_session):
  297. """Verify target_parts_count can be updated."""
  298. project = await project_factory()
  299. response = await async_client.patch(
  300. f"/api/v1/projects/{project.id}",
  301. json={"target_parts_count": 100},
  302. )
  303. assert response.status_code == 200
  304. assert response.json()["target_parts_count"] == 100
  305. @pytest.mark.asyncio
  306. @pytest.mark.integration
  307. async def test_project_parts_progress_calculation(
  308. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  309. ):
  310. """Verify parts progress is calculated from archive quantities."""
  311. # Create project with target of 20 parts
  312. project = await project_factory(target_parts_count=20)
  313. # Create archives with different quantities
  314. await archive_factory(project_id=project.id, quantity=3, status="completed") # 3 parts
  315. await archive_factory(project_id=project.id, quantity=5, status="completed") # 5 parts
  316. await archive_factory(project_id=project.id, quantity=2, status="completed") # 2 parts
  317. # Total: 10 parts completed out of 20 = 50%
  318. response = await async_client.get(f"/api/v1/projects/{project.id}")
  319. assert response.status_code == 200
  320. data = response.json()
  321. # Check stats
  322. assert data["stats"]["completed_prints"] == 10 # Sum of quantities
  323. assert data["stats"]["parts_progress_percent"] == 50.0 # 10/20 = 50%
  324. assert data["stats"]["remaining_parts"] == 10 # 20 - 10 = 10
  325. @pytest.mark.asyncio
  326. @pytest.mark.integration
  327. async def test_project_list_shows_parts_count(
  328. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  329. ):
  330. """Verify project list returns correct completed_count (parts sum)."""
  331. project = await project_factory(name="List Parts Project", target_parts_count=100)
  332. # Create archives with quantities
  333. await archive_factory(project_id=project.id, quantity=4, status="completed")
  334. await archive_factory(project_id=project.id, quantity=6, status="completed")
  335. # Total: 10 parts, 2 plates
  336. response = await async_client.get("/api/v1/projects/")
  337. assert response.status_code == 200
  338. data = response.json()
  339. # Find our project
  340. our_project = next((p for p in data if p["name"] == "List Parts Project"), None)
  341. assert our_project is not None
  342. assert our_project["archive_count"] == 2 # 2 plates
  343. assert our_project["completed_count"] == 10 # 10 parts (sum of quantities)
  344. assert our_project["target_parts_count"] == 100
  345. @pytest.mark.asyncio
  346. @pytest.mark.integration
  347. async def test_plates_vs_parts_progress(
  348. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  349. ):
  350. """Verify plates and parts progress are calculated separately."""
  351. # Project needs 5 plates producing 25 parts total (5 parts per plate)
  352. project = await project_factory(target_count=5, target_parts_count=25)
  353. # Complete 2 plates, each with 5 parts
  354. await archive_factory(project_id=project.id, quantity=5, status="completed")
  355. await archive_factory(project_id=project.id, quantity=5, status="completed")
  356. # Plates: 2/5 = 40%, Parts: 10/25 = 40%
  357. response = await async_client.get(f"/api/v1/projects/{project.id}")
  358. assert response.status_code == 200
  359. data = response.json()
  360. assert data["stats"]["total_archives"] == 2 # 2 plates
  361. assert data["stats"]["completed_prints"] == 10 # 10 parts
  362. assert data["stats"]["progress_percent"] == 40.0 # plates: 2/5
  363. assert data["stats"]["parts_progress_percent"] == 40.0 # parts: 10/25
  364. class TestProjectArchivedStatusNotCounted:
  365. """Tests for bug #630: archived files added to a project should not count as printed."""
  366. @pytest.fixture
  367. async def project_factory(self, db_session):
  368. """Factory to create test projects."""
  369. async def _create_project(**kwargs):
  370. from backend.app.models.project import Project
  371. defaults = {
  372. "name": "Archived Status Test",
  373. "description": "Test project",
  374. "color": "#FF0000",
  375. }
  376. defaults.update(kwargs)
  377. project = Project(**defaults)
  378. db_session.add(project)
  379. await db_session.commit()
  380. await db_session.refresh(project)
  381. return project
  382. return _create_project
  383. @pytest.fixture
  384. async def archive_factory(self, db_session):
  385. """Factory to create a test archive plus a matching PrintLogEntry —
  386. see TestProjectPartsTracking.archive_factory for rationale (#1593)."""
  387. async def _create_archive(**kwargs):
  388. from backend.app.models.archive import PrintArchive
  389. from backend.app.models.print_log import PrintLogEntry
  390. defaults = {
  391. "filename": "test.3mf",
  392. "file_path": "test/test.3mf",
  393. "file_size": 1000,
  394. "print_name": "Test Print",
  395. "status": "completed",
  396. "quantity": 1,
  397. }
  398. defaults.update(kwargs)
  399. archive = PrintArchive(**defaults)
  400. db_session.add(archive)
  401. await db_session.commit()
  402. await db_session.refresh(archive)
  403. if archive.status != "archived":
  404. db_session.add(
  405. PrintLogEntry(
  406. archive_id=archive.id,
  407. print_name=archive.print_name,
  408. status=archive.status,
  409. )
  410. )
  411. await db_session.commit()
  412. return archive
  413. return _create_archive
  414. @pytest.mark.asyncio
  415. @pytest.mark.integration
  416. async def test_archived_files_not_counted_as_completed(
  417. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  418. ):
  419. """Archived files added to a project should not count in completed_prints stats."""
  420. project = await project_factory(target_parts_count=20)
  421. # 2 actually printed (completed), 3 just archived (not printed yet)
  422. await archive_factory(project_id=project.id, quantity=2, status="completed")
  423. await archive_factory(project_id=project.id, quantity=3, status="archived")
  424. await archive_factory(project_id=project.id, quantity=5, status="archived")
  425. response = await async_client.get(f"/api/v1/projects/{project.id}")
  426. assert response.status_code == 200
  427. data = response.json()
  428. # Only the completed archive should count
  429. assert data["stats"]["completed_prints"] == 2
  430. assert data["stats"]["parts_progress_percent"] == 10.0 # 2/20 = 10%
  431. assert data["stats"]["remaining_parts"] == 18
  432. @pytest.mark.asyncio
  433. @pytest.mark.integration
  434. async def test_archived_files_not_counted_in_project_list(
  435. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  436. ):
  437. """Project list endpoint should not count archived files as completed."""
  438. project = await project_factory(name="List Archived Test", target_parts_count=50)
  439. await archive_factory(project_id=project.id, quantity=4, status="completed")
  440. await archive_factory(project_id=project.id, quantity=6, status="archived")
  441. response = await async_client.get("/api/v1/projects/")
  442. assert response.status_code == 200
  443. data = response.json()
  444. our_project = next((p for p in data if p["name"] == "List Archived Test"), None)
  445. assert our_project is not None
  446. assert our_project["completed_count"] == 4 # Only completed, not archived
  447. # Post-#1593: archive_count is "print runs", not "files attached". An
  448. # ``archived``-status file (shelved without printing) has no
  449. # PrintLogEntry and doesn't count — only the actual printed run does.
  450. assert our_project["archive_count"] == 1
  451. @pytest.mark.asyncio
  452. @pytest.mark.integration
  453. async def test_only_completed_status_counts(
  454. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  455. ):
  456. """Only 'completed' status should count in stats, not archived/failed/etc."""
  457. project = await project_factory(target_parts_count=100)
  458. await archive_factory(project_id=project.id, quantity=10, status="completed")
  459. await archive_factory(project_id=project.id, quantity=5, status="archived")
  460. await archive_factory(project_id=project.id, quantity=3, status="failed")
  461. await archive_factory(project_id=project.id, quantity=2, status="aborted")
  462. response = await async_client.get(f"/api/v1/projects/{project.id}")
  463. assert response.status_code == 200
  464. data = response.json()
  465. assert data["stats"]["completed_prints"] == 10 # Only "completed"
  466. assert data["stats"]["failed_prints"] == 2 # failed + aborted (count of runs)
  467. # Post-#1593: total_archives counts runs from print_log_entries, not
  468. # files. The ``archived`` row is a shelved file with no run, so it
  469. # contributes 0; the other three (completed, failed, aborted) each
  470. # produced a run.
  471. assert data["stats"]["total_archives"] == 3
  472. # total_items sums quantity per run: 10 (completed) + 3 (failed) + 2 (aborted) = 15
  473. assert data["stats"]["total_items"] == 15
  474. class TestProjectStatsPerRun:
  475. """Project stats aggregate per-run from ``print_log_entries`` so
  476. reprints and multi-plate prints count every run (#1593). Pre-fix the
  477. stats counted ``print_archives`` (one row per file), so 3 reprints of
  478. one file showed as 1 job with plate-1-only filament/time/cost.
  479. """
  480. @pytest.fixture
  481. async def project_factory(self, db_session):
  482. async def _create_project(**kwargs):
  483. from backend.app.models.project import Project
  484. defaults = {"name": "Per-Run Stats Project", "color": "#FF0000"}
  485. defaults.update(kwargs)
  486. project = Project(**defaults)
  487. db_session.add(project)
  488. await db_session.commit()
  489. await db_session.refresh(project)
  490. return project
  491. return _create_project
  492. @pytest.fixture
  493. async def archive_with_runs(self, db_session):
  494. """Build a single archive + N PrintLogEntry rows.
  495. Models the reporter's case: one source file (archive) is reprinted
  496. N times, each run with its own duration / filament / cost.
  497. """
  498. async def _create(*, project_id: int, runs: list[dict], archive_status: str = "completed", quantity: int = 1):
  499. from backend.app.models.archive import PrintArchive
  500. from backend.app.models.print_log import PrintLogEntry
  501. archive = PrintArchive(
  502. filename="reprinted.3mf",
  503. file_path="test/reprinted.3mf",
  504. file_size=1000,
  505. print_name="Reprinted Print",
  506. status=archive_status,
  507. quantity=quantity,
  508. project_id=project_id,
  509. )
  510. db_session.add(archive)
  511. await db_session.commit()
  512. await db_session.refresh(archive)
  513. for run in runs:
  514. db_session.add(
  515. PrintLogEntry(
  516. archive_id=archive.id,
  517. print_name=archive.print_name,
  518. status=run.get("status", "completed"),
  519. duration_seconds=run.get("duration_seconds"),
  520. filament_used_grams=run.get("filament_used_grams"),
  521. cost=run.get("cost"),
  522. energy_kwh=run.get("energy_kwh"),
  523. energy_cost=run.get("energy_cost"),
  524. )
  525. )
  526. await db_session.commit()
  527. return archive
  528. return _create
  529. @pytest.mark.asyncio
  530. @pytest.mark.integration
  531. async def test_three_reprints_count_as_three_jobs_with_summed_totals(
  532. self, async_client: AsyncClient, project_factory, archive_with_runs
  533. ):
  534. """Reporter's case: 3 runs of one multi-plate file should report
  535. 3 jobs and summed time / filament / cost — pre-fix it reported 1
  536. job with plate-1-only totals."""
  537. project = await project_factory()
  538. await archive_with_runs(
  539. project_id=project.id,
  540. runs=[
  541. {"duration_seconds": 7140, "filament_used_grams": 19.2, "cost": 0.40},
  542. {"duration_seconds": 6000, "filament_used_grams": 20.0, "cost": 0.40},
  543. {"duration_seconds": 6300, "filament_used_grams": 18.8, "cost": 0.40},
  544. ],
  545. )
  546. response = await async_client.get(f"/api/v1/projects/{project.id}")
  547. assert response.status_code == 200
  548. stats = response.json()["stats"]
  549. assert stats["total_archives"] == 3, "3 runs must show as 3 jobs"
  550. assert stats["completed_prints"] == 3, "Each run with quantity=1 contributes 1 part"
  551. assert stats["total_filament_grams"] == round(19.2 + 20.0 + 18.8, 2)
  552. assert stats["total_print_time_hours"] == round((7140 + 6000 + 6300) / 3600, 2)
  553. # Cost rounds at 2 decimals — 3 * 0.40 = 1.20
  554. assert stats["estimated_cost"] == 1.20
  555. @pytest.mark.asyncio
  556. @pytest.mark.integration
  557. async def test_orphan_log_entries_do_not_bleed_into_projects(
  558. self, async_client: AsyncClient, project_factory, db_session
  559. ):
  560. """Log rows whose ``archive_id`` is NULL (archive deleted via
  561. ON DELETE SET NULL) must not leak into any project — the inner
  562. join filters them out by construction."""
  563. from backend.app.models.print_log import PrintLogEntry
  564. project = await project_factory()
  565. # Orphan log entries — no archive_id.
  566. for _ in range(5):
  567. db_session.add(
  568. PrintLogEntry(
  569. archive_id=None,
  570. print_name="Orphan Run",
  571. status="completed",
  572. duration_seconds=3600,
  573. filament_used_grams=20.0,
  574. cost=0.5,
  575. )
  576. )
  577. await db_session.commit()
  578. response = await async_client.get(f"/api/v1/projects/{project.id}")
  579. assert response.status_code == 200
  580. stats = response.json()["stats"]
  581. # None of the orphan rows are attributable to this project.
  582. assert stats["total_archives"] == 0
  583. assert stats["completed_prints"] == 0
  584. assert stats["total_filament_grams"] == 0
  585. assert stats["total_print_time_hours"] == 0
  586. assert stats["estimated_cost"] == 0
  587. @pytest.mark.asyncio
  588. @pytest.mark.integration
  589. async def test_mixed_run_outcomes_split_completed_and_failed(
  590. self, async_client: AsyncClient, project_factory, archive_with_runs
  591. ):
  592. """A multi-run archive with mixed outcomes splits cleanly between
  593. completed_prints (per-quantity) and failed_prints (per-run)."""
  594. project = await project_factory()
  595. await archive_with_runs(
  596. project_id=project.id,
  597. quantity=2,
  598. runs=[
  599. {"status": "completed", "filament_used_grams": 30.0},
  600. {"status": "completed", "filament_used_grams": 30.0},
  601. {"status": "failed", "filament_used_grams": 5.0},
  602. {"status": "aborted", "filament_used_grams": 2.0},
  603. ],
  604. )
  605. response = await async_client.get(f"/api/v1/projects/{project.id}")
  606. stats = response.json()["stats"]
  607. assert stats["total_archives"] == 4
  608. # 2 completed runs × quantity=2 each = 4 parts
  609. assert stats["completed_prints"] == 4
  610. # 2 failure runs (failed + aborted) count as 2, not 2*quantity
  611. assert stats["failed_prints"] == 2
  612. # All 4 runs contribute filament: 30 + 30 + 5 + 2 = 67
  613. assert stats["total_filament_grams"] == 67.0
  614. @pytest.mark.asyncio
  615. @pytest.mark.integration
  616. async def test_quick_stats_in_list_view_agree_with_per_project_stats(
  617. self, async_client: AsyncClient, project_factory, archive_with_runs
  618. ):
  619. """The /projects list view's quick stats must agree with
  620. /projects/{id}'s detailed stats — both come from the same per-run
  621. aggregation."""
  622. project = await project_factory(name="Quick-Stats Alignment")
  623. await archive_with_runs(
  624. project_id=project.id,
  625. quantity=1,
  626. runs=[
  627. {"status": "completed"},
  628. {"status": "completed"},
  629. {"status": "failed"},
  630. ],
  631. )
  632. list_resp = await async_client.get("/api/v1/projects/")
  633. ours = next(p for p in list_resp.json() if p["name"] == "Quick-Stats Alignment")
  634. assert ours["archive_count"] == 3
  635. assert ours["completed_count"] == 2
  636. assert ours["failed_count"] == 1
  637. class TestProjectArchivesAPI:
  638. """Tests for project-archive relationships."""
  639. @pytest.fixture
  640. async def project_factory(self, db_session):
  641. """Factory to create test projects."""
  642. async def _create_project(**kwargs):
  643. from backend.app.models.project import Project
  644. defaults = {
  645. "name": "Archive Test Project",
  646. "description": "Test project",
  647. "color": "#0000FF",
  648. }
  649. defaults.update(kwargs)
  650. project = Project(**defaults)
  651. db_session.add(project)
  652. await db_session.commit()
  653. await db_session.refresh(project)
  654. return project
  655. return _create_project
  656. @pytest.mark.asyncio
  657. @pytest.mark.integration
  658. async def test_get_project_with_archives(self, async_client: AsyncClient, project_factory, db_session):
  659. """Verify project can be retrieved with archive count."""
  660. project = await project_factory()
  661. response = await async_client.get(f"/api/v1/projects/{project.id}")
  662. assert response.status_code == 200
  663. # Project should have an archive count (may be 0)
  664. data = response.json()
  665. assert "name" in data
  666. @pytest.mark.asyncio
  667. @pytest.mark.integration
  668. async def test_list_archives_in_project_returns_archives_with_creator(
  669. self, async_client: AsyncClient, project_factory, db_session
  670. ):
  671. """``GET /projects/{id}/archives`` must eagerly load both the project AND
  672. the creator User. Without selectinload(created_by) the response
  673. converter triggers a lazy attribute load on a closed async session
  674. and the request 500s with MissingGreenlet — exactly what was reported
  675. the moment a user with auth enabled (so archives carry created_by_id)
  676. opened a project view.
  677. """
  678. from backend.app.models.archive import PrintArchive
  679. from backend.app.models.user import User
  680. # Seed: a user (the eventual creator) and a project owning two archives,
  681. # one with created_by_id set, one without.
  682. creator = User(
  683. username="archive-creator",
  684. password_hash="x",
  685. role="user",
  686. is_active=True,
  687. )
  688. db_session.add(creator)
  689. await db_session.commit()
  690. await db_session.refresh(creator)
  691. project = await project_factory(name="Project Archives Smoke")
  692. attributed = PrintArchive(
  693. filename="attributed.3mf",
  694. file_path="x/attributed.3mf",
  695. file_size=2048,
  696. print_name="Attributed Print",
  697. status="completed",
  698. quantity=1,
  699. project_id=project.id,
  700. created_by_id=creator.id,
  701. )
  702. anonymous = PrintArchive(
  703. filename="anon.3mf",
  704. file_path="x/anon.3mf",
  705. file_size=2048,
  706. print_name="Anonymous Print",
  707. status="completed",
  708. quantity=1,
  709. project_id=project.id,
  710. created_by_id=None,
  711. )
  712. db_session.add_all([attributed, anonymous])
  713. await db_session.commit()
  714. response = await async_client.get(f"/api/v1/projects/{project.id}/archives?limit=100&offset=0")
  715. assert response.status_code == 200, f"Expected 200, got {response.status_code} body={response.text}"
  716. rows = response.json()
  717. assert len(rows) == 2
  718. # Both archive shapes serialise — the attributed one surfaces the
  719. # creator username (proving the eager-load worked) and the anonymous
  720. # one stays None without exploding.
  721. by_filename = {r["filename"]: r for r in rows}
  722. assert by_filename["attributed.3mf"]["created_by_username"] == "archive-creator"
  723. assert by_filename["attributed.3mf"]["created_by_id"] == creator.id
  724. assert by_filename["anon.3mf"]["created_by_username"] is None
  725. assert by_filename["anon.3mf"]["created_by_id"] is None
  726. class TestProjectExportImport:
  727. """Tests for project export/import functionality."""
  728. @pytest.fixture
  729. async def project_factory(self, db_session):
  730. """Factory to create test projects."""
  731. _counter = [0]
  732. async def _create_project(**kwargs):
  733. from backend.app.models.project import Project
  734. _counter[0] += 1
  735. counter = _counter[0]
  736. defaults = {
  737. "name": f"Export Test Project {counter}",
  738. "description": "Test project for export",
  739. "color": "#00FF00",
  740. }
  741. defaults.update(kwargs)
  742. project = Project(**defaults)
  743. db_session.add(project)
  744. await db_session.commit()
  745. await db_session.refresh(project)
  746. return project
  747. return _create_project
  748. @pytest.fixture
  749. async def bom_item_factory(self, db_session):
  750. """Factory to create test BOM items."""
  751. async def _create_bom_item(project_id: int, **kwargs):
  752. from backend.app.models.project_bom import ProjectBOMItem
  753. defaults = {
  754. "project_id": project_id,
  755. "name": "Test Part",
  756. "quantity_needed": 1,
  757. "quantity_acquired": 0,
  758. "sort_order": 0,
  759. }
  760. defaults.update(kwargs)
  761. item = ProjectBOMItem(**defaults)
  762. db_session.add(item)
  763. await db_session.commit()
  764. await db_session.refresh(item)
  765. return item
  766. return _create_bom_item
  767. @pytest.mark.asyncio
  768. @pytest.mark.integration
  769. async def test_export_project(self, async_client: AsyncClient, project_factory, bom_item_factory, db_session):
  770. """Verify project export includes BOM items."""
  771. project = await project_factory(
  772. name="Export Me",
  773. description="A test project",
  774. target_count=10,
  775. target_parts_count=50,
  776. budget=100.0,
  777. )
  778. # Add BOM items
  779. await bom_item_factory(project.id, name="M3x8 Screws", quantity_needed=20, unit_price=0.10)
  780. await bom_item_factory(project.id, name="Heat Inserts", quantity_needed=10, unit_price=0.25)
  781. # Test JSON format export
  782. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  783. assert response.status_code == 200
  784. data = response.json()
  785. assert data["name"] == "Export Me"
  786. assert data["description"] == "A test project"
  787. assert data["target_count"] == 10
  788. assert data["target_parts_count"] == 50
  789. assert data["budget"] == 100.0
  790. assert len(data["bom_items"]) == 2
  791. # Check BOM items
  792. bom_names = [item["name"] for item in data["bom_items"]]
  793. assert "M3x8 Screws" in bom_names
  794. assert "Heat Inserts" in bom_names
  795. # Test ZIP format export (default)
  796. zip_response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  797. assert zip_response.status_code == 200
  798. assert zip_response.headers["content-type"] == "application/zip"
  799. @pytest.mark.asyncio
  800. @pytest.mark.integration
  801. async def test_import_project(self, async_client: AsyncClient):
  802. """Verify project can be imported with BOM items."""
  803. import_data = {
  804. "name": "Imported Project",
  805. "description": "Imported from JSON",
  806. "color": "#FF00FF",
  807. "target_count": 5,
  808. "target_parts_count": 25,
  809. "budget": 50.0,
  810. "bom_items": [
  811. {
  812. "name": "PTFE Tubes",
  813. "quantity_needed": 4,
  814. "quantity_acquired": 0,
  815. "unit_price": 2.50,
  816. "sourcing_url": "https://example.com",
  817. "stl_filename": None,
  818. "remarks": "Need 4mm ID",
  819. },
  820. ],
  821. }
  822. response = await async_client.post("/api/v1/projects/import", json=import_data)
  823. assert response.status_code == 200
  824. data = response.json()
  825. assert data["name"] == "Imported Project"
  826. assert data["description"] == "Imported from JSON"
  827. assert data["target_count"] == 5
  828. assert data["target_parts_count"] == 25
  829. assert data["budget"] == 50.0
  830. assert data["id"] > 0 # Has a valid ID
  831. # BOM stats should show 1 item imported
  832. assert data["stats"]["bom_total_items"] == 1
  833. @pytest.mark.asyncio
  834. @pytest.mark.integration
  835. async def test_export_project_with_linked_folder(self, async_client: AsyncClient, project_factory, db_session):
  836. """Verify project export includes linked folders."""
  837. from backend.app.models.library import LibraryFolder
  838. project = await project_factory(name="Project With Folder")
  839. # Create a linked folder
  840. folder = LibraryFolder(name="Project Files", project_id=project.id)
  841. db_session.add(folder)
  842. await db_session.commit()
  843. response = await async_client.get(f"/api/v1/projects/{project.id}/export?format=json")
  844. assert response.status_code == 200
  845. data = response.json()
  846. assert data["name"] == "Project With Folder"
  847. assert len(data["linked_folders"]) == 1
  848. assert data["linked_folders"][0]["name"] == "Project Files"
  849. @pytest.mark.asyncio
  850. @pytest.mark.integration
  851. async def test_import_project_with_linked_folder(self, async_client: AsyncClient):
  852. """Verify project import accepts linked folders data."""
  853. import_data = {
  854. "name": "Imported With Folders",
  855. "linked_folders": [
  856. {"name": "STL Files"},
  857. {"name": "Documentation"},
  858. ],
  859. }
  860. # Import should succeed with linked_folders
  861. response = await async_client.post("/api/v1/projects/import", json=import_data)
  862. assert response.status_code == 200
  863. data = response.json()
  864. assert data["name"] == "Imported With Folders"
  865. assert data["id"] > 0
  866. @pytest.mark.asyncio
  867. @pytest.mark.integration
  868. async def test_import_project_from_json_file(self, async_client: AsyncClient):
  869. """Verify project can be imported from JSON file upload."""
  870. import io
  871. import json
  872. project_data = {
  873. "name": "File Uploaded Project",
  874. "description": "Imported from JSON file",
  875. "color": "#123456",
  876. }
  877. # Create a file-like object
  878. file_content = json.dumps(project_data).encode()
  879. files = {"file": ("project.json", io.BytesIO(file_content), "application/json")}
  880. response = await async_client.post("/api/v1/projects/import/file", files=files)
  881. assert response.status_code == 200
  882. data = response.json()
  883. assert data["name"] == "File Uploaded Project"
  884. assert data["description"] == "Imported from JSON file"
  885. @pytest.mark.asyncio
  886. @pytest.mark.integration
  887. async def test_import_project_from_zip_file(self, async_client: AsyncClient):
  888. """Verify project can be imported from ZIP file with files."""
  889. import io
  890. import json
  891. import zipfile
  892. project_data = {
  893. "name": "ZIP Imported Project",
  894. "description": "Imported from ZIP",
  895. "linked_folders": [{"name": "TestFolder", "files": [{"filename": "test.txt"}]}],
  896. }
  897. # Create a ZIP file in memory
  898. zip_buffer = io.BytesIO()
  899. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  900. zf.writestr("project.json", json.dumps(project_data))
  901. zf.writestr("files/TestFolder/test.txt", "Hello World")
  902. zip_buffer.seek(0)
  903. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  904. response = await async_client.post("/api/v1/projects/import/file", files=files)
  905. assert response.status_code == 200
  906. data = response.json()
  907. assert data["name"] == "ZIP Imported Project"
  908. assert data["description"] == "Imported from ZIP"
  909. @pytest.mark.asyncio
  910. @pytest.mark.integration
  911. async def test_export_zip_contains_files(self, async_client: AsyncClient, project_factory, db_session):
  912. """Verify ZIP export contains actual files from linked folders."""
  913. import io
  914. import json
  915. import zipfile
  916. from pathlib import Path
  917. from backend.app.api.routes.library import get_library_dir
  918. from backend.app.models.library import LibraryFile, LibraryFolder
  919. project = await project_factory(name="Project With Files")
  920. # Create a linked folder with is_external fields
  921. folder = LibraryFolder(
  922. name="TestExportFolder",
  923. project_id=project.id,
  924. is_external=False,
  925. external_readonly=False,
  926. external_show_hidden=False,
  927. )
  928. db_session.add(folder)
  929. await db_session.flush()
  930. # Create a test file on disk
  931. library_dir = get_library_dir()
  932. folder_path = library_dir / "TestExportFolder"
  933. folder_path.mkdir(parents=True, exist_ok=True)
  934. test_file_path = folder_path / "test_export.txt"
  935. test_file_path.write_text("Export test content")
  936. # Create library file record
  937. lib_file = LibraryFile(
  938. folder_id=folder.id,
  939. filename="test_export.txt",
  940. file_path="TestExportFolder/test_export.txt",
  941. file_type="other",
  942. file_size=19,
  943. is_external=False,
  944. )
  945. db_session.add(lib_file)
  946. await db_session.commit()
  947. # Export as ZIP
  948. response = await async_client.get(f"/api/v1/projects/{project.id}/export")
  949. assert response.status_code == 200
  950. assert response.headers["content-type"] == "application/zip"
  951. # Verify ZIP contents
  952. zip_buffer = io.BytesIO(response.content)
  953. with zipfile.ZipFile(zip_buffer, "r") as zf:
  954. assert "project.json" in zf.namelist()
  955. assert "files/TestExportFolder/test_export.txt" in zf.namelist()
  956. # Verify file content
  957. file_content = zf.read("files/TestExportFolder/test_export.txt").decode()
  958. assert file_content == "Export test content"
  959. # Verify project.json
  960. project_data = json.loads(zf.read("project.json"))
  961. assert project_data["name"] == "Project With Files"
  962. # Cleanup
  963. test_file_path.unlink(missing_ok=True)
  964. folder_path.rmdir()
  965. @pytest.mark.asyncio
  966. @pytest.mark.integration
  967. async def test_import_invalid_file_type(self, async_client: AsyncClient):
  968. """Verify import rejects invalid file types."""
  969. import io
  970. files = {"file": ("project.txt", io.BytesIO(b"invalid"), "text/plain")}
  971. response = await async_client.post("/api/v1/projects/import/file", files=files)
  972. assert response.status_code == 400
  973. assert "must be .zip or .json" in response.json()["detail"]
  974. @pytest.mark.asyncio
  975. @pytest.mark.integration
  976. async def test_import_zip_missing_project_json(self, async_client: AsyncClient):
  977. """Verify import rejects ZIP without project.json."""
  978. import io
  979. import zipfile
  980. zip_buffer = io.BytesIO()
  981. with zipfile.ZipFile(zip_buffer, "w") as zf:
  982. zf.writestr("other.txt", "no project.json here")
  983. zip_buffer.seek(0)
  984. files = {"file": ("project.zip", zip_buffer, "application/zip")}
  985. response = await async_client.post("/api/v1/projects/import/file", files=files)
  986. assert response.status_code == 400
  987. assert "project.json" in response.json()["detail"]
  988. @pytest.mark.asyncio
  989. @pytest.mark.integration
  990. async def test_import_invalid_json(self, async_client: AsyncClient):
  991. """Verify import rejects invalid JSON content."""
  992. import io
  993. files = {"file": ("project.json", io.BytesIO(b"not valid json"), "application/json")}
  994. response = await async_client.post("/api/v1/projects/import/file", files=files)
  995. assert response.status_code == 400
  996. assert "Invalid JSON" in response.json()["detail"]
  997. @pytest.mark.asyncio
  998. @pytest.mark.integration
  999. async def test_import_rejects_absolute_path_in_folder_name(self, async_client: AsyncClient, tmp_path):
  1000. """Absolute paths in `linked_folders[*].name` must not escape library_dir.
  1001. Verbatim shape from the upstream advisory: attacker sets folder name to
  1002. an absolute path, expecting Python's ``Path("/lib") / "/anywhere"`` to
  1003. collapse to ``Path("/anywhere")`` and let the next file write land
  1004. outside the library directory.
  1005. """
  1006. import io
  1007. import json
  1008. import zipfile
  1009. target_outside = tmp_path / "outside" / "owned"
  1010. # Build a ZIP whose folder name points outside library_dir entirely.
  1011. zip_buffer = io.BytesIO()
  1012. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1013. zf.writestr(
  1014. "project.json",
  1015. json.dumps(
  1016. {
  1017. "name": "innocent",
  1018. "linked_folders": [{"name": str(target_outside)}],
  1019. }
  1020. ),
  1021. )
  1022. zf.writestr(f"files/{target_outside}/evil.pth", b"import os; os.system('echo pwned > /tmp/owned')\n")
  1023. zip_buffer.seek(0)
  1024. files = {"file": ("evil.zip", zip_buffer, "application/zip")}
  1025. response = await async_client.post("/api/v1/projects/import/file", files=files)
  1026. assert response.status_code == 400, response.text
  1027. assert not target_outside.exists(), "Attacker payload landed outside library_dir"
  1028. @pytest.mark.asyncio
  1029. @pytest.mark.integration
  1030. async def test_import_rejects_dotdot_in_folder_name(self, async_client: AsyncClient):
  1031. """`..` segments in folder name must be rejected."""
  1032. import io
  1033. import json
  1034. import zipfile
  1035. zip_buffer = io.BytesIO()
  1036. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1037. zf.writestr(
  1038. "project.json",
  1039. json.dumps(
  1040. {
  1041. "name": "innocent",
  1042. "linked_folders": [{"name": "../../../etc"}],
  1043. }
  1044. ),
  1045. )
  1046. zf.writestr("files/../../../etc/x.txt", b"x")
  1047. zip_buffer.seek(0)
  1048. files = {"file": ("evil.zip", zip_buffer, "application/zip")}
  1049. response = await async_client.post("/api/v1/projects/import/file", files=files)
  1050. assert response.status_code == 400, response.text
  1051. @pytest.mark.asyncio
  1052. @pytest.mark.integration
  1053. async def test_import_rejects_dotdot_in_relative_path(self, async_client: AsyncClient):
  1054. """`..` segments in the per-entry path (Vector B in the advisory) must
  1055. be rejected even when the folder name itself is fine."""
  1056. import io
  1057. import json
  1058. import zipfile
  1059. zip_buffer = io.BytesIO()
  1060. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1061. zf.writestr(
  1062. "project.json",
  1063. json.dumps(
  1064. {
  1065. "name": "innocent",
  1066. "linked_folders": [{"name": "ok"}],
  1067. }
  1068. ),
  1069. )
  1070. # Folder name is benign, but the file path inside attempts to
  1071. # escape via ``..``.
  1072. zf.writestr("files/ok/../../../etc/x.txt", b"x")
  1073. zip_buffer.seek(0)
  1074. files = {"file": ("evil.zip", zip_buffer, "application/zip")}
  1075. response = await async_client.post("/api/v1/projects/import/file", files=files)
  1076. assert response.status_code == 400, response.text
  1077. @pytest.mark.asyncio
  1078. @pytest.mark.integration
  1079. async def test_import_legit_nested_zip_still_works(self, async_client: AsyncClient):
  1080. """A legitimate ZIP with a nested file path inside the folder must
  1081. continue to import cleanly. Guards against the fix being over-strict."""
  1082. import io
  1083. import json
  1084. import zipfile
  1085. zip_buffer = io.BytesIO()
  1086. with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
  1087. zf.writestr(
  1088. "project.json",
  1089. json.dumps(
  1090. {
  1091. "name": "nested-ok",
  1092. "linked_folders": [{"name": "OkFolder"}],
  1093. }
  1094. ),
  1095. )
  1096. zf.writestr("files/OkFolder/sub/dir/inside.txt", b"hello")
  1097. zip_buffer.seek(0)
  1098. files = {"file": ("nested.zip", zip_buffer, "application/zip")}
  1099. response = await async_client.post("/api/v1/projects/import/file", files=files)
  1100. assert response.status_code == 200, response.text
  1101. data = response.json()
  1102. assert data["name"] == "nested-ok"
  1103. class TestProjectListEditableFields:
  1104. """Tests for #2536 — the project list payload must carry every field the
  1105. shared edit dialog renders. The dialog is opened from both the project list
  1106. and the project detail page and seeds itself from whichever project object it
  1107. is handed, so a field missing from the list payload shows up blank there and
  1108. is saved back over the stored value."""
  1109. @pytest.fixture
  1110. async def project_factory(self, db_session):
  1111. async def _create(**kwargs):
  1112. from backend.app.models.project import Project
  1113. defaults = {"name": "Editable Fields Project", "color": "#123456"}
  1114. defaults.update(kwargs)
  1115. project = Project(**defaults)
  1116. db_session.add(project)
  1117. await db_session.commit()
  1118. await db_session.refresh(project)
  1119. return project
  1120. return _create
  1121. @pytest.mark.asyncio
  1122. @pytest.mark.integration
  1123. async def test_list_carries_the_fields_the_edit_dialog_renders(self, async_client: AsyncClient, project_factory):
  1124. """The list view is where the reporter saw an empty tags field."""
  1125. from datetime import datetime
  1126. await project_factory(
  1127. name="Tagged Project",
  1128. tags="prototype,client-work",
  1129. due_date=datetime(2026, 8, 1, 12, 0, 0),
  1130. priority="high",
  1131. target_parts_count=7,
  1132. )
  1133. response = await async_client.get("/api/v1/projects/")
  1134. assert response.status_code == 200
  1135. item = next(p for p in response.json() if p["name"] == "Tagged Project")
  1136. assert item["tags"] == "prototype,client-work"
  1137. assert item["due_date"].startswith("2026-08-01")
  1138. assert item["priority"] == "high"
  1139. assert item["target_parts_count"] == 7
  1140. @pytest.mark.asyncio
  1141. @pytest.mark.integration
  1142. async def test_template_list_carries_them_too(self, async_client: AsyncClient, project_factory):
  1143. """Templates feed the same dialog, so they need the same payload."""
  1144. await project_factory(
  1145. name="Tagged Template",
  1146. is_template=True,
  1147. tags="reusable",
  1148. priority="urgent",
  1149. target_parts_count=3,
  1150. )
  1151. response = await async_client.get("/api/v1/projects/templates")
  1152. assert response.status_code == 200
  1153. item = next(p for p in response.json() if p["name"] == "Tagged Template")
  1154. assert item["tags"] == "reusable"
  1155. assert item["priority"] == "urgent"
  1156. assert item["target_parts_count"] == 3
  1157. @pytest.mark.asyncio
  1158. @pytest.mark.integration
  1159. async def test_priority_survives_an_edit_that_does_not_touch_it(self, async_client: AsyncClient, project_factory):
  1160. """A save from the list view used to submit the default priority over a
  1161. stored 'high' — the dialog never received the real one."""
  1162. project = await project_factory(name="Important", priority="high", tags="keep-me")
  1163. response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"name": "Still Important"})
  1164. assert response.status_code == 200
  1165. result = response.json()
  1166. assert result["priority"] == "high"
  1167. assert result["tags"] == "keep-me"
  1168. @pytest.mark.asyncio
  1169. @pytest.mark.integration
  1170. async def test_explicit_null_clears_tags_and_due_date(self, async_client: AsyncClient, project_factory):
  1171. """Emptying the field in the dialog has to actually remove the value."""
  1172. from datetime import datetime
  1173. project = await project_factory(name="Clearable", tags="obsolete", due_date=datetime(2026, 8, 1, 12, 0, 0))
  1174. response = await async_client.patch(f"/api/v1/projects/{project.id}", json={"tags": None, "due_date": None})
  1175. assert response.status_code == 200
  1176. result = response.json()
  1177. assert result["tags"] is None
  1178. assert result["due_date"] is None
  1179. class TestProjectFileProgress:
  1180. """Per-file print progress inside a project (#1897).
  1181. Covers GET /projects/{id}/file-progress (attribution: library_file_id →
  1182. content hash → filename, completed runs only, project-scoped), the
  1183. target_sets field round-trip, and the add-to-queue project inheritance
  1184. that feeds the attribution chain.
  1185. """
  1186. @pytest.fixture
  1187. async def project_factory(self, db_session):
  1188. _counter = [0]
  1189. async def _create_project(**kwargs):
  1190. from backend.app.models.project import Project
  1191. _counter[0] += 1
  1192. defaults = {"name": f"Progress Project {_counter[0]}"}
  1193. defaults.update(kwargs)
  1194. project = Project(**defaults)
  1195. db_session.add(project)
  1196. await db_session.commit()
  1197. await db_session.refresh(project)
  1198. return project
  1199. return _create_project
  1200. @pytest.fixture
  1201. async def folder_factory(self, db_session):
  1202. _counter = [0]
  1203. async def _create_folder(**kwargs):
  1204. from backend.app.models.library import LibraryFolder
  1205. _counter[0] += 1
  1206. defaults = {"name": f"ProgressFolder {_counter[0]}"}
  1207. defaults.update(kwargs)
  1208. folder = LibraryFolder(**defaults)
  1209. db_session.add(folder)
  1210. await db_session.commit()
  1211. await db_session.refresh(folder)
  1212. return folder
  1213. return _create_folder
  1214. @pytest.fixture
  1215. async def file_factory(self, db_session):
  1216. _counter = [0]
  1217. async def _create_file(**kwargs):
  1218. from backend.app.models.library import LibraryFile
  1219. _counter[0] += 1
  1220. counter = _counter[0]
  1221. defaults = {
  1222. "filename": f"plate_{counter}.gcode.3mf",
  1223. "file_path": f"library/plate_{counter}.gcode.3mf",
  1224. "file_size": 1024,
  1225. "file_type": "3mf",
  1226. }
  1227. defaults.update(kwargs)
  1228. lib_file = LibraryFile(**defaults)
  1229. db_session.add(lib_file)
  1230. await db_session.commit()
  1231. await db_session.refresh(lib_file)
  1232. return lib_file
  1233. return _create_file
  1234. @pytest.mark.asyncio
  1235. @pytest.mark.integration
  1236. async def test_counts_by_library_file_id(
  1237. self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
  1238. ):
  1239. """Runs stamped with library_file_id count toward that file even when
  1240. the archive's filename differs (rename after dispatch)."""
  1241. project = await project_factory()
  1242. folder = await folder_factory(project_id=project.id)
  1243. file_a = await file_factory(folder_id=folder.id)
  1244. file_b = await file_factory(folder_id=folder.id)
  1245. printer = await printer_factory()
  1246. for _ in range(2):
  1247. await archive_factory(
  1248. printer.id,
  1249. project_id=project.id,
  1250. library_file_id=file_a.id,
  1251. filename="renamed_on_dispatch.gcode.3mf",
  1252. )
  1253. await archive_factory(printer.id, project_id=project.id, library_file_id=file_b.id)
  1254. response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
  1255. assert response.status_code == 200
  1256. counts = {row["file_id"]: row["completed_count"] for row in response.json()}
  1257. assert counts == {file_a.id: 2, file_b.id: 1}
  1258. @pytest.mark.asyncio
  1259. @pytest.mark.integration
  1260. async def test_hash_and_filename_fallback(
  1261. self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
  1262. ):
  1263. """Historical archives without library_file_id match by content hash,
  1264. then by filename."""
  1265. project = await project_factory()
  1266. folder = await folder_factory(project_id=project.id)
  1267. hashed_file = await file_factory(folder_id=folder.id, file_hash="a" * 64)
  1268. named_file = await file_factory(folder_id=folder.id, filename="unique_name.gcode.3mf")
  1269. printer = await printer_factory()
  1270. # Hash match despite a different filename
  1271. await archive_factory(
  1272. printer.id, project_id=project.id, content_hash="a" * 64, filename="printer_copy.gcode.3mf"
  1273. )
  1274. # Filename match with no hash on either side
  1275. await archive_factory(printer.id, project_id=project.id, filename="unique_name.gcode.3mf")
  1276. response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
  1277. counts = {row["file_id"]: row["completed_count"] for row in response.json()}
  1278. assert counts == {hashed_file.id: 1, named_file.id: 1}
  1279. @pytest.mark.asyncio
  1280. @pytest.mark.integration
  1281. async def test_only_completed_runs_count(
  1282. self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
  1283. ):
  1284. """Failed runs and never-printed archives do not advance the count."""
  1285. project = await project_factory()
  1286. folder = await folder_factory(project_id=project.id)
  1287. lib_file = await file_factory(folder_id=folder.id)
  1288. printer = await printer_factory()
  1289. await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id)
  1290. await archive_factory(
  1291. printer.id, project_id=project.id, library_file_id=lib_file.id, status="failed", run_status="failed"
  1292. )
  1293. await archive_factory(printer.id, project_id=project.id, library_file_id=lib_file.id, with_run=False)
  1294. response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
  1295. counts = {row["file_id"]: row["completed_count"] for row in response.json()}
  1296. assert counts == {lib_file.id: 1}
  1297. @pytest.mark.asyncio
  1298. @pytest.mark.integration
  1299. async def test_scoped_to_project(
  1300. self, async_client: AsyncClient, project_factory, folder_factory, file_factory, printer_factory, archive_factory
  1301. ):
  1302. """Runs of the same file outside the project (no project / another
  1303. project) are excluded."""
  1304. project = await project_factory()
  1305. other_project = await project_factory()
  1306. folder = await folder_factory(project_id=project.id)
  1307. lib_file = await file_factory(folder_id=folder.id)
  1308. printer = await printer_factory()
  1309. await archive_factory(printer.id, project_id=None, library_file_id=lib_file.id)
  1310. await archive_factory(printer.id, project_id=other_project.id, library_file_id=lib_file.id)
  1311. response = await async_client.get(f"/api/v1/projects/{project.id}/file-progress")
  1312. assert response.json() == []
  1313. @pytest.mark.asyncio
  1314. @pytest.mark.integration
  1315. async def test_unknown_project_404(self, async_client: AsyncClient):
  1316. response = await async_client.get("/api/v1/projects/999999/file-progress")
  1317. assert response.status_code == 404
  1318. @pytest.mark.asyncio
  1319. @pytest.mark.integration
  1320. async def test_target_sets_roundtrip(self, async_client: AsyncClient):
  1321. """target_sets survives create, update, and explicit-null clearing."""
  1322. create = await async_client.post("/api/v1/projects/", json={"name": "Sets Project", "target_sets": 10})
  1323. assert create.status_code == 200
  1324. project = create.json()
  1325. assert project["target_sets"] == 10
  1326. update = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": 4})
  1327. assert update.status_code == 200, update.json()
  1328. assert update.json()["target_sets"] == 4
  1329. cleared = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"target_sets": None})
  1330. assert cleared.json()["target_sets"] is None
  1331. untouched = await async_client.patch(f"/api/v1/projects/{project['id']}", json={"name": "Renamed"})
  1332. assert untouched.json()["target_sets"] is None
  1333. @pytest.mark.asyncio
  1334. @pytest.mark.integration
  1335. async def test_add_to_queue_inherits_folder_project(
  1336. self, async_client: AsyncClient, project_factory, folder_factory, file_factory, db_session, tmp_path
  1337. ):
  1338. """Queueing a file from a project-linked folder attributes the queue
  1339. item (and thus the later archive) to that project; a root file stays
  1340. unattributed."""
  1341. from sqlalchemy import select
  1342. from backend.app.models.print_queue import PrintQueueItem
  1343. project = await project_factory()
  1344. folder = await folder_factory(project_id=project.id)
  1345. on_disk = tmp_path / "linked.gcode.3mf"
  1346. on_disk.write_bytes(b"fake sliced content")
  1347. linked_file = await file_factory(folder_id=folder.id, file_path=str(on_disk))
  1348. root_disk = tmp_path / "root.gcode.3mf"
  1349. root_disk.write_bytes(b"fake sliced content")
  1350. root_file = await file_factory(folder_id=None, file_path=str(root_disk))
  1351. response = await async_client.post(
  1352. "/api/v1/library/files/add-to-queue", json={"file_ids": [linked_file.id, root_file.id]}
  1353. )
  1354. assert response.status_code == 200
  1355. assert len(response.json()["added"]) == 2
  1356. result = await db_session.execute(
  1357. select(PrintQueueItem.library_file_id, PrintQueueItem.project_id).where(
  1358. PrintQueueItem.library_file_id.in_([linked_file.id, root_file.id])
  1359. )
  1360. )
  1361. projects_by_file = dict(result.all())
  1362. assert projects_by_file[linked_file.id] == project.id
  1363. assert projects_by_file[root_file.id] is None
  1364. class TestSoftDeletedArchivesLeaveTheProject:
  1365. """Deleting a print removes it from its project, everywhere (#2731).
  1366. The default archive delete is soft (#1343): the files go, the row stays so
  1367. global Quick Stats keeps counting its filament / time / cost. Nothing in the
  1368. projects module filtered on that, so a deleted print stayed listed on the
  1369. project with a thumbnail pointing at a file that no longer existed — and
  1370. could not be unassigned, because the only unassign UI lives on the Archives
  1371. page, which correctly hides it.
  1372. Unlike Quick Stats, project *counts* exclude it too. A project is a piece of
  1373. work with a definite membership, not a lifetime total, so a project that
  1374. lists one print must not claim two.
  1375. """
  1376. @pytest.fixture
  1377. async def project_factory(self, db_session):
  1378. async def _create_project(**kwargs):
  1379. from backend.app.models.project import Project
  1380. defaults = {"name": "Deleted Archive Project", "color": "#FF0000"}
  1381. defaults.update(kwargs)
  1382. project = Project(**defaults)
  1383. db_session.add(project)
  1384. await db_session.commit()
  1385. await db_session.refresh(project)
  1386. return project
  1387. return _create_project
  1388. @pytest.fixture
  1389. async def archive_factory(self, db_session):
  1390. """Archive + matching PrintLogEntry, as production always writes both."""
  1391. async def _create_archive(**kwargs):
  1392. from backend.app.models.archive import PrintArchive
  1393. from backend.app.models.print_log import PrintLogEntry
  1394. defaults = {
  1395. "filename": "test.3mf",
  1396. "file_path": "test/test.3mf",
  1397. "file_size": 1000,
  1398. "print_name": "Test Print",
  1399. "status": "completed",
  1400. "quantity": 1,
  1401. "thumbnail_path": "test/thumb.png",
  1402. }
  1403. defaults.update(kwargs)
  1404. archive = PrintArchive(**defaults)
  1405. db_session.add(archive)
  1406. await db_session.commit()
  1407. await db_session.refresh(archive)
  1408. db_session.add(
  1409. PrintLogEntry(
  1410. archive_id=archive.id,
  1411. print_name=archive.print_name,
  1412. status=archive.status,
  1413. filament_used_grams=10.0,
  1414. )
  1415. )
  1416. await db_session.commit()
  1417. return archive
  1418. return _create_archive
  1419. @staticmethod
  1420. async def _soft_delete(db_session, archive) -> int:
  1421. """Soft-delete *archive* and return its id.
  1422. The commit expires the instance, so reading an attribute off it
  1423. afterwards is lazy IO outside the greenlet context (MissingGreenlet).
  1424. Callers take the id from here instead.
  1425. """
  1426. from datetime import datetime, timezone
  1427. archive_id = archive.id
  1428. archive.deleted_at = datetime.now(timezone.utc)
  1429. await db_session.commit()
  1430. return archive_id
  1431. @pytest.mark.asyncio
  1432. @pytest.mark.integration
  1433. async def test_deleted_archive_is_not_listed_on_the_project(
  1434. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  1435. ):
  1436. """The reported symptom: a card with a broken preview image."""
  1437. project = await project_factory()
  1438. await archive_factory(project_id=project.id, print_name="Kept")
  1439. gone = await archive_factory(project_id=project.id, print_name="Deleted")
  1440. await self._soft_delete(db_session, gone)
  1441. response = await async_client.get(f"/api/v1/projects/{project.id}/archives")
  1442. assert response.status_code == 200
  1443. assert [a["print_name"] for a in response.json()] == ["Kept"]
  1444. @pytest.mark.asyncio
  1445. @pytest.mark.integration
  1446. async def test_deleted_archive_is_not_a_preview_on_the_project_card(
  1447. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  1448. ):
  1449. """The overview page renders these as thumbnails too, so it broke there
  1450. as well — not just on the detail page."""
  1451. project = await project_factory()
  1452. gone = await archive_factory(project_id=project.id, print_name="Deleted")
  1453. await self._soft_delete(db_session, gone)
  1454. response = await async_client.get("/api/v1/projects/")
  1455. assert response.status_code == 200
  1456. row = next(p for p in response.json() if p["id"] == project.id)
  1457. assert row["archives"] == []
  1458. @pytest.mark.asyncio
  1459. @pytest.mark.integration
  1460. async def test_project_counts_exclude_the_deleted_archive(
  1461. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  1462. ):
  1463. """The list shows one print, so the count must say one."""
  1464. project = await project_factory()
  1465. await archive_factory(project_id=project.id, print_name="Kept")
  1466. gone = await archive_factory(project_id=project.id, print_name="Deleted")
  1467. await self._soft_delete(db_session, gone)
  1468. response = await async_client.get("/api/v1/projects/")
  1469. row = next(p for p in response.json() if p["id"] == project.id)
  1470. assert row["archive_count"] == 1
  1471. @pytest.mark.asyncio
  1472. @pytest.mark.integration
  1473. async def test_project_stats_exclude_the_deleted_archive(
  1474. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  1475. ):
  1476. """Deliberate divergence from #1343: the contribution leaves the project
  1477. even though it stays in global Quick Stats."""
  1478. project = await project_factory()
  1479. await archive_factory(project_id=project.id, print_name="Kept")
  1480. gone = await archive_factory(project_id=project.id, print_name="Deleted")
  1481. await self._soft_delete(db_session, gone)
  1482. response = await async_client.get(f"/api/v1/projects/{project.id}")
  1483. assert response.status_code == 200
  1484. stats = response.json()["stats"]
  1485. assert stats["total_archives"] == 1
  1486. assert stats["total_filament_grams"] == pytest.approx(10.0)
  1487. @pytest.mark.asyncio
  1488. @pytest.mark.integration
  1489. async def test_deleted_archive_is_not_in_the_project_timeline(
  1490. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  1491. ):
  1492. """A timeline entry for it links to an archive that 404s when clicked."""
  1493. project = await project_factory()
  1494. gone = await archive_factory(project_id=project.id, print_name="Deleted")
  1495. await self._soft_delete(db_session, gone)
  1496. response = await async_client.get(f"/api/v1/projects/{project.id}/timeline")
  1497. assert response.status_code == 200
  1498. assert not any(e.get("description") == "Deleted" for e in response.json())
  1499. @pytest.mark.asyncio
  1500. @pytest.mark.integration
  1501. async def test_a_live_archive_is_untouched_by_all_of_this(
  1502. self, async_client: AsyncClient, project_factory, archive_factory
  1503. ):
  1504. """The filter must not cost a project its actual prints."""
  1505. project = await project_factory()
  1506. await archive_factory(project_id=project.id, print_name="Kept")
  1507. listing = await async_client.get(f"/api/v1/projects/{project.id}/archives")
  1508. assert [a["print_name"] for a in listing.json()] == ["Kept"]
  1509. stats = await async_client.get(f"/api/v1/projects/{project.id}")
  1510. assert stats.json()["stats"]["total_archives"] == 1
  1511. row = next(p for p in (await async_client.get("/api/v1/projects/")).json() if p["id"] == project.id)
  1512. assert row["archive_count"] == 1
  1513. assert len(row["archives"]) == 1
  1514. @pytest.mark.asyncio
  1515. @pytest.mark.integration
  1516. async def test_unassigning_an_already_orphaned_link_still_works(
  1517. self, async_client: AsyncClient, project_factory, archive_factory, db_session
  1518. ):
  1519. """The listings hide it, but the API must still be able to clear the
  1520. link — that is the repair path for rows written before this fix."""
  1521. from sqlalchemy import select
  1522. from backend.app.models.archive import PrintArchive
  1523. project = await project_factory()
  1524. gone = await archive_factory(project_id=project.id, print_name="Deleted")
  1525. gone_id = await self._soft_delete(db_session, gone)
  1526. response = await async_client.post(
  1527. f"/api/v1/projects/{project.id}/remove-archives", json={"archive_ids": [gone_id]}
  1528. )
  1529. assert response.status_code == 200
  1530. db_session.expire_all()
  1531. result = await db_session.execute(select(PrintArchive.project_id).where(PrintArchive.id == gone_id))
  1532. assert result.scalar_one() is None
  1533. class TestSubProjectRollup:
  1534. """Tests for #1264 — nesting projects and rolling their figures up.
  1535. The parent/child columns predate this; what these cover is the roll-up,
  1536. the cycle guard that a roll-up needs to terminate, and what a delete does
  1537. to the branch hanging off it.
  1538. """
  1539. @pytest.fixture
  1540. async def project_factory(self, db_session):
  1541. async def _create_project(**kwargs):
  1542. from backend.app.models.project import Project
  1543. defaults = {"name": "Rollup Project", "color": "#FF0000"}
  1544. defaults.update(kwargs)
  1545. project = Project(**defaults)
  1546. db_session.add(project)
  1547. await db_session.commit()
  1548. await db_session.refresh(project)
  1549. return project
  1550. return _create_project
  1551. @pytest.fixture
  1552. async def run_factory(self, db_session):
  1553. """One completed run against a project, with figures worth summing."""
  1554. async def _create_run(project_id, *, grams=100.0, cost=5.0, seconds=3600, status="completed", quantity=1):
  1555. from backend.app.models.archive import PrintArchive
  1556. from backend.app.models.print_log import PrintLogEntry
  1557. archive = PrintArchive(
  1558. filename="test.3mf",
  1559. file_path="test/test.3mf",
  1560. file_size=1000,
  1561. print_name="Run",
  1562. status=status,
  1563. quantity=quantity,
  1564. project_id=project_id,
  1565. )
  1566. db_session.add(archive)
  1567. await db_session.commit()
  1568. await db_session.refresh(archive)
  1569. db_session.add(
  1570. PrintLogEntry(
  1571. archive_id=archive.id,
  1572. print_name=archive.print_name,
  1573. status=status,
  1574. duration_seconds=seconds,
  1575. filament_used_grams=grams,
  1576. cost=cost,
  1577. )
  1578. )
  1579. await db_session.commit()
  1580. return archive
  1581. return _create_run
  1582. @pytest.mark.asyncio
  1583. @pytest.mark.integration
  1584. async def test_a_master_project_rolls_up_every_sub_project(
  1585. self, async_client: AsyncClient, project_factory, run_factory
  1586. ):
  1587. """The whole point of the feature: one number for the programme."""
  1588. master = await project_factory(name="Airframe")
  1589. wing = await project_factory(name="Wing", parent_id=master.id)
  1590. tail = await project_factory(name="Tail", parent_id=master.id)
  1591. await run_factory(master.id, grams=10.0, cost=1.0, seconds=3600)
  1592. await run_factory(wing.id, grams=20.0, cost=2.0, seconds=7200)
  1593. await run_factory(tail.id, grams=30.0, cost=3.0, seconds=1800)
  1594. body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
  1595. assert body["descendant_count"] == 2
  1596. assert body["rollup_stats"]["total_archives"] == 3
  1597. assert body["rollup_stats"]["total_filament_grams"] == 60.0
  1598. assert body["rollup_stats"]["estimated_cost"] == 6.0
  1599. assert body["rollup_stats"]["total_print_time_hours"] == 3.5
  1600. @pytest.mark.asyncio
  1601. @pytest.mark.integration
  1602. async def test_the_masters_own_stats_still_mean_its_own_prints(
  1603. self, async_client: AsyncClient, project_factory, run_factory
  1604. ):
  1605. """``stats`` keeps its existing meaning — anyone who nested projects
  1606. over the API before this shipped must not see their figures restated."""
  1607. master = await project_factory(name="Airframe")
  1608. wing = await project_factory(name="Wing", parent_id=master.id)
  1609. await run_factory(master.id, grams=10.0)
  1610. await run_factory(wing.id, grams=20.0)
  1611. body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
  1612. assert body["stats"]["total_archives"] == 1
  1613. assert body["stats"]["total_filament_grams"] == 10.0
  1614. @pytest.mark.asyncio
  1615. @pytest.mark.integration
  1616. async def test_the_roll_up_reaches_past_the_first_generation(
  1617. self, async_client: AsyncClient, project_factory, run_factory
  1618. ):
  1619. """Nesting is arbitrary depth, so a grandchild has to count too."""
  1620. master = await project_factory(name="Airframe")
  1621. wing = await project_factory(name="Wing", parent_id=master.id)
  1622. spar = await project_factory(name="Spar", parent_id=wing.id)
  1623. await run_factory(spar.id, grams=50.0)
  1624. body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
  1625. assert body["descendant_count"] == 2
  1626. assert body["rollup_stats"]["total_filament_grams"] == 50.0
  1627. @pytest.mark.asyncio
  1628. @pytest.mark.integration
  1629. async def test_a_childless_project_reports_no_roll_up_at_all(
  1630. self, async_client: AsyncClient, project_factory, run_factory
  1631. ):
  1632. """Null, not a copy of ``stats`` — the page uses the absence to stay
  1633. quiet rather than printing the same figures twice."""
  1634. lonely = await project_factory(name="Solo")
  1635. await run_factory(lonely.id)
  1636. body = (await async_client.get(f"/api/v1/projects/{lonely.id}")).json()
  1637. assert body["rollup_stats"] is None
  1638. assert body["descendant_count"] == 0
  1639. assert body["children"] == []
  1640. @pytest.mark.asyncio
  1641. @pytest.mark.integration
  1642. async def test_each_listed_child_carries_its_own_branch_total(
  1643. self, async_client: AsyncClient, project_factory, run_factory
  1644. ):
  1645. """Otherwise the listed rows do not add up to the master's total and
  1646. the page contradicts itself."""
  1647. master = await project_factory(name="Airframe")
  1648. wing = await project_factory(name="Wing", parent_id=master.id)
  1649. spar = await project_factory(name="Spar", parent_id=wing.id)
  1650. await run_factory(wing.id, grams=20.0, cost=2.0)
  1651. await run_factory(spar.id, grams=30.0, cost=3.0)
  1652. body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
  1653. assert len(body["children"]) == 1
  1654. row = body["children"][0]
  1655. assert row["name"] == "Wing"
  1656. assert row["descendant_count"] == 1
  1657. assert row["total_archives"] == 2
  1658. assert row["total_filament_grams"] == 50.0
  1659. assert row["total_cost"] == 5.0
  1660. @pytest.mark.asyncio
  1661. @pytest.mark.integration
  1662. async def test_roll_up_progress_measures_against_the_summed_targets(
  1663. self, async_client: AsyncClient, project_factory, run_factory
  1664. ):
  1665. """A target on each part of the tree is a target for the whole."""
  1666. master = await project_factory(name="Airframe", target_count=2)
  1667. wing = await project_factory(name="Wing", parent_id=master.id, target_count=2)
  1668. await run_factory(master.id)
  1669. await run_factory(wing.id)
  1670. await run_factory(wing.id)
  1671. body = (await async_client.get(f"/api/v1/projects/{master.id}")).json()
  1672. assert body["stats"]["progress_percent"] == 50.0 # 1 of its own 2
  1673. assert body["rollup_stats"]["progress_percent"] == 75.0 # 3 of the tree's 4
  1674. assert body["rollup_stats"]["remaining_prints"] == 1
  1675. @pytest.mark.asyncio
  1676. @pytest.mark.integration
  1677. async def test_a_project_cannot_be_moved_under_its_own_sub_project(
  1678. self, async_client: AsyncClient, project_factory
  1679. ):
  1680. """Rejecting only the direct self-parent left A -> B -> A reachable in
  1681. two calls, and a cycle has no root to roll anything up to."""
  1682. master = await project_factory(name="Airframe")
  1683. wing = await project_factory(name="Wing", parent_id=master.id)
  1684. response = await async_client.patch(f"/api/v1/projects/{master.id}", json={"parent_id": wing.id})
  1685. assert response.status_code == 400
  1686. assert "sub-projects" in response.json()["detail"]
  1687. @pytest.mark.asyncio
  1688. @pytest.mark.integration
  1689. async def test_the_guard_reaches_a_distant_descendant_too(self, async_client: AsyncClient, project_factory):
  1690. """A three-deep loop is no more legal than a two-deep one."""
  1691. master = await project_factory(name="Airframe")
  1692. wing = await project_factory(name="Wing", parent_id=master.id)
  1693. spar = await project_factory(name="Spar", parent_id=wing.id)
  1694. response = await async_client.patch(f"/api/v1/projects/{master.id}", json={"parent_id": spar.id})
  1695. assert response.status_code == 400
  1696. @pytest.mark.asyncio
  1697. @pytest.mark.integration
  1698. async def test_an_unrelated_project_is_still_a_legal_parent(self, async_client: AsyncClient, project_factory):
  1699. """The guard must not refuse ordinary nesting."""
  1700. master = await project_factory(name="Airframe")
  1701. wing = await project_factory(name="Wing", parent_id=master.id)
  1702. other = await project_factory(name="Ground Station")
  1703. response = await async_client.patch(f"/api/v1/projects/{other.id}", json={"parent_id": wing.id})
  1704. assert response.status_code == 200
  1705. assert response.json()["parent_id"] == wing.id
  1706. @pytest.mark.asyncio
  1707. @pytest.mark.integration
  1708. async def test_a_cycle_already_in_the_database_does_not_hang_the_roll_up(
  1709. self, async_client: AsyncClient, project_factory, db_session
  1710. ):
  1711. """Databases written before the guard was widened can hold A -> B -> A.
  1712. Reading one has to terminate, not spin."""
  1713. from sqlalchemy import update as sa_update
  1714. from backend.app.models.project import Project
  1715. first = await project_factory(name="First")
  1716. second = await project_factory(name="Second", parent_id=first.id)
  1717. # Straight to the table: the API now refuses to write this.
  1718. await db_session.execute(sa_update(Project).where(Project.id == first.id).values(parent_id=second.id))
  1719. await db_session.commit()
  1720. response = await async_client.get(f"/api/v1/projects/{first.id}")
  1721. assert response.status_code == 200
  1722. assert response.json()["descendant_count"] == 1
  1723. @pytest.mark.asyncio
  1724. @pytest.mark.integration
  1725. async def test_deleting_a_middle_layer_promotes_its_children(
  1726. self, async_client: AsyncClient, project_factory, db_session
  1727. ):
  1728. """Collapse the tree by one rather than scattering the branch."""
  1729. from sqlalchemy import select
  1730. from backend.app.models.project import Project
  1731. master = await project_factory(name="Airframe")
  1732. wing = await project_factory(name="Wing", parent_id=master.id)
  1733. spar = await project_factory(name="Spar", parent_id=wing.id)
  1734. # Read the ids out before expiring: an expired instance refreshes itself
  1735. # on attribute access, which is a lazy load in a sync frame.
  1736. master_id, spar_id = master.id, spar.id
  1737. response = await async_client.delete(f"/api/v1/projects/{wing.id}")
  1738. assert response.status_code == 200
  1739. db_session.expire_all()
  1740. parent_id = (await db_session.execute(select(Project.parent_id).where(Project.id == spar_id))).scalar_one()
  1741. assert parent_id == master_id
  1742. @pytest.mark.asyncio
  1743. @pytest.mark.integration
  1744. async def test_deleting_a_top_level_project_frees_its_children(
  1745. self, async_client: AsyncClient, project_factory, db_session
  1746. ):
  1747. """Nothing to promote to, so the child becomes top-level — and the
  1748. delete has to succeed at all, which the bare FK would have refused."""
  1749. from sqlalchemy import select
  1750. from backend.app.models.project import Project
  1751. master = await project_factory(name="Airframe")
  1752. wing = await project_factory(name="Wing", parent_id=master.id)
  1753. wing_id = wing.id # See the sibling test: expiring invalidates the instance.
  1754. response = await async_client.delete(f"/api/v1/projects/{master.id}")
  1755. assert response.status_code == 200
  1756. db_session.expire_all()
  1757. parent_id = (await db_session.execute(select(Project.parent_id).where(Project.id == wing_id))).scalar_one()
  1758. assert parent_id is None
  1759. @pytest.mark.asyncio
  1760. @pytest.mark.integration
  1761. async def test_the_grid_can_tell_a_sub_project_from_a_top_level_one(
  1762. self, async_client: AsyncClient, project_factory
  1763. ):
  1764. """Without these the list view shows eight sub-projects as eight
  1765. unrelated ones."""
  1766. master = await project_factory(name="Airframe")
  1767. await project_factory(name="Wing", parent_id=master.id)
  1768. rows = {p["name"]: p for p in (await async_client.get("/api/v1/projects/")).json()}
  1769. assert rows["Airframe"]["parent_id"] is None
  1770. assert rows["Airframe"]["child_count"] == 1
  1771. assert rows["Wing"]["parent_id"] == master.id
  1772. assert rows["Wing"]["child_count"] == 0
  1773. @pytest.mark.asyncio
  1774. @pytest.mark.integration
  1775. async def test_a_filtered_listing_still_admits_to_its_hidden_children(
  1776. self, async_client: AsyncClient, project_factory
  1777. ):
  1778. """A parent that claimed no children would invite deleting it as if
  1779. nothing hung off it."""
  1780. master = await project_factory(name="Airframe", status="active")
  1781. await project_factory(name="Wing", parent_id=master.id, status="completed")
  1782. rows = {p["name"]: p for p in (await async_client.get("/api/v1/projects/?status=active")).json()}
  1783. assert "Wing" not in rows
  1784. assert rows["Airframe"]["child_count"] == 1