test_check_previous_success.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218
  1. """Tests for `PrintScheduler._check_previous_success` (#1667).
  2. Pre-fix behaviour: the lookback `.in_([...])` list excluded `cancelled` and
  3. included `skipped`, so a single user-cancelled print blocked every downstream
  4. item with `require_previous_success=True` permanently (the reporter saw 18
  5. items blocked over 3 days from one cancellation, because each new skip
  6. became the next skip's "failed predecessor").
  7. Post-fix behaviour:
  8. - `cancelled` is a neutral outcome → returns True (a deliberate user action
  9. is not a print failure)
  10. - `skipped` is excluded from the lookback → an already-skipped item never
  11. counts as a predecessor; the query walks back to the most recent real
  12. print attempt
  13. - `failed` and `aborted` still gate as before
  14. """
  15. from __future__ import annotations
  16. from datetime import datetime, timedelta, timezone
  17. import pytest
  18. from backend.app.models.print_queue import PrintQueueItem
  19. from backend.app.services.print_scheduler import PrintScheduler
  20. @pytest.fixture
  21. def scheduler():
  22. return PrintScheduler()
  23. @pytest.fixture
  24. def queue_factory(db_session, printer_factory):
  25. """Helper to drop completed/failed/cancelled/skipped queue items in order.
  26. Each call assigns a monotonically increasing `completed_at` so the
  27. scheduler's `ORDER BY completed_at DESC` reliably picks the latest as
  28. the predecessor. `printer_id` is shared so all items count.
  29. """
  30. base_time = datetime(2026, 6, 6, 12, 0, 0, tzinfo=timezone.utc)
  31. counter = {"n": 0}
  32. printer_holder: dict = {}
  33. async def _make_printer():
  34. if "p" not in printer_holder:
  35. printer_holder["p"] = await printer_factory()
  36. return printer_holder["p"]
  37. async def _add(
  38. status: str,
  39. error_message: str | None = None,
  40. gate_acknowledged: bool = False,
  41. ) -> PrintQueueItem:
  42. printer = await _make_printer()
  43. counter["n"] += 1
  44. item = PrintQueueItem(
  45. printer_id=printer.id,
  46. status=status,
  47. error_message=error_message,
  48. completed_at=base_time + timedelta(minutes=counter["n"]),
  49. require_previous_success=True,
  50. gate_acknowledged=gate_acknowledged,
  51. )
  52. db_session.add(item)
  53. await db_session.commit()
  54. await db_session.refresh(item)
  55. return item
  56. async def _add_pending() -> PrintQueueItem:
  57. printer = await _make_printer()
  58. item = PrintQueueItem(
  59. printer_id=printer.id,
  60. status="pending",
  61. require_previous_success=True,
  62. )
  63. db_session.add(item)
  64. await db_session.commit()
  65. await db_session.refresh(item)
  66. return item
  67. return {"add": _add, "add_pending": _add_pending}
  68. @pytest.mark.asyncio
  69. async def test_no_previous_item_returns_true(scheduler, db_session, queue_factory):
  70. """First item in the queue has no predecessor → always passes."""
  71. pending = await queue_factory["add_pending"]()
  72. assert await scheduler._check_previous_success(db_session, pending) is True
  73. @pytest.mark.asyncio
  74. async def test_previous_completed_returns_true(scheduler, db_session, queue_factory):
  75. await queue_factory["add"]("completed")
  76. pending = await queue_factory["add_pending"]()
  77. assert await scheduler._check_previous_success(db_session, pending) is True
  78. @pytest.mark.asyncio
  79. async def test_previous_failed_returns_false(scheduler, db_session, queue_factory):
  80. await queue_factory["add"]("failed")
  81. pending = await queue_factory["add_pending"]()
  82. assert await scheduler._check_previous_success(db_session, pending) is False
  83. @pytest.mark.asyncio
  84. async def test_previous_aborted_returns_false(scheduler, db_session, queue_factory):
  85. """A printer-detected abort (e.g. clogged nozzle) is a real failure → blocks."""
  86. await queue_factory["add"]("aborted")
  87. pending = await queue_factory["add_pending"]()
  88. assert await scheduler._check_previous_success(db_session, pending) is False
  89. @pytest.mark.asyncio
  90. async def test_previous_cancelled_returns_true_bug_a(scheduler, db_session, queue_factory):
  91. """#1667 bug A: user cancellation is deliberate, not a failure → passes."""
  92. await queue_factory["add"]("cancelled")
  93. pending = await queue_factory["add_pending"]()
  94. assert await scheduler._check_previous_success(db_session, pending) is True
  95. @pytest.mark.asyncio
  96. async def test_skipped_predecessor_is_walked_past_bug_b(scheduler, db_session, queue_factory):
  97. """#1667 bug B: a skipped item is not an attempt — query walks back to the
  98. most recent real outcome instead of treating skipped as failed."""
  99. await queue_factory["add"]("completed") # real predecessor that should be found
  100. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  101. pending = await queue_factory["add_pending"]()
  102. assert await scheduler._check_previous_success(db_session, pending) is True
  103. @pytest.mark.asyncio
  104. async def test_only_skipped_history_returns_true(scheduler, db_session, queue_factory):
  105. """Edge case: every prior item is skipped → no real predecessor found,
  106. returns True (first-in-queue semantics)."""
  107. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  108. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  109. pending = await queue_factory["add_pending"]()
  110. assert await scheduler._check_previous_success(db_session, pending) is True
  111. @pytest.mark.asyncio
  112. async def test_cascade_reporters_scenario(scheduler, db_session, queue_factory):
  113. """The exact #1667 reporter scenario: failed → cancelled → skipped → pending.
  114. Pre-fix: pending blocked because the buggy lookback walked past the
  115. cancelled item (excluded) and the prior skipped item (included), found
  116. the failed item, and returned False.
  117. Post-fix: cancelled is the predecessor (skipped is excluded; cancelled
  118. is included and passes), pending dispatches.
  119. """
  120. await queue_factory["add"]("failed")
  121. await queue_factory["add"]("cancelled")
  122. await queue_factory["add"]("skipped", "Previous print failed or was aborted")
  123. pending = await queue_factory["add_pending"]()
  124. assert await scheduler._check_previous_success(db_session, pending) is True
  125. @pytest.mark.asyncio
  126. async def test_failed_then_cancelled_still_passes(scheduler, db_session, queue_factory):
  127. """User cancelled after a failure → most recent action wins. The cancellation
  128. is the user explicitly choosing to move on, so dispatching the next item
  129. respects their intent."""
  130. await queue_factory["add"]("failed")
  131. await queue_factory["add"]("cancelled")
  132. pending = await queue_factory["add_pending"]()
  133. assert await scheduler._check_previous_success(db_session, pending) is True
  134. @pytest.mark.asyncio
  135. async def test_completed_then_failed_blocks(scheduler, db_session, queue_factory):
  136. """Regression guard: a real failure after a previously-successful print
  137. still gates downstream items. Only the MOST RECENT outcome matters."""
  138. await queue_factory["add"]("completed")
  139. await queue_factory["add"]("failed")
  140. pending = await queue_factory["add_pending"]()
  141. assert await scheduler._check_previous_success(db_session, pending) is False
  142. # ---- #1818: per-printer Resume-after-failure gate acknowledgement ----
  143. @pytest.mark.asyncio
  144. async def test_acknowledged_failure_is_excluded(scheduler, db_session, queue_factory):
  145. """The reporter scenario: failure with gate_acknowledged=True must NOT
  146. block. Without the acknowledge filter, a single failure poisons every
  147. later require_previous_success item forever."""
  148. await queue_factory["add"]("failed", gate_acknowledged=True)
  149. pending = await queue_factory["add_pending"]()
  150. assert await scheduler._check_previous_success(db_session, pending) is True
  151. @pytest.mark.asyncio
  152. async def test_acknowledged_aborted_is_excluded(scheduler, db_session, queue_factory):
  153. await queue_factory["add"]("aborted", gate_acknowledged=True)
  154. pending = await queue_factory["add_pending"]()
  155. assert await scheduler._check_previous_success(db_session, pending) is True
  156. @pytest.mark.asyncio
  157. async def test_fresh_failure_after_ack_still_blocks(scheduler, db_session, queue_factory):
  158. """Per-item acknowledgement is independent — a NEW failure after the
  159. user resumed the queue must re-gate downstream items so they don't
  160. silently steamroll past a real problem."""
  161. await queue_factory["add"]("failed", gate_acknowledged=True) # the old one
  162. await queue_factory["add"]("failed", gate_acknowledged=False) # fresh post-resume
  163. pending = await queue_factory["add_pending"]()
  164. assert await scheduler._check_previous_success(db_session, pending) is False
  165. @pytest.mark.asyncio
  166. async def test_acknowledged_failure_walks_back_to_completed(scheduler, db_session, queue_factory):
  167. """After acknowledging the failure, the next real predecessor (a
  168. completed print prior to the failure) governs the gate."""
  169. await queue_factory["add"]("completed")
  170. await queue_factory["add"]("failed", gate_acknowledged=True)
  171. pending = await queue_factory["add_pending"]()
  172. assert await scheduler._check_previous_success(db_session, pending) is True