test_archive_run_aggregation.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247
  1. """Tests for the PrintRun-based stats aggregation (#1378).
  2. Statistics and per-archive aggregates now come from PrintLogEntry rows rather
  3. than PrintArchive's runtime fields, so a reprint contributes new totals
  4. instead of overwriting the source archive's first-run data.
  5. """
  6. from datetime import datetime, timezone
  7. import pytest
  8. from httpx import AsyncClient
  9. from backend.app.models.print_log import PrintLogEntry
  10. @pytest.mark.asyncio
  11. @pytest.mark.integration
  12. async def test_stats_count_reprints_independently(
  13. async_client: AsyncClient, archive_factory, printer_factory, db_session
  14. ):
  15. """A reprint adds to stats instead of overwriting the source archive."""
  16. printer = await printer_factory()
  17. archive = await archive_factory(
  18. printer.id,
  19. status="completed",
  20. filament_used_grams=100.0,
  21. cost=2.5,
  22. print_time_seconds=3600,
  23. with_run=False,
  24. )
  25. # First run — completed, 100g.
  26. db_session.add(
  27. PrintLogEntry(
  28. archive_id=archive.id,
  29. printer_id=archive.printer_id,
  30. status="completed",
  31. started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
  32. completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
  33. duration_seconds=3600,
  34. filament_used_grams=100.0,
  35. cost=2.5,
  36. created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
  37. )
  38. )
  39. # Reprint — failed at 10g.
  40. db_session.add(
  41. PrintLogEntry(
  42. archive_id=archive.id,
  43. printer_id=archive.printer_id,
  44. status="failed",
  45. started_at=datetime(2026, 5, 5, 10, 0, tzinfo=timezone.utc),
  46. completed_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
  47. duration_seconds=300,
  48. filament_used_grams=10.0,
  49. cost=0.25,
  50. failure_reason="Cancelled by user",
  51. created_at=datetime(2026, 5, 5, 10, 5, tzinfo=timezone.utc),
  52. )
  53. )
  54. await db_session.commit()
  55. response = await async_client.get("/api/v1/archives/stats")
  56. assert response.status_code == 200
  57. body = response.json()
  58. # Both runs counted, not the single archive row.
  59. assert body["total_prints"] == 2
  60. assert body["successful_prints"] == 1
  61. assert body["failed_prints"] == 1
  62. # 100g + 10g — NOT 10g (which is what archives.filament_used_grams alone
  63. # would give if the archive's runtime fields were the source of truth).
  64. assert body["total_filament_grams"] == pytest.approx(110.0)
  65. assert body["total_cost"] == pytest.approx(2.75)
  66. @pytest.mark.asyncio
  67. @pytest.mark.integration
  68. async def test_archive_list_includes_run_aggregates(
  69. async_client: AsyncClient, archive_factory, printer_factory, db_session
  70. ):
  71. """List response carries run_count, last_run_at, total_filament_actual_grams."""
  72. printer = await printer_factory()
  73. archive = await archive_factory(
  74. printer.id,
  75. status="completed",
  76. filament_used_grams=100.0,
  77. with_run=False,
  78. )
  79. db_session.add_all(
  80. [
  81. PrintLogEntry(
  82. archive_id=archive.id,
  83. printer_id=archive.printer_id,
  84. status="completed",
  85. started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
  86. completed_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
  87. filament_used_grams=100.0,
  88. created_at=datetime(2026, 5, 1, 11, 0, tzinfo=timezone.utc),
  89. ),
  90. PrintLogEntry(
  91. archive_id=archive.id,
  92. printer_id=archive.printer_id,
  93. status="failed",
  94. started_at=datetime(2026, 5, 10, 10, 0, tzinfo=timezone.utc),
  95. completed_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
  96. filament_used_grams=10.0,
  97. created_at=datetime(2026, 5, 10, 10, 5, tzinfo=timezone.utc),
  98. ),
  99. ]
  100. )
  101. await db_session.commit()
  102. response = await async_client.get("/api/v1/archives/")
  103. assert response.status_code == 200
  104. rows = response.json()
  105. row = next(r for r in rows if r["id"] == archive.id)
  106. assert row["run_count"] == 2
  107. assert row["successful_run_count"] == 1
  108. assert row["failed_run_count"] == 1
  109. assert row["total_filament_actual_grams"] == pytest.approx(110.0)
  110. assert row["last_run_at"] is not None # max(started_at) populated
  111. @pytest.mark.asyncio
  112. @pytest.mark.integration
  113. async def test_runs_endpoint_returns_runs_newest_first(
  114. async_client: AsyncClient, archive_factory, printer_factory, db_session
  115. ):
  116. """GET /archives/{id}/runs returns each PrintLogEntry for the archive."""
  117. printer = await printer_factory()
  118. archive = await archive_factory(
  119. printer.id,
  120. status="completed",
  121. with_run=False,
  122. )
  123. db_session.add_all(
  124. [
  125. PrintLogEntry(
  126. archive_id=archive.id,
  127. printer_id=archive.printer_id,
  128. status="completed",
  129. started_at=datetime(2026, 4, 1, 10, 0, tzinfo=timezone.utc),
  130. completed_at=datetime(2026, 4, 1, 11, 0, tzinfo=timezone.utc),
  131. filament_used_grams=50.0,
  132. ),
  133. PrintLogEntry(
  134. archive_id=archive.id,
  135. printer_id=archive.printer_id,
  136. status="failed",
  137. started_at=datetime(2026, 5, 1, 10, 0, tzinfo=timezone.utc),
  138. completed_at=datetime(2026, 5, 1, 10, 5, tzinfo=timezone.utc),
  139. filament_used_grams=5.0,
  140. failure_reason="Cancelled by user",
  141. ),
  142. ]
  143. )
  144. await db_session.commit()
  145. response = await async_client.get(f"/api/v1/archives/{archive.id}/runs")
  146. assert response.status_code == 200
  147. body = response.json()
  148. assert body["total"] == 2
  149. # Newest first
  150. assert body["items"][0]["status"] == "failed"
  151. assert body["items"][0]["failure_reason"] == "Cancelled by user"
  152. assert body["items"][1]["status"] == "completed"
  153. assert body["items"][1]["filament_used_grams"] == pytest.approx(50.0)
  154. @pytest.mark.asyncio
  155. @pytest.mark.integration
  156. async def test_purge_stats_also_deletes_linked_runs(
  157. async_client: AsyncClient, archive_factory, printer_factory, db_session
  158. ):
  159. """``DELETE /archives/{id}?purge_stats=true`` hard-deletes linked PrintLogEntry
  160. rows so their filament / cost / count contributions truly leave Quick Stats.
  161. Without this, ON DELETE SET NULL on the FK would orphan the runs and they'd
  162. keep showing up in the new aggregate-from-PrintLogEntry totals (#1378)."""
  163. from sqlalchemy import func, select
  164. printer = await printer_factory()
  165. keep = await archive_factory(printer.id, status="completed", filament_used_grams=50.0)
  166. purge = await archive_factory(printer.id, status="completed", filament_used_grams=100.0)
  167. # Extra runs on the archive about to be purged, to prove they all go.
  168. db_session.add_all(
  169. [
  170. PrintLogEntry(
  171. archive_id=purge.id,
  172. printer_id=purge.printer_id,
  173. status="failed",
  174. filament_used_grams=10.0,
  175. ),
  176. PrintLogEntry(
  177. archive_id=purge.id,
  178. printer_id=purge.printer_id,
  179. status="completed",
  180. filament_used_grams=100.0,
  181. ),
  182. ]
  183. )
  184. await db_session.commit()
  185. resp = await async_client.delete(f"/api/v1/archives/{purge.id}?purge_stats=true")
  186. assert resp.status_code == 200
  187. assert resp.json()["purged_from_stats"] is True
  188. remaining = await db_session.execute(
  189. select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == purge.id)
  190. )
  191. assert remaining.scalar() == 0
  192. # The OTHER archive's auto-synthesized run is still there.
  193. keep_remaining = await db_session.execute(
  194. select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == keep.id)
  195. )
  196. assert keep_remaining.scalar() == 1
  197. @pytest.mark.asyncio
  198. @pytest.mark.integration
  199. async def test_soft_delete_keeps_runs_for_stats(
  200. async_client: AsyncClient, archive_factory, printer_factory, db_session
  201. ):
  202. """Default soft-delete (without ``purge_stats=true``) keeps the archive's
  203. PrintLogEntry rows so the #1343 stats-preservation contract still holds —
  204. the archive disappears from listings, but its filament / time / cost stay
  205. in Quick Stats."""
  206. from sqlalchemy import func, select
  207. printer = await printer_factory()
  208. archive = await archive_factory(printer.id, status="completed", filament_used_grams=75.0)
  209. resp = await async_client.delete(f"/api/v1/archives/{archive.id}")
  210. assert resp.status_code == 200
  211. assert resp.json()["purged_from_stats"] is False
  212. # The run row is still there for stats aggregation.
  213. runs = await db_session.execute(select(func.count(PrintLogEntry.id)).where(PrintLogEntry.archive_id == archive.id))
  214. assert runs.scalar() == 1
  215. stats = (await async_client.get("/api/v1/archives/stats")).json()
  216. assert stats["total_prints"] >= 1
  217. assert stats["total_filament_grams"] >= 75.0