test_archives_api.py 43 KB

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