test_archives_api.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159
  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. @pytest.mark.asyncio
  134. @pytest.mark.integration
  135. async def test_update_archive_failure_reason_mirrors_to_print_log_entry(
  136. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  137. ):
  138. """#1444: PATCH /archives/{id} with failure_reason must mirror to the
  139. latest PrintLogEntry so the Stats page Failure Analysis widget
  140. (which reads PrintLogEntry.failure_reason) reflects the user's
  141. reclassification instead of showing "Unknown" forever.
  142. """
  143. from sqlalchemy import select
  144. from backend.app.models.print_log import PrintLogEntry
  145. printer = await printer_factory()
  146. # archive_factory auto-creates a matching PrintLogEntry (failure_reason
  147. # carried from the archive, which is NULL here — same shape as the bug
  148. # repro: print completed → log entry written with NULL → user goes to
  149. # classify the failure afterwards).
  150. archive = await archive_factory(printer.id, print_name="Failed Print", status="failed", run_status="failed")
  151. response = await async_client.patch(
  152. f"/api/v1/archives/{archive.id}",
  153. json={"failure_reason": "Adhesion failure"},
  154. )
  155. assert response.status_code == 200
  156. result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  157. mirrored = result.scalar_one()
  158. assert mirrored.failure_reason == "Adhesion failure"
  159. @pytest.mark.asyncio
  160. @pytest.mark.integration
  161. async def test_update_archive_status_mirrors_to_print_log_entry(
  162. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  163. ):
  164. """#1444: PATCH /archives/{id} with status must mirror to the latest
  165. PrintLogEntry so stats that filter on PrintLogEntry.status see the
  166. user's reclassification.
  167. """
  168. from sqlalchemy import select
  169. from backend.app.models.print_log import PrintLogEntry
  170. printer = await printer_factory()
  171. archive = await archive_factory(printer.id, run_status="completed")
  172. response = await async_client.patch(
  173. f"/api/v1/archives/{archive.id}",
  174. json={"status": "failed"},
  175. )
  176. assert response.status_code == 200
  177. result = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  178. mirrored = result.scalar_one()
  179. assert mirrored.status == "failed"
  180. @pytest.mark.asyncio
  181. @pytest.mark.integration
  182. async def test_update_archive_failure_reason_only_touches_latest_entry(
  183. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  184. ):
  185. """#1444: For an archive with multiple runs (reprints), only the
  186. latest PrintLogEntry should receive the reclassification. Earlier
  187. runs were classified at their own time and must not be retroactively
  188. overwritten.
  189. """
  190. from backend.app.models.print_log import PrintLogEntry
  191. printer = await printer_factory()
  192. # First run — created by the factory's auto-run with its own reason.
  193. archive = await archive_factory(printer.id, status="failed", run_status="failed")
  194. from sqlalchemy import select
  195. first_run = (
  196. await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  197. ).scalar_one()
  198. first_run.failure_reason = "Filament tangle"
  199. await db_session.commit()
  200. # Second run — the reprint that just finished with NULL classification.
  201. latest_run = PrintLogEntry(archive_id=archive.id, status="failed", failure_reason=None)
  202. db_session.add(latest_run)
  203. await db_session.commit()
  204. response = await async_client.patch(
  205. f"/api/v1/archives/{archive.id}",
  206. json={"failure_reason": "Adhesion failure"},
  207. )
  208. assert response.status_code == 200
  209. await db_session.refresh(first_run)
  210. await db_session.refresh(latest_run)
  211. assert first_run.failure_reason == "Filament tangle"
  212. assert latest_run.failure_reason == "Adhesion failure"
  213. # ========================================================================
  214. # Delete endpoints
  215. # ========================================================================
  216. @pytest.mark.asyncio
  217. @pytest.mark.integration
  218. async def test_delete_archive(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  219. """Verify archive can be deleted."""
  220. printer = await printer_factory()
  221. archive = await archive_factory(printer.id)
  222. archive_id = archive.id
  223. response = await async_client.delete(f"/api/v1/archives/{archive_id}")
  224. assert response.status_code == 200
  225. # Verify deleted
  226. response = await async_client.get(f"/api/v1/archives/{archive_id}")
  227. assert response.status_code == 404
  228. @pytest.mark.asyncio
  229. @pytest.mark.integration
  230. async def test_delete_nonexistent_archive(self, async_client: AsyncClient):
  231. """Verify deleting non-existent archive returns 404."""
  232. response = await async_client.delete("/api/v1/archives/9999")
  233. assert response.status_code == 404
  234. @pytest.mark.asyncio
  235. @pytest.mark.integration
  236. async def test_soft_delete_preserves_stats_contribution(
  237. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  238. ):
  239. """#1343: deleting an archive without ``purge_stats`` keeps its
  240. contribution in Quick Stats. The row vanishes from listings but the
  241. filament / time / cost totals stay intact.
  242. """
  243. printer = await printer_factory()
  244. await archive_factory(
  245. printer.id,
  246. status="completed",
  247. print_time_seconds=3600,
  248. filament_used_grams=50.0,
  249. cost=1.50,
  250. )
  251. archive_to_delete = await archive_factory(
  252. printer.id,
  253. status="completed",
  254. print_time_seconds=7200,
  255. filament_used_grams=100.0,
  256. cost=3.00,
  257. )
  258. # Pre-delete: stats include both archives.
  259. pre = (await async_client.get("/api/v1/archives/stats")).json()
  260. assert pre["total_prints"] == 2
  261. assert pre["total_filament_grams"] == 150.0
  262. assert pre["total_cost"] == 4.50
  263. # Soft delete (default — no purge_stats param).
  264. resp = await async_client.delete(f"/api/v1/archives/{archive_to_delete.id}")
  265. assert resp.status_code == 200
  266. body = resp.json()
  267. assert body["purged_from_stats"] is False
  268. # Listing hides the deleted archive…
  269. listing = (await async_client.get("/api/v1/archives/")).json()
  270. assert all(a["id"] != archive_to_delete.id for a in listing)
  271. # …but stats still reflect both prints (the whole point of #1343).
  272. post = (await async_client.get("/api/v1/archives/stats")).json()
  273. assert post["total_prints"] == 2
  274. assert post["total_filament_grams"] == 150.0
  275. assert post["total_cost"] == 4.50
  276. @pytest.mark.asyncio
  277. @pytest.mark.integration
  278. async def test_soft_delete_clears_thumbnail_path_on_linked_log_entries(
  279. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  280. ):
  281. """#1348 follow-up: soft-deleting an archive removes its files from disk;
  282. the cached thumbnail_path on linked PrintLogEntry rows must be NULLed
  283. in the same transaction so the print-log view doesn't 404-storm on the
  284. now-deleted thumbnail file."""
  285. from sqlalchemy import select
  286. from backend.app.models.print_log import PrintLogEntry
  287. printer = await printer_factory()
  288. archive = await archive_factory(
  289. printer.id,
  290. status="completed",
  291. thumbnail_path="archives/test/test_print/thumbnail.png",
  292. )
  293. # The factory's auto-PrintLogEntry doesn't copy thumbnail_path; set it
  294. # manually to mirror what the production write_log_entry path stores.
  295. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  296. run = run_query.scalar_one()
  297. run.thumbnail_path = "archives/test/test_print/thumbnail.png"
  298. await db_session.commit()
  299. assert run.thumbnail_path is not None
  300. resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
  301. assert resp.status_code == 200
  302. assert resp.json()["purged_from_stats"] is False
  303. await db_session.refresh(run)
  304. assert run.thumbnail_path is None, "soft-delete must NULL thumbnail_path on linked log entry"
  305. # The log entry itself survives the soft delete (its filament/cost
  306. # contribution still needs to flow into stats per #1343).
  307. assert run.id is not None
  308. assert run.archive_id == archive.id
  309. @pytest.mark.asyncio
  310. @pytest.mark.integration
  311. async def test_hard_delete_clears_thumbnail_path_before_fk_cascade(
  312. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  313. ):
  314. """#1348 follow-up: the auto-purge sweeper (and any caller of
  315. ArchiveService.delete_archive) hard-deletes the archive row but leaves
  316. PrintLogEntry rows alive via ON DELETE SET NULL. The eager
  317. thumbnail_path clear must run inside delete_archive so even orphaned
  318. log entries don't surface stale paths."""
  319. from sqlalchemy import select
  320. from backend.app.models.print_log import PrintLogEntry
  321. from backend.app.services.archive import ArchiveService
  322. printer = await printer_factory()
  323. archive = await archive_factory(
  324. printer.id,
  325. status="completed",
  326. thumbnail_path="archives/test/test_print/thumbnail.png",
  327. )
  328. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  329. run = run_query.scalar_one()
  330. run.thumbnail_path = "archives/test/test_print/thumbnail.png"
  331. await db_session.commit()
  332. run_id = run.id
  333. service = ArchiveService(db_session)
  334. assert await service.delete_archive(archive.id) is True
  335. # Log entry survives the hard-delete (the FK is ON DELETE SET NULL
  336. # in production; SQLite test config doesn't enable foreign_keys=ON
  337. # by default so archive_id may still be set, but the row itself
  338. # remains for audit). The thumbnail_path was cleared eagerly by
  339. # _null_print_log_thumbnail_paths before db.delete(archive).
  340. refetch = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.id == run_id))
  341. survivor = refetch.scalar_one()
  342. assert survivor.thumbnail_path is None, (
  343. "delete_archive must NULL thumbnail_path before removing the archive row"
  344. )
  345. @pytest.mark.asyncio
  346. @pytest.mark.integration
  347. async def test_print_log_thumbnail_route_lazy_nulls_missing_file(
  348. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  349. ):
  350. """#1348 follow-up: GET /print-log/{id}/thumbnail self-heals when the
  351. thumbnail_path on a log entry points at a missing file (failed print
  352. whose thumbnail was never written, or a stale path that escaped the
  353. delete-time cleanup)."""
  354. from sqlalchemy import select
  355. from backend.app.models.print_log import PrintLogEntry
  356. printer = await printer_factory()
  357. archive = await archive_factory(printer.id, status="failed")
  358. run_query = await db_session.execute(select(PrintLogEntry).where(PrintLogEntry.archive_id == archive.id))
  359. run = run_query.scalar_one()
  360. # Path points at a file that never existed (failed-print case where
  361. # archive.thumbnail_path was set but the extractor never produced one).
  362. run.thumbnail_path = "archives/missing/never_written/thumbnail.png"
  363. await db_session.commit()
  364. # Auth is disabled in the integration test config, so the stream-token
  365. # guard is bypassed — the route runs the lazy-NULL branch directly.
  366. resp = await async_client.get(f"/api/v1/print-log/{run.id}/thumbnail")
  367. assert resp.status_code == 404
  368. await db_session.refresh(run)
  369. assert run.thumbnail_path is None, "missing file must self-heal to NULL"
  370. @pytest.mark.asyncio
  371. @pytest.mark.integration
  372. async def test_purge_stats_drops_archive_from_quick_stats(
  373. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  374. ):
  375. """#1343: deleting with ``?purge_stats=true`` hard-deletes the row,
  376. dropping its contribution from Quick Stats (the original behaviour,
  377. now opt-in)."""
  378. printer = await printer_factory()
  379. keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
  380. purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
  381. resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
  382. assert resp.status_code == 200
  383. assert resp.json()["purged_from_stats"] is True
  384. stats = (await async_client.get("/api/v1/archives/stats")).json()
  385. assert stats["total_prints"] == 1
  386. assert stats["total_filament_grams"] == 50.0
  387. # The kept archive is still listed.
  388. listing = (await async_client.get("/api/v1/archives/")).json()
  389. assert [a["id"] for a in listing] == [keep.id]
  390. @pytest.mark.asyncio
  391. @pytest.mark.integration
  392. async def test_soft_deleted_archive_404_on_detail(
  393. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  394. ):
  395. """A soft-deleted archive must 404 on GET — a stale bookmark or
  396. direct URL should not expose a row the user has already removed."""
  397. printer = await printer_factory()
  398. archive = await archive_factory(printer.id)
  399. await async_client.delete(f"/api/v1/archives/{archive.id}")
  400. resp = await async_client.get(f"/api/v1/archives/{archive.id}")
  401. assert resp.status_code == 404
  402. @pytest.mark.asyncio
  403. @pytest.mark.integration
  404. async def test_soft_deleted_archive_hidden_from_search(
  405. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  406. ):
  407. """Search must skip soft-deleted archives. Uses the LIKE fallback by
  408. querying a single-character pattern that the SQLite FTS5 rejects, so
  409. the test covers the fallback path that the production FTS path also
  410. respects."""
  411. printer = await printer_factory()
  412. archive = await archive_factory(printer.id, print_name="UniqueSoftDeleteCandidate")
  413. await async_client.delete(f"/api/v1/archives/{archive.id}")
  414. resp = await async_client.get("/api/v1/archives/search?q=UniqueSoftDeleteCandidate")
  415. assert resp.status_code == 200
  416. assert resp.json() == []
  417. # ========================================================================
  418. # Statistics endpoints
  419. # ========================================================================
  420. @pytest.mark.asyncio
  421. @pytest.mark.integration
  422. async def test_get_archive_stats(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  423. """Verify archive statistics can be retrieved."""
  424. printer = await printer_factory()
  425. await archive_factory(
  426. printer.id,
  427. status="completed",
  428. print_time_seconds=3600,
  429. filament_used_grams=50.0,
  430. )
  431. await archive_factory(
  432. printer.id,
  433. status="completed",
  434. print_time_seconds=7200,
  435. filament_used_grams=100.0,
  436. )
  437. response = await async_client.get("/api/v1/archives/stats")
  438. assert response.status_code == 200
  439. result = response.json()
  440. # Check for actual stats fields
  441. assert "total_prints" in result
  442. assert "successful_prints" in result
  443. class TestArchivesSlimAPI:
  444. """Integration tests for /api/v1/archives/slim endpoint."""
  445. @pytest.mark.asyncio
  446. @pytest.mark.integration
  447. async def test_slim_empty(self, async_client: AsyncClient):
  448. """Verify empty list when no archives exist."""
  449. response = await async_client.get("/api/v1/archives/slim")
  450. assert response.status_code == 200
  451. assert response.json() == []
  452. @pytest.mark.asyncio
  453. @pytest.mark.integration
  454. async def test_slim_returns_only_expected_fields(
  455. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  456. ):
  457. """Verify response contains only slim fields, not full archive data."""
  458. printer = await printer_factory()
  459. await archive_factory(
  460. printer.id,
  461. print_name="Slim Test",
  462. status="completed",
  463. filament_type="PLA",
  464. filament_color="#FF0000",
  465. filament_used_grams=50.0,
  466. print_time_seconds=3600,
  467. cost=1.50,
  468. quantity=2,
  469. )
  470. response = await async_client.get("/api/v1/archives/slim")
  471. assert response.status_code == 200
  472. data = response.json()
  473. assert len(data) == 1
  474. item = data[0]
  475. # Expected fields present
  476. assert item["printer_id"] == printer.id
  477. assert item["print_name"] == "Slim Test"
  478. assert item["status"] == "completed"
  479. assert item["filament_type"] == "PLA"
  480. assert item["filament_color"] == "#FF0000"
  481. assert item["filament_used_grams"] == 50.0
  482. assert item["print_time_seconds"] == 3600
  483. assert item["cost"] == 1.50
  484. # quantity is per-event semantics now (each PrintLogEntry = one run);
  485. # the archive's quantity field is no longer surfaced through this
  486. # endpoint after the #1390 per-event migration.
  487. assert item["quantity"] == 1
  488. assert "created_at" in item
  489. # Full archive fields must NOT be present
  490. assert "id" not in item
  491. assert "filename" not in item
  492. assert "file_path" not in item
  493. assert "file_size" not in item
  494. assert "extra_data" not in item
  495. assert "notes" not in item
  496. assert "tags" not in item
  497. assert "photos" not in item
  498. assert "thumbnail_path" not in item
  499. assert "content_hash" not in item
  500. assert "duplicates" not in item
  501. assert "duplicate_count" not in item
  502. @pytest.mark.asyncio
  503. @pytest.mark.integration
  504. async def test_slim_computes_actual_time(
  505. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  506. ):
  507. """Verify actual_time_seconds is computed from started_at/completed_at."""
  508. from datetime import datetime, timezone
  509. printer = await printer_factory()
  510. started = datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc)
  511. completed = datetime(2024, 1, 1, 12, 0, 0, tzinfo=timezone.utc) # 2 hours = 7200s
  512. await archive_factory(
  513. printer.id,
  514. status="completed",
  515. started_at=started,
  516. completed_at=completed,
  517. )
  518. response = await async_client.get("/api/v1/archives/slim")
  519. assert response.status_code == 200
  520. item = response.json()[0]
  521. assert item["actual_time_seconds"] == 7200
  522. @pytest.mark.asyncio
  523. @pytest.mark.integration
  524. async def test_slim_actual_time_for_failed_includes_elapsed(
  525. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  526. ):
  527. """Failed prints report measured elapsed time so Printer Stats By Time
  528. matches Quick Stats Print Time (#1390). Previously this returned null
  529. and the frontend fell back to the slicer estimate, double-counting the
  530. unfinished portion of the print."""
  531. from datetime import datetime, timezone
  532. printer = await printer_factory()
  533. await archive_factory(
  534. printer.id,
  535. status="failed",
  536. started_at=datetime(2024, 1, 1, 10, 0, 0, tzinfo=timezone.utc),
  537. completed_at=datetime(2024, 1, 1, 11, 0, 0, tzinfo=timezone.utc),
  538. )
  539. response = await async_client.get("/api/v1/archives/slim")
  540. assert response.status_code == 200
  541. item = response.json()[0]
  542. assert item["actual_time_seconds"] == 3600
  543. @pytest.mark.asyncio
  544. @pytest.mark.integration
  545. async def test_slim_date_filtering(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  546. """Verify date_from and date_to filters work."""
  547. from datetime import datetime, timezone
  548. printer = await printer_factory()
  549. await archive_factory(
  550. printer.id,
  551. print_name="Old Print",
  552. created_at=datetime(2024, 1, 1, tzinfo=timezone.utc),
  553. )
  554. await archive_factory(
  555. printer.id,
  556. print_name="New Print",
  557. created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
  558. )
  559. # Filter to only June 2024
  560. response = await async_client.get("/api/v1/archives/slim?date_from=2024-06-01&date_to=2024-06-30")
  561. assert response.status_code == 200
  562. data = response.json()
  563. assert len(data) == 1
  564. assert data[0]["print_name"] == "New Print"
  565. @pytest.mark.asyncio
  566. @pytest.mark.integration
  567. async def test_slim_pagination(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  568. """Verify limit and offset work."""
  569. printer = await printer_factory()
  570. for i in range(5):
  571. await archive_factory(printer.id, print_name=f"Print {i}")
  572. response = await async_client.get("/api/v1/archives/slim?limit=2&offset=0")
  573. assert response.status_code == 200
  574. assert len(response.json()) == 2
  575. @pytest.mark.asyncio
  576. @pytest.mark.integration
  577. async def test_slim_counts_reprints_as_separate_rows(
  578. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  579. ):
  580. """Reprints add events even though the archive row is overwritten (#1390).
  581. Before the per-event migration, /archives/slim returned one row per
  582. archive — so an archive that had been reprinted three times appeared
  583. once and undercounted Filament Used / Cost / Time. The endpoint must
  584. now return one row per logged event.
  585. """
  586. from backend.app.models.print_log import PrintLogEntry
  587. printer = await printer_factory()
  588. archive = await archive_factory(
  589. printer.id,
  590. print_name="Reprinted Model",
  591. filament_used_grams=50.0,
  592. cost=1.50,
  593. )
  594. # archive_factory synthesizes one event; add two more to simulate
  595. # the same archive being reprinted twice more.
  596. for _ in range(2):
  597. db_session.add(
  598. PrintLogEntry(
  599. archive_id=archive.id,
  600. printer_id=archive.printer_id,
  601. status="completed",
  602. filament_type=archive.filament_type,
  603. filament_used_grams=archive.filament_used_grams,
  604. cost=archive.cost,
  605. print_name=archive.print_name,
  606. )
  607. )
  608. await db_session.commit()
  609. response = await async_client.get("/api/v1/archives/slim")
  610. assert response.status_code == 200
  611. data = response.json()
  612. assert len(data) == 3, "Each reprint must contribute one row"
  613. total_filament = sum(item["filament_used_grams"] or 0 for item in data)
  614. assert total_filament == 150.0, "Sum across events must reflect all three runs"
  615. @pytest.mark.asyncio
  616. @pytest.mark.integration
  617. async def test_slim_includes_orphan_events(self, async_client: AsyncClient, printer_factory, db_session):
  618. """Events whose archive was hard-deleted still appear (#1390).
  619. After ON DELETE SET NULL the event row survives with archive_id=NULL.
  620. The slim endpoint must keep counting it so Quick Stats and the
  621. archive-iterating widgets stay aligned.
  622. """
  623. from backend.app.models.print_log import PrintLogEntry
  624. printer = await printer_factory()
  625. db_session.add(
  626. PrintLogEntry(
  627. archive_id=None,
  628. printer_id=printer.id,
  629. status="completed",
  630. filament_type="PETG",
  631. filament_used_grams=25.0,
  632. cost=0.75,
  633. print_name="Orphaned Print",
  634. )
  635. )
  636. await db_session.commit()
  637. response = await async_client.get("/api/v1/archives/slim")
  638. assert response.status_code == 200
  639. data = response.json()
  640. assert len(data) == 1
  641. assert data[0]["print_name"] == "Orphaned Print"
  642. assert data[0]["filament_used_grams"] == 25.0
  643. # print_time_seconds (sliced estimate) comes from the archive table,
  644. # which orphans no longer have — must surface as null gracefully.
  645. assert data[0]["print_time_seconds"] is None
  646. class TestFailureAnalysisAPI:
  647. """Per-event failure analysis (#1390)."""
  648. @pytest.mark.asyncio
  649. @pytest.mark.integration
  650. async def test_failure_analysis_counts_reprints_and_orphans(
  651. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  652. ):
  653. """Failure analysis aggregates per event, not per archive.
  654. Verifies the dual fix for #1390: a reprint that adds a second failed
  655. event must count twice, and an orphan failed event (archive deleted)
  656. must still appear in the totals.
  657. """
  658. from backend.app.models.print_log import PrintLogEntry
  659. printer = await printer_factory()
  660. archive = await archive_factory(
  661. printer.id,
  662. print_name="Failing Model",
  663. status="failed",
  664. failure_reason="filament_runout",
  665. )
  666. # Add a second failed event for the same archive (a reprint that also
  667. # failed) and one orphan failed event (archive was deleted).
  668. db_session.add(
  669. PrintLogEntry(
  670. archive_id=archive.id,
  671. printer_id=printer.id,
  672. status="failed",
  673. failure_reason="filament_runout",
  674. filament_type=archive.filament_type,
  675. print_name=archive.print_name,
  676. )
  677. )
  678. db_session.add(
  679. PrintLogEntry(
  680. archive_id=None,
  681. printer_id=printer.id,
  682. status="failed",
  683. failure_reason="bed_adhesion",
  684. filament_type="PETG",
  685. print_name="Orphaned Failed Print",
  686. )
  687. )
  688. await db_session.commit()
  689. response = await async_client.get("/api/v1/archives/analysis/failures")
  690. assert response.status_code == 200
  691. result = response.json()
  692. assert result["total_prints"] == 3
  693. assert result["failed_prints"] == 3
  694. assert result["failures_by_reason"]["filament_runout"] == 2
  695. assert result["failures_by_reason"]["bed_adhesion"] == 1
  696. class TestArchiveDataIntegrity:
  697. """Tests for archive data integrity."""
  698. @pytest.mark.asyncio
  699. @pytest.mark.integration
  700. async def test_archive_linked_to_printer(
  701. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  702. ):
  703. """Verify archive is properly linked to printer."""
  704. printer = await printer_factory(name="My Printer")
  705. archive = await archive_factory(printer.id)
  706. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  707. assert response.status_code == 200
  708. result = response.json()
  709. assert result["printer_id"] == printer.id
  710. @pytest.mark.asyncio
  711. @pytest.mark.integration
  712. async def test_archive_stores_print_data(
  713. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  714. ):
  715. """Verify archive stores all print data correctly."""
  716. printer = await printer_factory()
  717. archive = await archive_factory(
  718. printer.id,
  719. print_name="Test Print",
  720. filename="test.3mf",
  721. status="completed",
  722. filament_type="PLA",
  723. filament_used_grams=75.5,
  724. print_time_seconds=5400,
  725. )
  726. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  727. assert response.status_code == 200
  728. result = response.json()
  729. assert result["print_name"] == "Test Print"
  730. assert result["filename"] == "test.3mf"
  731. assert result["status"] == "completed"
  732. assert result["filament_type"] == "PLA"
  733. assert result["filament_used_grams"] == 75.5
  734. assert result["print_time_seconds"] == 5400
  735. @pytest.mark.asyncio
  736. @pytest.mark.integration
  737. async def test_archive_update_persists(
  738. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  739. ):
  740. """CRITICAL: Verify archive updates persist."""
  741. printer = await printer_factory()
  742. archive = await archive_factory(printer.id, notes="Original notes")
  743. # Update
  744. await async_client.patch(f"/api/v1/archives/{archive.id}", json={"notes": "Updated notes", "is_favorite": True})
  745. # Verify persistence
  746. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  747. result = response.json()
  748. assert result["notes"] == "Updated notes"
  749. assert result["is_favorite"] is True
  750. class TestArchiveF3DEndpoints:
  751. """Tests for F3D (Fusion 360 design file) attachment endpoints."""
  752. @pytest.mark.asyncio
  753. @pytest.mark.integration
  754. async def test_archive_response_includes_f3d_path(
  755. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  756. ):
  757. """Verify f3d_path is included in archive response."""
  758. printer = await printer_factory()
  759. archive = await archive_factory(printer.id, f3d_path="archives/test/design.f3d")
  760. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  761. assert response.status_code == 200
  762. result = response.json()
  763. assert "f3d_path" in result
  764. assert result["f3d_path"] == "archives/test/design.f3d"
  765. @pytest.mark.asyncio
  766. @pytest.mark.integration
  767. async def test_archive_response_f3d_path_null_when_not_set(
  768. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  769. ):
  770. """Verify f3d_path is null when no F3D file attached."""
  771. printer = await printer_factory()
  772. archive = await archive_factory(printer.id)
  773. response = await async_client.get(f"/api/v1/archives/{archive.id}")
  774. assert response.status_code == 200
  775. result = response.json()
  776. assert "f3d_path" in result
  777. assert result["f3d_path"] is None
  778. @pytest.mark.asyncio
  779. @pytest.mark.integration
  780. async def test_upload_f3d_to_nonexistent_archive(self, async_client: AsyncClient):
  781. """Verify 404 when uploading F3D to non-existent archive."""
  782. # Create a minimal file-like upload
  783. files = {"file": ("design.f3d", b"fake f3d content", "application/octet-stream")}
  784. response = await async_client.post("/api/v1/archives/9999/f3d", files=files)
  785. assert response.status_code == 404
  786. @pytest.mark.asyncio
  787. @pytest.mark.integration
  788. async def test_download_f3d_not_found_when_no_file(
  789. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  790. ):
  791. """Verify 404 when downloading F3D from archive without F3D file."""
  792. printer = await printer_factory()
  793. archive = await archive_factory(printer.id)
  794. response = await async_client.get(f"/api/v1/archives/{archive.id}/f3d")
  795. assert response.status_code == 404
  796. @pytest.mark.asyncio
  797. @pytest.mark.integration
  798. async def test_download_f3d_nonexistent_archive(self, async_client: AsyncClient):
  799. """Verify 404 when downloading F3D from non-existent archive."""
  800. response = await async_client.get("/api/v1/archives/9999/f3d")
  801. assert response.status_code == 404
  802. @pytest.mark.asyncio
  803. @pytest.mark.integration
  804. async def test_delete_f3d_nonexistent_archive(self, async_client: AsyncClient):
  805. """Verify 404 when deleting F3D from non-existent archive."""
  806. response = await async_client.delete("/api/v1/archives/9999/f3d")
  807. assert response.status_code == 404
  808. @pytest.mark.asyncio
  809. @pytest.mark.integration
  810. async def test_delete_f3d_when_no_file(
  811. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  812. ):
  813. """Verify 404 when deleting F3D from archive without F3D file."""
  814. printer = await printer_factory()
  815. archive = await archive_factory(printer.id)
  816. response = await async_client.delete(f"/api/v1/archives/{archive.id}/f3d")
  817. assert response.status_code == 404
  818. @pytest.mark.asyncio
  819. @pytest.mark.integration
  820. async def test_list_archives_includes_f3d_path(
  821. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  822. ):
  823. """Verify f3d_path is included in archive list responses."""
  824. printer = await printer_factory()
  825. await archive_factory(printer.id, print_name="With F3D", f3d_path="archives/test/design.f3d")
  826. await archive_factory(printer.id, print_name="Without F3D")
  827. response = await async_client.get("/api/v1/archives/")
  828. assert response.status_code == 200
  829. data = response.json()
  830. assert len(data) >= 2
  831. with_f3d = next((a for a in data if a["print_name"] == "With F3D"), None)
  832. without_f3d = next((a for a in data if a["print_name"] == "Without F3D"), None)
  833. assert with_f3d is not None
  834. assert with_f3d["f3d_path"] == "archives/test/design.f3d"
  835. assert without_f3d is not None
  836. assert without_f3d["f3d_path"] is None
  837. # ========================================================================
  838. # Multi-Plate 3MF endpoints (Issue #93)
  839. # ========================================================================
  840. @pytest.mark.asyncio
  841. @pytest.mark.integration
  842. async def test_get_archive_plates_not_found(self, async_client: AsyncClient):
  843. """Verify 404 when fetching plates for non-existent archive."""
  844. response = await async_client.get("/api/v1/archives/999999/plates")
  845. assert response.status_code == 404
  846. @pytest.mark.asyncio
  847. @pytest.mark.integration
  848. async def test_get_plate_thumbnail_not_found(self, async_client: AsyncClient):
  849. """Verify 404 when fetching plate thumbnail for non-existent archive."""
  850. response = await async_client.get("/api/v1/archives/999999/plate-thumbnail/1")
  851. assert response.status_code == 404
  852. @pytest.mark.asyncio
  853. @pytest.mark.integration
  854. async def test_filament_requirements_not_found(self, async_client: AsyncClient):
  855. """Verify filament-requirements returns 404 for non-existent archive."""
  856. response = await async_client.get("/api/v1/archives/999999/filament-requirements")
  857. assert response.status_code == 404
  858. @pytest.mark.asyncio
  859. @pytest.mark.integration
  860. async def test_filament_requirements_with_plate_id_not_found(self, async_client: AsyncClient):
  861. """Verify filament-requirements with plate_id returns 404 for non-existent archive."""
  862. response = await async_client.get("/api/v1/archives/999999/filament-requirements?plate_id=1")
  863. assert response.status_code == 404
  864. # ========================================================================
  865. # Tag Management endpoints (Issue #183)
  866. # ========================================================================
  867. @pytest.mark.asyncio
  868. @pytest.mark.integration
  869. async def test_get_tags_empty(self, async_client: AsyncClient):
  870. """Verify empty list when no tags exist."""
  871. response = await async_client.get("/api/v1/archives/tags")
  872. assert response.status_code == 200
  873. data = response.json()
  874. assert isinstance(data, list)
  875. assert len(data) == 0
  876. @pytest.mark.asyncio
  877. @pytest.mark.integration
  878. async def test_get_tags_with_data(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  879. """Verify tags are returned with counts."""
  880. printer = await printer_factory()
  881. await archive_factory(printer.id, print_name="Archive 1", tags="functional, test")
  882. await archive_factory(printer.id, print_name="Archive 2", tags="functional, calibration")
  883. await archive_factory(printer.id, print_name="Archive 3", tags="test")
  884. response = await async_client.get("/api/v1/archives/tags")
  885. assert response.status_code == 200
  886. data = response.json()
  887. assert isinstance(data, list)
  888. # Convert to dict for easier lookup
  889. tags_dict = {t["name"]: t["count"] for t in data}
  890. assert tags_dict.get("functional") == 2
  891. assert tags_dict.get("test") == 2
  892. assert tags_dict.get("calibration") == 1
  893. @pytest.mark.asyncio
  894. @pytest.mark.integration
  895. async def test_get_tags_sorted_by_count(
  896. self, async_client: AsyncClient, archive_factory, printer_factory, db_session
  897. ):
  898. """Verify tags are sorted by count descending, then by name."""
  899. printer = await printer_factory()
  900. await archive_factory(printer.id, tags="alpha")
  901. await archive_factory(printer.id, tags="beta, alpha")
  902. await archive_factory(printer.id, tags="gamma, beta, alpha")
  903. response = await async_client.get("/api/v1/archives/tags")
  904. assert response.status_code == 200
  905. data = response.json()
  906. # alpha=3, beta=2, gamma=1
  907. assert data[0]["name"] == "alpha"
  908. assert data[0]["count"] == 3
  909. assert data[1]["name"] == "beta"
  910. assert data[1]["count"] == 2
  911. assert data[2]["name"] == "gamma"
  912. assert data[2]["count"] == 1
  913. @pytest.mark.asyncio
  914. @pytest.mark.integration
  915. async def test_rename_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  916. """Verify renaming a tag updates all archives."""
  917. printer = await printer_factory()
  918. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="old-tag, other")
  919. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="old-tag")
  920. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  921. response = await async_client.put("/api/v1/archives/tags/old-tag", json={"new_name": "new-tag"})
  922. assert response.status_code == 200
  923. data = response.json()
  924. assert data["affected"] == 2
  925. # Verify the archives were updated
  926. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  927. assert "new-tag" in response.json()["tags"]
  928. assert "old-tag" not in response.json()["tags"]
  929. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  930. assert response.json()["tags"] == "new-tag"
  931. @pytest.mark.asyncio
  932. @pytest.mark.integration
  933. async def test_rename_tag_no_change(self, async_client: AsyncClient):
  934. """Verify renaming to same name returns 0 affected."""
  935. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": "some-tag"})
  936. assert response.status_code == 200
  937. assert response.json()["affected"] == 0
  938. @pytest.mark.asyncio
  939. @pytest.mark.integration
  940. async def test_rename_tag_empty_name_error(self, async_client: AsyncClient):
  941. """Verify renaming to empty name returns error."""
  942. response = await async_client.put("/api/v1/archives/tags/some-tag", json={"new_name": ""})
  943. assert response.status_code == 400
  944. @pytest.mark.asyncio
  945. @pytest.mark.integration
  946. async def test_delete_tag(self, async_client: AsyncClient, archive_factory, printer_factory, db_session):
  947. """Verify deleting a tag removes it from all archives."""
  948. printer = await printer_factory()
  949. a1 = await archive_factory(printer.id, print_name="Archive 1", tags="delete-me, keep")
  950. a2 = await archive_factory(printer.id, print_name="Archive 2", tags="delete-me")
  951. await archive_factory(printer.id, print_name="Archive 3", tags="different")
  952. response = await async_client.delete("/api/v1/archives/tags/delete-me")
  953. assert response.status_code == 200
  954. data = response.json()
  955. assert data["affected"] == 2
  956. # Verify the archives were updated
  957. response = await async_client.get(f"/api/v1/archives/{a1.id}")
  958. assert response.json()["tags"] == "keep"
  959. response = await async_client.get(f"/api/v1/archives/{a2.id}")
  960. # Should be None or empty when last tag is removed
  961. assert response.json()["tags"] is None or response.json()["tags"] == ""
  962. @pytest.mark.asyncio
  963. @pytest.mark.integration
  964. async def test_delete_tag_not_found(self, async_client: AsyncClient):
  965. """Verify deleting non-existent tag returns 0 affected."""
  966. response = await async_client.delete("/api/v1/archives/tags/nonexistent-tag")
  967. assert response.status_code == 200
  968. assert response.json()["affected"] == 0