test_archive_purge_api.py 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  1. """Integration tests for archive auto-purge (#1008 follow-up)."""
  2. from datetime import datetime, timedelta, timezone
  3. import pytest
  4. from httpx import AsyncClient
  5. @pytest.mark.asyncio
  6. @pytest.mark.integration
  7. async def test_settings_defaults_when_unset(async_client: AsyncClient):
  8. """GET /archives/purge/settings returns sensible defaults on a fresh install."""
  9. resp = await async_client.get("/api/v1/archives/purge/settings")
  10. assert resp.status_code == 200
  11. body = resp.json()
  12. assert body["enabled"] is False
  13. assert body["days"] == 365
  14. # #1390: default soft-delete — preserves Quick Stats contribution.
  15. assert body["purge_stats"] is False
  16. @pytest.mark.asyncio
  17. @pytest.mark.integration
  18. async def test_settings_roundtrip(async_client: AsyncClient):
  19. """PUT persists, GET returns the saved values, days is clamped."""
  20. resp = await async_client.put(
  21. "/api/v1/archives/purge/settings",
  22. json={"enabled": True, "days": 180, "purge_stats": True},
  23. )
  24. assert resp.status_code == 200
  25. assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
  26. resp = await async_client.get("/api/v1/archives/purge/settings")
  27. assert resp.json() == {"enabled": True, "days": 180, "purge_stats": True}
  28. @pytest.mark.asyncio
  29. @pytest.mark.integration
  30. async def test_settings_rejects_out_of_range_days(async_client: AsyncClient):
  31. """days below MIN or above MAX is rejected."""
  32. resp = await async_client.put(
  33. "/api/v1/archives/purge/settings",
  34. json={"enabled": True, "days": 1},
  35. )
  36. # Pydantic validation returns 422; explicit bound check returns 400.
  37. assert resp.status_code in (400, 422)
  38. @pytest.mark.asyncio
  39. @pytest.mark.integration
  40. async def test_preview_counts_old_archives(async_client: AsyncClient, archive_factory, printer_factory, db_session):
  41. """Preview returns the count + total bytes of archives older than the threshold."""
  42. printer = await printer_factory()
  43. old = await archive_factory(printer.id, print_name="Old", file_size=1000)
  44. fresh = await archive_factory(printer.id, print_name="Fresh", file_size=2000)
  45. old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  46. fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
  47. await db_session.commit()
  48. resp = await async_client.get("/api/v1/archives/purge/preview?older_than_days=365")
  49. assert resp.status_code == 200
  50. body = resp.json()
  51. assert body["count"] == 1
  52. assert body["total_bytes"] == 1000
  53. assert "Old" in body["sample_filenames"][0] or old.filename in body["sample_filenames"]
  54. @pytest.mark.asyncio
  55. @pytest.mark.integration
  56. async def test_preview_ignores_recently_reprinted_archives(
  57. async_client: AsyncClient, archive_factory, printer_factory, db_session
  58. ):
  59. """Reprints update completed_at but leave created_at pinned; purge must honour that."""
  60. printer = await printer_factory()
  61. reprinted = await archive_factory(printer.id, print_name="Reprinted", file_size=1000)
  62. # Originally printed 400 days ago, but a reprint last week refreshed completed_at.
  63. reprinted.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  64. reprinted.started_at = datetime.now(timezone.utc) - timedelta(days=7)
  65. reprinted.completed_at = datetime.now(timezone.utc) - timedelta(days=7)
  66. await db_session.commit()
  67. resp = await async_client.get("/api/v1/archives/purge/preview?older_than_days=365")
  68. assert resp.status_code == 200
  69. body = resp.json()
  70. assert body["count"] == 0
  71. @pytest.mark.asyncio
  72. @pytest.mark.integration
  73. async def test_manual_purge_soft_deletes_by_default(
  74. async_client: AsyncClient, archive_factory, printer_factory, db_session
  75. ):
  76. """#1390: POST /archives/purge with no body flag soft-deletes — files
  77. off disk, ``deleted_at`` set, archive row survives so Quick Stats keeps
  78. every contribution. Matches the single-archive delete default from #1343."""
  79. from backend.app.models.archive import PrintArchive
  80. printer = await printer_factory()
  81. old = await archive_factory(printer.id, print_name="Old")
  82. fresh = await archive_factory(printer.id, print_name="Fresh")
  83. old_id = old.id
  84. fresh_id = fresh.id
  85. old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  86. fresh.created_at = datetime.now(timezone.utc) - timedelta(days=10)
  87. await db_session.commit()
  88. resp = await async_client.post(
  89. "/api/v1/archives/purge",
  90. json={"older_than_days": 365},
  91. )
  92. assert resp.status_code == 200
  93. body = resp.json()
  94. assert body["deleted"] == 1
  95. assert body["purge_stats"] is False
  96. db_session.expire_all()
  97. # Old row still exists in DB but is soft-deleted.
  98. old_row = await db_session.get(PrintArchive, old_id)
  99. assert old_row is not None
  100. assert old_row.deleted_at is not None
  101. fresh_row = await db_session.get(PrintArchive, fresh_id)
  102. assert fresh_row is not None
  103. assert fresh_row.deleted_at is None
  104. @pytest.mark.asyncio
  105. @pytest.mark.integration
  106. async def test_manual_purge_hard_deletes_when_purge_stats_set(
  107. async_client: AsyncClient, archive_factory, printer_factory, db_session
  108. ):
  109. """#1390: when ``purge_stats=true`` is sent in the body, the bulk purge
  110. hard-deletes the archive AND the linked PrintLogEntry rows so the
  111. contribution drops from /stats — matches the single-archive route's
  112. ``?purge_stats=true`` semantics."""
  113. from backend.app.models.archive import PrintArchive
  114. printer = await printer_factory()
  115. old = await archive_factory(printer.id, print_name="Old")
  116. old_id = old.id
  117. old.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  118. await db_session.commit()
  119. resp = await async_client.post(
  120. "/api/v1/archives/purge",
  121. json={"older_than_days": 365, "purge_stats": True},
  122. )
  123. assert resp.status_code == 200
  124. body = resp.json()
  125. assert body["deleted"] == 1
  126. assert body["purge_stats"] is True
  127. db_session.expire_all()
  128. assert await db_session.get(PrintArchive, old_id) is None
  129. @pytest.mark.asyncio
  130. @pytest.mark.integration
  131. async def test_auto_purge_soft_deletes_by_default(
  132. async_client: AsyncClient, archive_factory, printer_factory, db_session
  133. ):
  134. """#1390: scheduled auto-purge defaults to soft-delete — Quick Stats
  135. preserved unless the admin explicitly opts into hard-delete via the
  136. settings toggle.
  137. ``async_client`` is included solely so its fixture activates the module-level
  138. ``async_session`` patches that let :meth:`purge_older_than`'s per-row
  139. delete sessions reach the in-memory test database.
  140. """
  141. from backend.app.models.archive import PrintArchive
  142. from backend.app.services.archive_purge import archive_purge_service
  143. printer = await printer_factory()
  144. stale = await archive_factory(printer.id, print_name="Stale")
  145. stale_id = stale.id
  146. stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  147. await db_session.commit()
  148. await archive_purge_service.set_settings(db_session, enabled=True, days=365)
  149. deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
  150. assert deleted >= 1
  151. db_session.expire_all()
  152. stale_row = await db_session.get(PrintArchive, stale_id)
  153. assert stale_row is not None
  154. assert stale_row.deleted_at is not None
  155. @pytest.mark.asyncio
  156. @pytest.mark.integration
  157. async def test_auto_purge_hard_deletes_when_settings_opts_in(
  158. async_client: AsyncClient, archive_factory, printer_factory, db_session
  159. ):
  160. """#1390: scheduled auto-purge honours the ``purge_stats`` setting —
  161. when True the sweeper hard-deletes archive rows AND linked PrintLogEntry
  162. rows, dropping every contribution from /stats."""
  163. from backend.app.models.archive import PrintArchive
  164. from backend.app.services.archive_purge import archive_purge_service
  165. printer = await printer_factory()
  166. stale = await archive_factory(printer.id, print_name="Stale")
  167. stale_id = stale.id
  168. stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  169. await db_session.commit()
  170. await archive_purge_service.set_settings(db_session, enabled=True, days=365, purge_stats=True)
  171. deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
  172. assert deleted >= 1
  173. db_session.expire_all()
  174. assert await db_session.get(PrintArchive, stale_id) is None
  175. @pytest.mark.asyncio
  176. @pytest.mark.integration
  177. async def test_auto_purge_throttles_within_24h(async_client: AsyncClient, archive_factory, printer_factory, db_session):
  178. """A recent last-run timestamp blocks the sweeper for 24h."""
  179. from backend.app.services.archive_purge import archive_purge_service
  180. printer = await printer_factory()
  181. stale = await archive_factory(printer.id, print_name="Stale")
  182. stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  183. await db_session.commit()
  184. await archive_purge_service.set_settings(db_session, enabled=True, days=365)
  185. # Stamp a last-run time 1h ago — should block the sweeper for another 23h.
  186. await archive_purge_service._stamp_last_run(db_session, datetime.now(timezone.utc) - timedelta(hours=1))
  187. deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
  188. assert deleted == 0
  189. @pytest.mark.asyncio
  190. @pytest.mark.integration
  191. async def test_auto_purge_skipped_when_disabled(
  192. async_client: AsyncClient, archive_factory, printer_factory, db_session
  193. ):
  194. """When the toggle is off, old archives stay put."""
  195. from backend.app.models.archive import PrintArchive
  196. from backend.app.services.archive_purge import archive_purge_service
  197. printer = await printer_factory()
  198. stale = await archive_factory(printer.id, print_name="Stale")
  199. stale_id = stale.id
  200. stale.created_at = datetime.now(timezone.utc) - timedelta(days=400)
  201. await db_session.commit()
  202. await archive_purge_service.set_settings(db_session, enabled=False, days=365)
  203. deleted = await archive_purge_service._maybe_run_auto_purge(db_session)
  204. assert deleted == 0
  205. db_session.expire_all()
  206. assert await db_session.get(PrintArchive, stale_id) is not None