test_archives_api.py 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810
  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. @pytest.mark.asyncio
  155. @pytest.mark.integration
  156. async def test_soft_delete_preserves_stats_contribution(
  157. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  158. ):
  159. """#1343: deleting an archive without ``purge_stats`` keeps its
  160. contribution in Quick Stats. The row vanishes from listings but the
  161. filament / time / cost totals stay intact.
  162. """
  163. printer = await printer_factory()
  164. await archive_factory(
  165. printer.id,
  166. status="completed",
  167. print_time_seconds=3600,
  168. filament_used_grams=50.0,
  169. cost=1.50,
  170. )
  171. archive_to_delete = await archive_factory(
  172. printer.id,
  173. status="completed",
  174. print_time_seconds=7200,
  175. filament_used_grams=100.0,
  176. cost=3.00,
  177. )
  178. # Pre-delete: stats include both archives.
  179. pre = (await async_client.get("/api/v1/archives/stats")).json()
  180. assert pre["total_prints"] == 2
  181. assert pre["total_filament_grams"] == 150.0
  182. assert pre["total_cost"] == 4.50
  183. # Soft delete (default — no purge_stats param).
  184. resp = await async_client.delete(f"/api/v1/archives/{archive_to_delete.id}")
  185. assert resp.status_code == 200
  186. body = resp.json()
  187. assert body["purged_from_stats"] is False
  188. # Listing hides the deleted archive…
  189. listing = (await async_client.get("/api/v1/archives/")).json()
  190. assert all(a["id"] != archive_to_delete.id for a in listing)
  191. # …but stats still reflect both prints (the whole point of #1343).
  192. post = (await async_client.get("/api/v1/archives/stats")).json()
  193. assert post["total_prints"] == 2
  194. assert post["total_filament_grams"] == 150.0
  195. assert post["total_cost"] == 4.50
  196. @pytest.mark.asyncio
  197. @pytest.mark.integration
  198. async def test_purge_stats_drops_archive_from_quick_stats(
  199. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  200. ):
  201. """#1343: deleting with ``?purge_stats=true`` hard-deletes the row,
  202. dropping its contribution from Quick Stats (the original behaviour,
  203. now opt-in)."""
  204. printer = await printer_factory()
  205. keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
  206. purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
  207. resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
  208. assert resp.status_code == 200
  209. assert resp.json()["purged_from_stats"] is True
  210. stats = (await async_client.get("/api/v1/archives/stats")).json()
  211. assert stats["total_prints"] == 1
  212. assert stats["total_filament_grams"] == 50.0
  213. # The kept archive is still listed.
  214. listing = (await async_client.get("/api/v1/archives/")).json()
  215. assert [a["id"] for a in listing] == [keep.id]
  216. @pytest.mark.asyncio
  217. @pytest.mark.integration
  218. async def test_soft_deleted_archive_404_on_detail(
  219. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  220. ):
  221. """A soft-deleted archive must 404 on GET — a stale bookmark or
  222. direct URL should not expose a row the user has already removed."""
  223. printer = await printer_factory()
  224. archive = await archive_factory(printer.id)
  225. await async_client.delete(f"/api/v1/archives/{archive.id}")
  226. resp = await async_client.get(f"/api/v1/archives/{archive.id}")
  227. assert resp.status_code == 404
  228. @pytest.mark.asyncio
  229. @pytest.mark.integration
  230. async def test_soft_deleted_archive_hidden_from_search(
  231. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  232. ):
  233. """Search must skip soft-deleted archives. Uses the LIKE fallback by
  234. querying a single-character pattern that the SQLite FTS5 rejects, so
  235. the test covers the fallback path that the production FTS path also
  236. respects."""
  237. printer = await printer_factory()
  238. archive = await archive_factory(printer.id, print_name="UniqueSoftDeleteCandidate")
  239. await async_client.delete(f"/api/v1/archives/{archive.id}")
  240. resp = await async_client.get("/api/v1/archives/search?q=UniqueSoftDeleteCandidate")
  241. assert resp.status_code == 200
  242. assert resp.json() == []
  243. # ========================================================================
  244. # Statistics endpoints
  245. # ========================================================================
  246. @pytest.mark.asyncio
  247. @pytest.mark.integration
  248. async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  249. """Verify archive statistics can be retrieved."""
  250. printer = await printer_factory()
  251. await archive_factory(
  252. printer.id,
  253. status="completed",
  254. print_time_seconds=3600,
  255. filament_used_grams=50.0,
  256. )
  257. await archive_factory(
  258. printer.id,
  259. status="completed",
  260. print_time_seconds=7200,
  261. filament_used_grams=100.0,
  262. )
  263. response = await async_client.get("/api/v1/archives/stats")
  264. assert response.status_code == 200
  265. result = response.json()
  266. # Check for actual stats fields
  267. assert "total_prints" in result
  268. assert "successful_prints" in result
  269. class TestArchivesSlimAPI:
  270. """Integration tests for /api/v1/archives/slim endpoint."""
  271. @pytest.mark.asyncio
  272. @pytest.mark.integration
  273. async def test_slim_empty(self, async_client: AsyncClient):
  274. """Verify empty list when no archives exist."""
  275. response = await async_client.get("/api/v1/archives/slim")
  276. assert response.status_code == 200
  277. assert response.json() == []
  278. @pytest.mark.asyncio
  279. @pytest.mark.integration
  280. async def test_slim_returns_only_expected_fields(
  281. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  282. ):
  283. """Verify response contains only slim fields, not full archive data."""
  284. printer = await printer_factory()
  285. await archive_factory(
  286. printer.id,
  287. print_name="Slim Test",
  288. status="completed",
  289. filament_type="PLA",
  290. filament_color="#FF0000",
  291. filament_used_grams=50.0,
  292. print_time_seconds=3600,
  293. cost=1.50,
  294. quantity=2,
  295. )
  296. response = await async_client.get("/api/v1/archives/slim")
  297. assert response.status_code == 200
  298. data = response.json()
  299. assert len(data) == 1
  300. item = data[0]
  301. # Expected fields present
  302. assert item["printer_id"] == printer.id
  303. assert item["print_name"] == "Slim Test"
  304. assert item["status"] == "completed"
  305. assert item["filament_type"] == "PLA"
  306. assert item["filament_color"] == "#FF0000"
  307. assert item["filament_used_grams"] == 50.0
  308. assert item["print_time_seconds"] == 3600
  309. assert item["cost"] == 1.50
  310. assert item["quantity"] == 2
  311. assert "created_at" in item
  312. # Full archive fields must NOT be present
  313. assert "id" not in item
  314. assert "filename" not in item
  315. assert "file_path" not in item
  316. assert "file_size" not in item
  317. assert "extra_data" not in item
  318. assert "notes" not in item
  319. assert "tags" not in item
  320. assert "photos" not in item
  321. assert "thumbnail_path" not in item
  322. assert "content_hash" not in item
  323. assert "duplicates" not in item
  324. assert "duplicate_count" not in item
  325. @pytest.mark.asyncio
  326. @pytest.mark.integration
  327. async def test_slim_computes_actual_time(
  328. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  329. ):
  330. """Verify actual_time_seconds is computed from started_at/completed_at."""
  331. from datetime import datetime, timezone
  332. printer = await printer_factory()
  333. started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
  334. completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # 2 hours = 7200s
  335. await archive_factory(
  336. printer.id,
  337. status="completed",
  338. started_at=started,
  339. completed_at=completed,
  340. )
  341. response = await async_client.get("/api/v1/archives/slim")
  342. assert response.status_code == 200
  343. item = response.json()[0]
  344. assert item["actual_time_seconds"] == 7200
  345. @pytest.mark.asyncio
  346. @pytest.mark.integration
  347. async def test_slim_actual_time_null_for_failed(
  348. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  349. ):
  350. """Verify actual_time_seconds is null for non-completed prints."""
  351. from datetime import datetime, timezone
  352. printer = await printer_factory()
  353. await archive_factory(
  354. printer.id,
  355. status="failed",
  356. started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
  357. completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
  358. )
  359. response = await async_client.get("/api/v1/archives/slim")
  360. assert response.status_code == 200
  361. item = response.json()[0]
  362. assert item["actual_time_seconds"] is None
  363. @pytest.mark.asyncio
  364. @pytest.mark.integration
  365. async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  366. """Verify date_from and date_to filters work."""
  367. from datetime import datetime, timezone
  368. printer = await printer_factory()
  369. await archive_factory(
  370. printer.id,
  371. print_name="Old Print",
  372. created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
  373. )
  374. await archive_factory(
  375. printer.id,
  376. print_name="New Print",
  377. created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
  378. )
  379. # Filter to only June 2024
  380. response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
  381. assert response.status_code == 200
  382. data = response.json()
  383. assert len(data) == 1
  384. assert data[0]["print_name"] == "New Print"
  385. @pytest.mark.asyncio
  386. @pytest.mark.integration
  387. async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  388. """Verify limit and offset work."""
  389. printer = await printer_factory()
  390. for i in range(5):
  391. await archive_factory(printer.id, print_name=f"Print {i}")
  392. response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
  393. assert response.status_code == 200
  394. assert len(response.json()) == 2
  395. class TestArchiveDataIntegrity:
  396. """Tests for archive data integrity."""
  397. @pytest.mark.asyncio
  398. @pytest.mark.integration
  399. async def test_archive_linked_to_printer(
  400. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  401. ):
  402. """Verify archive is properly linked to printer."""
  403. printer = await printer_factory(name="My Printer")
  404. archive = await archive_factory(printer.id)
  405. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  406. assert response.status_code == 200
  407. result = response.json()
  408. assert result["printer_id"] == printer.id
  409. @pytest.mark.asyncio
  410. @pytest.mark.integration
  411. async def test_archive_stores_print_data(
  412. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  413. ):
  414. """Verify archive stores all print data correctly."""
  415. printer = await printer_factory()
  416. archive = await archive_factory(
  417. printer.id,
  418. print_name="Test Print",
  419. filename="test.3mf",
  420. status="completed",
  421. filament_type="PLA",
  422. filament_used_grams=75.5,
  423. print_time_seconds=5400,
  424. )
  425. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  426. assert response.status_code == 200
  427. result = response.json()
  428. assert result["print_name"] == "Test Print"
  429. assert result["filename"] == "test.3mf"
  430. assert result["status"] == "completed"
  431. assert result["filament_type"] == "PLA"
  432. assert result["filament_used_grams"] == 75.5
  433. assert result["print_time_seconds"] == 5400
  434. @pytest.mark.asyncio
  435. @pytest.mark.integration
  436. async def test_archive_update_persists(
  437. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  438. ):
  439. """CRITICAL: Verify archive updates persist."""
  440. printer = await printer_factory()
  441. archive = await archive_factory(printer.id, notes="Original notes")
  442. # Update
  443. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
  444. # Verify persistence
  445. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  446. result = response.json()
  447. assert result["notes"] == "Updated notes"
  448. assert result["is_favorite"] is True
  449. class TestArchiveF3DEndpoints:
  450. """Tests for F3D (Fusion 360 design file) attachment endpoints."""
  451. @pytest.mark.asyncio
  452. @pytest.mark.integration
  453. async def test_archive_response_includes_f3d_path(
  454. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  455. ):
  456. """Verify f3d_path is included in archive response."""
  457. printer = await printer_factory()
  458. archive = await archive_factory(printer.id, f3d_path="archives/test/design.f3d")
  459. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  460. assert response.status_code == 200
  461. result = response.json()
  462. assert "f3d_path" in result
  463. assert result["f3d_path"] == "archives/test/design.f3d"
  464. @pytest.mark.asyncio
  465. @pytest.mark.integration
  466. async def test_archive_response_f3d_path_null_when_not_set(
  467. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  468. ):
  469. """Verify f3d_path is null when no F3D file attached."""
  470. printer = await printer_factory()
  471. archive = await archive_factory(printer.id)
  472. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  473. assert response.status_code == 200
  474. result = response.json()
  475. assert "f3d_path" in result
  476. assert result["f3d_path"] is None
  477. @pytest.mark.asyncio
  478. @pytest.mark.integration
  479. async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):
  480. """Verify 404 when uploading F3D to non-existent archive."""
  481. # Create a minimal file-like upload
  482. files = {"file": ("design.f3d", b"fake f3d content", "application/octet-stream")}
  483. response = await async_client.post("/api/v1/archives/9999/f3d", files=files)
  484. assert response.status_code == 404
  485. @pytest.mark.asyncio
  486. @pytest.mark.integration
  487. async def test_download_f3d_not_found_when_no_file(
  488. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  489. ):
  490. """Verify 404 when downloading F3D from archive without F3D file."""
  491. printer = await printer_factory()
  492. archive = await archive_factory(printer.id)
  493. response = await async_client.get(f"/api/v1/archives/{archive.id}/f3d")
  494. assert response.status_code == 404
  495. @pytest.mark.asyncio
  496. @pytest.mark.integration
  497. async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):
  498. """Verify 404 when downloading F3D from non-existent archive."""
  499. response = await async_client.get("/api/v1/archives/9999/f3d")
  500. assert response.status_code == 404
  501. @pytest.mark.asyncio
  502. @pytest.mark.integration
  503. async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):
  504. """Verify 404 when deleting F3D from non-existent archive."""
  505. response = await async_client.delete("/api/v1/archives/9999/f3d")
  506. assert response.status_code == 404
  507. @pytest.mark.asyncio
  508. @pytest.mark.integration
  509. async def test_delete_f3d_when_no_file(
  510. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  511. ):
  512. """Verify 404 when deleting F3D from archive without F3D file."""
  513. printer = await printer_factory()
  514. archive = await archive_factory(printer.id)
  515. response = await async_client.delete(f"/api/v1/archives/{archive.id}/f3d")
  516. assert response.status_code == 404
  517. @pytest.mark.asyncio
  518. @pytest.mark.integration
  519. async def test_list_archives_includes_f3d_path(
  520. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  521. ):
  522. """Verify f3d_path is included in archive list responses."""
  523. printer = await printer_factory()
  524. await archive_factory(printer.id, print_name="With F3D", f3d_path="archives/test/design.f3d")
  525. await archive_factory(printer.id, print_name="Without F3D")
  526. response = await async_client.get("/api/v1/archives/")
  527. assert response.status_code == 200
  528. data = response.json()
  529. assert len(data) >= 2
  530. with_f3d = next((a for a in data if a["print_name"] == "With F3D"), None)
  531. without_f3d = next((a for a in data if a["print_name"] == "Without F3D"), None)
  532. assert with_f3d is not None
  533. assert with_f3d["f3d_path"] == "archives/test/design.f3d"
  534. assert without_f3d is not None
  535. assert without_f3d["f3d_path"] is None
  536. # ========================================================================
  537. # Multi-Plate 3MF endpoints (Issue #93)
  538. # ========================================================================
  539. @pytest.mark.asyncio
  540. @pytest.mark.integration
  541. async def test_get_archive_plates_not_found(self, async_client: AsyncClient):
  542. """Verify 404 when fetching plates for non-existent archive."""
  543. response = await async_client.get("/api/v1/archives/999999/plates")
  544. assert response.status_code == 404
  545. @pytest.mark.asyncio
  546. @pytest.mark.integration
  547. async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):
  548. """Verify 404 when fetching plate thumbnail for non-existent archive."""
  549. response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1")
  550. assert response.status_code == 404
  551. @pytest.mark.asyncio
  552. @pytest.mark.integration
  553. async def test_filament_requirements_not_found(self, async_client: AsyncClient):
  554. """Verify filament-requirements returns 404 for non-existent archive."""
  555. response = await async_client.get("/api/v1/archives/999999/filament-requirements")
  556. assert response.status_code == 404
  557. @pytest.mark.asyncio
  558. @pytest.mark.integration
  559. async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):
  560. """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
  561. response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
  562. assert response.status_code == 404
  563. # ========================================================================
  564. # Tag Management endpoints (Issue #183)
  565. # ========================================================================
  566. @pytest.mark.asyncio
  567. @pytest.mark.integration
  568. async def test_get_tags_empty(self, async_client: AsyncClient):
  569. """Verify empty list when no tags exist."""
  570. response = await async_client.get("/api/v1/archives/tags")
  571. assert response.status_code == 200
  572. data = response.json()
  573. assert isinstance(data, list)
  574. assert len(data) == 0
  575. @pytest.mark.asyncio
  576. @pytest.mark.integration
  577. async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  578. """Verify tags are returned with counts."""
  579. printer = await printer_factory()
  580. await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
  581. await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
  582. await archive_factory(printer.id, print_name="Archive 3", tags="test")
  583. response = await async_client.get("/api/v1/archives/tags")
  584. assert response.status_code == 200
  585. data = response.json()
  586. assert isinstance(data, list)
  587. # Convert to dict for easier lookup
  588. tags_dict = {t["name"]: t["count"] for t in data}
  589. assert tags_dict.get("functional") == 2
  590. assert tags_dict.get("test") == 2
  591. assert tags_dict.get("calibration") == 1
  592. @pytest.mark.asyncio
  593. @pytest.mark.integration
  594. async def test_get_tags_sorted_by_count(
  595. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  596. ):
  597. """Verify tags are sorted by count descending, then by name."""
  598. printer = await printer_factory()
  599. await archive_factory(printer.id, tags="alpha")
  600. await archive_factory(printer.id, tags="beta, alpha")
  601. await archive_factory(printer.id, tags="gamma, beta, alpha")
  602. response = await async_client.get("/api/v1/archives/tags")
  603. assert response.status_code == 200
  604. data = response.json()
  605. # alpha=3, beta=2, gamma=1
  606. assert data[0]["name"] == "alpha"
  607. assert data[0]["count"] == 3
  608. assert data[1]["name"] == "beta"
  609. assert data[1]["count"] == 2
  610. assert data[2]["name"] == "gamma"
  611. assert data[2]["count"] == 1
  612. @pytest.mark.asyncio
  613. @pytest.mark.integration
  614. async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  615. """Verify renaming a tag updates all archives."""
  616. printer = await printer_factory()
  617. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
  618. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
  619. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  620. response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
  621. assert response.status_code == 200
  622. data = response.json()
  623. assert data["affected"] == 2
  624. # Verify the archives were updated
  625. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  626. assert "new-tag" in response.json()["tags"]
  627. assert "old-tag" not in response.json()["tags"]
  628. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  629. assert response.json()["tags"] == "new-tag"
  630. @pytest.mark.asyncio
  631. @pytest.mark.integration
  632. async def test_rename_tag_no_change(self, async_client: AsyncClient):
  633. """Verify renaming to same name returns 0 affected."""
  634. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
  635. assert response.status_code == 200
  636. assert response.json()["affected"] == 0
  637. @pytest.mark.asyncio
  638. @pytest.mark.integration
  639. async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
  640. """Verify renaming to empty name returns error."""
  641. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
  642. assert response.status_code == 400
  643. @pytest.mark.asyncio
  644. @pytest.mark.integration
  645. async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  646. """Verify deleting a tag removes it from all archives."""
  647. printer = await printer_factory()
  648. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
  649. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
  650. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  651. response = await async_client.delete("/api/v1/archives/tags/delete-me")
  652. assert response.status_code == 200
  653. data = response.json()
  654. assert data["affected"] == 2
  655. # Verify the archives were updated
  656. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  657. assert response.json()["tags"] == "keep"
  658. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  659. # Should be None or empty when last tag is removed
  660. assert response.json()["tags"] is None or response.json()["tags"] == ""
  661. @pytest.mark.asyncio
  662. @pytest.mark.integration
  663. async def test_delete_tag_not_found(self, async_client: AsyncClient):
  664. """Verify deleting non-existent tag returns 0 affected."""
  665. response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
  666. assert response.status_code == 200
  667. assert response.json()["affected"] == 0