test_archives_api.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
  1. """Integration tests for Archives API endpoints.
  2. Tests the full request/response cycle for /api/v1/archives/ endpoints.
  3. """
  4. import pytest
  5. from httpx import AsyncClient
  6. class TestArchivesAPI:
  7. """Integration tests for /api/v1/archives/ endpoints."""
  8. # ========================================================================
  9. # List endpoints
  10. # ========================================================================
  11. @pytest.mark.asyncio
  12. @pytest.mark.integration
  13. async def test_list_archives_empty(self, async_client: AsyncClient):
  14. """Verify empty list is returned when no archives exist."""
  15. response = await async_client.get("/api/v1/archives/")
  16. assert response.status_code == 200
  17. data = response.json()
  18. assert isinstance(data, list)
  19. assert len(data) == 0
  20. @pytest.mark.asyncio
  21. @pytest.mark.integration
  22. async def test_list_archives_with_data(
  23. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  24. ):
  25. """Verify list returns existing archives."""
  26. printer = await printer_factory()
  27. await archive_factory(printer.id, print_name="Test Archive")
  28. response = await async_client.get("/api/v1/archives/")
  29. assert response.status_code == 200
  30. data = response.json()
  31. assert isinstance(data, list)
  32. assert len(data) >= 1
  33. assert any(a["print_name"] == "Test Archive" for a in data)
  34. @pytest.mark.asyncio
  35. @pytest.mark.integration
  36. async def test_list_archives_pagination(
  37. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  38. ):
  39. """Verify pagination works correctly."""
  40. printer = await printer_factory()
  41. # Create 5 archives
  42. for i in range(5):
  43. await archive_factory(printer.id, print_name=f"Archive {i}")
  44. # Get first page with limit 2
  45. response = await async_client.get("/api/v1/archives/?limit=2&offset=0")
  46. assert response.status_code == 200
  47. data = response.json()
  48. assert isinstance(data, list)
  49. assert len(data) == 2
  50. @pytest.mark.asyncio
  51. @pytest.mark.integration
  52. async def test_list_archives_filter_by_printer(
  53. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  54. ):
  55. """Verify filtering by printer_id works."""
  56. printer1 = await printer_factory(name="Printer 1", serial_number="00M09A000000001")
  57. printer2 = await printer_factory(name="Printer 2", serial_number="00M09A000000002")
  58. await archive_factory(printer1.id, print_name="Printer 1 Archive")
  59. await archive_factory(printer2.id, print_name="Printer 2 Archive")
  60. response = await async_client.get(f"/api/v1/archives/?printer_id={printer1.id}")
  61. assert response.status_code == 200
  62. data = response.json()
  63. assert all(a["printer_id"] == printer1.id for a in data)
  64. # ========================================================================
  65. # Get single endpoint
  66. # ========================================================================
  67. @pytest.mark.asyncio
  68. @pytest.mark.integration
  69. async def test_get_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  70. """Verify single archive can be retrieved."""
  71. printer = await printer_factory()
  72. archive = await archive_factory(printer.id, print_name="Get Test Archive")
  73. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  74. assert response.status_code == 200
  75. result = response.json()
  76. assert result["id"] == archive.id
  77. assert result["print_name"] == "Get Test Archive"
  78. @pytest.mark.asyncio
  79. @pytest.mark.integration
  80. async def test_get_archive_not_found(self, async_client: AsyncClient):
  81. """Verify 404 for non-existent archive."""
  82. response = await async_client.get("/api/v1/archives/9999")
  83. assert response.status_code == 404
  84. # ========================================================================
  85. # Update endpoints
  86. # ========================================================================
  87. @pytest.mark.asyncio
  88. @pytest.mark.integration
  89. async def test_update_archive_name(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  90. """Verify archive name can be updated."""
  91. printer = await printer_factory()
  92. archive = await archive_factory(printer.id, print_name="Original Name")
  93. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"print_name": "Updated Name"})
  94. assert response.status_code == 200
  95. assert response.json()["print_name"] == "Updated Name"
  96. @pytest.mark.asyncio
  97. @pytest.mark.integration
  98. async def test_update_archive_notes(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  99. """Verify archive notes can be updated."""
  100. printer = await printer_factory()
  101. archive = await archive_factory(printer.id)
  102. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Great print!"})
  103. assert response.status_code == 200
  104. assert response.json()["notes"] == "Great print!"
  105. @pytest.mark.asyncio
  106. @pytest.mark.integration
  107. async def test_update_archive_favorite(
  108. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  109. ):
  110. """Verify archive favorite status can be updated."""
  111. printer = await printer_factory()
  112. archive = await archive_factory(printer.id)
  113. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"is_favorite": True})
  114. assert response.status_code == 200
  115. assert response.json()["is_favorite"] is True
  116. @pytest.mark.asyncio
  117. @pytest.mark.integration
  118. async def test_update_archive_external_url(
  119. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  120. ):
  121. """Verify archive external_url can be updated."""
  122. printer = await printer_factory()
  123. archive = await archive_factory(printer.id)
  124. response = await async_client.patch(
  125. f"/api/v1/archives/{archive.id}", json={"external_url": "https://printables.com/model/12345"}
  126. )
  127. assert response.status_code == 200
  128. assert response.json()["external_url"] == "https://printables.com/model/12345"
  129. # Verify it can be cleared
  130. response = await async_client.patch(f"/api/v1/archives/{archive.id}", json={"external_url": None})
  131. assert response.status_code == 200
  132. assert response.json()["external_url"] is None
  133. # ========================================================================
  134. # Delete endpoints
  135. # ========================================================================
  136. @pytest.mark.asyncio
  137. @pytest.mark.integration
  138. async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  139. """Verify archive can be deleted."""
  140. printer = await printer_factory()
  141. archive = await archive_factory(printer.id)
  142. archive_id = archive.id
  143. response = await async_client.delete(f"/api/v1/archives/{archive_id}")
  144. assert response.status_code == 200
  145. # Verify deleted
  146. response = await async_client.get(f"/api/v1/archives/{archive_id}")
  147. assert response.status_code == 404
  148. @pytest.mark.asyncio
  149. @pytest.mark.integration
  150. async def test_delete_nonexistent_archive(self, async_client: AsyncClient):
  151. """Verify deleting non-existent archive returns 404."""
  152. response = await async_client.delete("/api/v1/archives/9999")
  153. assert response.status_code == 404
  154. # ========================================================================
  155. # Statistics endpoints
  156. # ========================================================================
  157. @pytest.mark.asyncio
  158. @pytest.mark.integration
  159. async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  160. """Verify archive statistics can be retrieved."""
  161. printer = await printer_factory()
  162. await archive_factory(
  163. printer.id,
  164. status="completed",
  165. print_time_seconds=3600,
  166. filament_used_grams=50.0,
  167. )
  168. await archive_factory(
  169. printer.id,
  170. status="completed",
  171. print_time_seconds=7200,
  172. filament_used_grams=100.0,
  173. )
  174. response = await async_client.get("/api/v1/archives/stats")
  175. assert response.status_code == 200
  176. result = response.json()
  177. # Check for actual stats fields
  178. assert "total_prints" in result
  179. assert "successful_prints" in result
  180. class TestArchiveDataIntegrity:
  181. """Tests for archive data integrity."""
  182. @pytest.mark.asyncio
  183. @pytest.mark.integration
  184. async def test_archive_linked_to_printer(
  185. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  186. ):
  187. """Verify archive is properly linked to printer."""
  188. printer = await printer_factory(name="My Printer")
  189. archive = await archive_factory(printer.id)
  190. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  191. assert response.status_code == 200
  192. result = response.json()
  193. assert result["printer_id"] == printer.id
  194. @pytest.mark.asyncio
  195. @pytest.mark.integration
  196. async def test_archive_stores_print_data(
  197. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  198. ):
  199. """Verify archive stores all print data correctly."""
  200. printer = await printer_factory()
  201. archive = await archive_factory(
  202. printer.id,
  203. print_name="Test Print",
  204. filename="test.3mf",
  205. status="completed",
  206. filament_type="PLA",
  207. filament_used_grams=75.5,
  208. print_time_seconds=5400,
  209. )
  210. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  211. assert response.status_code == 200
  212. result = response.json()
  213. assert result["print_name"] == "Test Print"
  214. assert result["filename"] == "test.3mf"
  215. assert result["status"] == "completed"
  216. assert result["filament_type"] == "PLA"
  217. assert result["filament_used_grams"] == 75.5
  218. assert result["print_time_seconds"] == 5400
  219. @pytest.mark.asyncio
  220. @pytest.mark.integration
  221. async def test_archive_update_persists(
  222. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  223. ):
  224. """CRITICAL: Verify archive updates persist."""
  225. printer = await printer_factory()
  226. archive = await archive_factory(printer.id, notes="Original notes")
  227. # Update
  228. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
  229. # Verify persistence
  230. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  231. result = response.json()
  232. assert result["notes"] == "Updated notes"
  233. assert result["is_favorite"] is True
  234. class TestArchiveF3DEndpoints:
  235. """Tests for F3D (Fusion 360 design file) attachment endpoints."""
  236. @pytest.mark.asyncio
  237. @pytest.mark.integration
  238. async def test_archive_response_includes_f3d_path(
  239. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  240. ):
  241. """Verify f3d_path is included in archive response."""
  242. printer = await printer_factory()
  243. archive = await archive_factory(printer.id, f3d_path="archives/test/design.f3d")
  244. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  245. assert response.status_code == 200
  246. result = response.json()
  247. assert "f3d_path" in result
  248. assert result["f3d_path"] == "archives/test/design.f3d"
  249. @pytest.mark.asyncio
  250. @pytest.mark.integration
  251. async def test_archive_response_f3d_path_null_when_not_set(
  252. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  253. ):
  254. """Verify f3d_path is null when no F3D file attached."""
  255. printer = await printer_factory()
  256. archive = await archive_factory(printer.id)
  257. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  258. assert response.status_code == 200
  259. result = response.json()
  260. assert "f3d_path" in result
  261. assert result["f3d_path"] is None
  262. @pytest.mark.asyncio
  263. @pytest.mark.integration
  264. async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):
  265. """Verify 404 when uploading F3D to non-existent archive."""
  266. # Create a minimal file-like upload
  267. files = {"file": ("design.f3d", b"fake f3d content", "application/octet-stream")}
  268. response = await async_client.post("/api/v1/archives/9999/f3d", files=files)
  269. assert response.status_code == 404
  270. @pytest.mark.asyncio
  271. @pytest.mark.integration
  272. async def test_download_f3d_not_found_when_no_file(
  273. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  274. ):
  275. """Verify 404 when downloading F3D from archive without F3D file."""
  276. printer = await printer_factory()
  277. archive = await archive_factory(printer.id)
  278. response = await async_client.get(f"/api/v1/archives/{archive.id}/f3d")
  279. assert response.status_code == 404
  280. @pytest.mark.asyncio
  281. @pytest.mark.integration
  282. async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):
  283. """Verify 404 when downloading F3D from non-existent archive."""
  284. response = await async_client.get("/api/v1/archives/9999/f3d")
  285. assert response.status_code == 404
  286. @pytest.mark.asyncio
  287. @pytest.mark.integration
  288. async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):
  289. """Verify 404 when deleting F3D from non-existent archive."""
  290. response = await async_client.delete("/api/v1/archives/9999/f3d")
  291. assert response.status_code == 404
  292. @pytest.mark.asyncio
  293. @pytest.mark.integration
  294. async def test_delete_f3d_when_no_file(
  295. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  296. ):
  297. """Verify 404 when deleting F3D from archive without F3D file."""
  298. printer = await printer_factory()
  299. archive = await archive_factory(printer.id)
  300. response = await async_client.delete(f"/api/v1/archives/{archive.id}/f3d")
  301. assert response.status_code == 404
  302. @pytest.mark.asyncio
  303. @pytest.mark.integration
  304. async def test_list_archives_includes_f3d_path(
  305. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  306. ):
  307. """Verify f3d_path is included in archive list responses."""
  308. printer = await printer_factory()
  309. await archive_factory(printer.id, print_name="With F3D", f3d_path="archives/test/design.f3d")
  310. await archive_factory(printer.id, print_name="Without F3D")
  311. response = await async_client.get("/api/v1/archives/")
  312. assert response.status_code == 200
  313. data = response.json()
  314. assert len(data) >= 2
  315. with_f3d = next((a for a in data if a["print_name"] == "With F3D"), None)
  316. without_f3d = next((a for a in data if a["print_name"] == "Without F3D"), None)
  317. assert with_f3d is not None
  318. assert with_f3d["f3d_path"] == "archives/test/design.f3d"
  319. assert without_f3d is not None
  320. assert without_f3d["f3d_path"] is None
  321. # ========================================================================
  322. # Multi-Plate 3MF endpoints (Issue #93)
  323. # ========================================================================
  324. @pytest.mark.asyncio
  325. @pytest.mark.integration
  326. async def test_get_archive_plates_not_found(self, async_client: AsyncClient):
  327. """Verify 404 when fetching plates for non-existent archive."""
  328. response = await async_client.get("/api/v1/archives/999999/plates")
  329. assert response.status_code == 404
  330. @pytest.mark.asyncio
  331. @pytest.mark.integration
  332. async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):
  333. """Verify 404 when fetching plate thumbnail for non-existent archive."""
  334. response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1")
  335. assert response.status_code == 404
  336. @pytest.mark.asyncio
  337. @pytest.mark.integration
  338. async def test_filament_requirements_not_found(self, async_client: AsyncClient):
  339. """Verify filament-requirements returns 404 for non-existent archive."""
  340. response = await async_client.get("/api/v1/archives/999999/filament-requirements")
  341. assert response.status_code == 404
  342. @pytest.mark.asyncio
  343. @pytest.mark.integration
  344. async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):
  345. """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
  346. response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
  347. assert response.status_code == 404
  348. # ========================================================================
  349. # Tag Management endpoints (Issue #183)
  350. # ========================================================================
  351. @pytest.mark.asyncio
  352. @pytest.mark.integration
  353. async def test_get_tags_empty(self, async_client: AsyncClient):
  354. """Verify empty list when no tags exist."""
  355. response = await async_client.get("/api/v1/archives/tags")
  356. assert response.status_code == 200
  357. data = response.json()
  358. assert isinstance(data, list)
  359. assert len(data) == 0
  360. @pytest.mark.asyncio
  361. @pytest.mark.integration
  362. async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  363. """Verify tags are returned with counts."""
  364. printer = await printer_factory()
  365. await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
  366. await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
  367. await archive_factory(printer.id, print_name="Archive 3", tags="test")
  368. response = await async_client.get("/api/v1/archives/tags")
  369. assert response.status_code == 200
  370. data = response.json()
  371. assert isinstance(data, list)
  372. # Convert to dict for easier lookup
  373. tags_dict = {t["name"]: t["count"] for t in data}
  374. assert tags_dict.get("functional") == 2
  375. assert tags_dict.get("test") == 2
  376. assert tags_dict.get("calibration") == 1
  377. @pytest.mark.asyncio
  378. @pytest.mark.integration
  379. async def test_get_tags_sorted_by_count(
  380. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  381. ):
  382. """Verify tags are sorted by count descending, then by name."""
  383. printer = await printer_factory()
  384. await archive_factory(printer.id, tags="alpha")
  385. await archive_factory(printer.id, tags="beta, alpha")
  386. await archive_factory(printer.id, tags="gamma, beta, alpha")
  387. response = await async_client.get("/api/v1/archives/tags")
  388. assert response.status_code == 200
  389. data = response.json()
  390. # alpha=3, beta=2, gamma=1
  391. assert data[0]["name"] == "alpha"
  392. assert data[0]["count"] == 3
  393. assert data[1]["name"] == "beta"
  394. assert data[1]["count"] == 2
  395. assert data[2]["name"] == "gamma"
  396. assert data[2]["count"] == 1
  397. @pytest.mark.asyncio
  398. @pytest.mark.integration
  399. async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  400. """Verify renaming a tag updates all archives."""
  401. printer = await printer_factory()
  402. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
  403. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
  404. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  405. response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
  406. assert response.status_code == 200
  407. data = response.json()
  408. assert data["affected"] == 2
  409. # Verify the archives were updated
  410. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  411. assert "new-tag" in response.json()["tags"]
  412. assert "old-tag" not in response.json()["tags"]
  413. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  414. assert response.json()["tags"] == "new-tag"
  415. @pytest.mark.asyncio
  416. @pytest.mark.integration
  417. async def test_rename_tag_no_change(self, async_client: AsyncClient):
  418. """Verify renaming to same name returns 0 affected."""
  419. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
  420. assert response.status_code == 200
  421. assert response.json()["affected"] == 0
  422. @pytest.mark.asyncio
  423. @pytest.mark.integration
  424. async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
  425. """Verify renaming to empty name returns error."""
  426. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
  427. assert response.status_code == 400
  428. @pytest.mark.asyncio
  429. @pytest.mark.integration
  430. async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  431. """Verify deleting a tag removes it from all archives."""
  432. printer = await printer_factory()
  433. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
  434. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
  435. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  436. response = await async_client.delete("/api/v1/archives/tags/delete-me")
  437. assert response.status_code == 200
  438. data = response.json()
  439. assert data["affected"] == 2
  440. # Verify the archives were updated
  441. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  442. assert response.json()["tags"] == "keep"
  443. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  444. # Should be None or empty when last tag is removed
  445. assert response.json()["tags"] is None or response.json()["tags"] == ""
  446. @pytest.mark.asyncio
  447. @pytest.mark.integration
  448. async def test_delete_tag_not_found(self, async_client: AsyncClient):
  449. """Verify deleting non-existent tag returns 0 affected."""
  450. response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
  451. assert response.status_code == 200
  452. assert response.json()["affected"] == 0