test_scheduled_drying_routes.py 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Route tests for /scheduled-dryings (#2638)."""
  2. from datetime import datetime, timedelta, timezone
  3. from unittest.mock import patch
  4. import pytest
  5. def _future_iso(hours: int = 2) -> str:
  6. return (datetime.now(timezone.utc) + timedelta(hours=hours)).isoformat()
  7. @pytest.mark.asyncio
  8. async def test_create_and_list(async_client, printer_factory):
  9. printer = await printer_factory()
  10. resp = await async_client.post(
  11. "/api/v1/scheduled-dryings",
  12. json={"printer_id": printer.id, "ams_id": 0, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
  13. )
  14. assert resp.status_code == 200, resp.text
  15. body = resp.json()
  16. assert body["status"] == "pending"
  17. assert body["printer_id"] == printer.id
  18. listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
  19. assert listed.status_code == 200
  20. assert [row["id"] for row in listed.json()] == [body["id"]]
  21. @pytest.mark.asyncio
  22. async def test_create_rejects_past_start_after(async_client, printer_factory):
  23. printer = await printer_factory()
  24. past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
  25. resp = await async_client.post(
  26. "/api/v1/scheduled-dryings",
  27. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": past},
  28. )
  29. assert resp.status_code == 400
  30. @pytest.mark.asyncio
  31. async def test_create_unknown_printer_404(async_client):
  32. resp = await async_client.post(
  33. "/api/v1/scheduled-dryings", json={"printer_id": 99999, "temp": 65, "duration_hours": 8}
  34. )
  35. assert resp.status_code == 404
  36. @pytest.mark.asyncio
  37. async def test_create_invalid_temp_422(async_client, printer_factory):
  38. printer = await printer_factory()
  39. resp = await async_client.post(
  40. "/api/v1/scheduled-dryings", json={"printer_id": printer.id, "temp": 90, "duration_hours": 8}
  41. )
  42. assert resp.status_code == 422
  43. @pytest.mark.asyncio
  44. async def test_cancel_pending(async_client, printer_factory):
  45. printer = await printer_factory()
  46. created = await async_client.post(
  47. "/api/v1/scheduled-dryings",
  48. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
  49. )
  50. row_id = created.json()["id"]
  51. resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row_id}")
  52. assert resp.status_code == 200
  53. assert resp.json()["status"] == "cancelled"
  54. # Cancelled rows disappear from the active list
  55. listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
  56. assert listed.json() == []
  57. # Second cancel is a 400 (not active any more)
  58. resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row_id}")
  59. assert resp.status_code == 400
  60. @pytest.mark.asyncio
  61. async def test_cancel_running_sends_stop_command(async_client, printer_factory, db_session):
  62. from backend.app.models.scheduled_drying import ScheduledDrying
  63. printer = await printer_factory()
  64. row = ScheduledDrying(printer_id=printer.id, ams_id=1, temp=65, duration_hours=8, status="running")
  65. db_session.add(row)
  66. await db_session.commit()
  67. await db_session.refresh(row)
  68. with patch("backend.app.api.routes.scheduled_dryings.printer_manager") as mock_pm:
  69. mock_pm.send_drying_command.return_value = True
  70. resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")
  71. assert resp.status_code == 200
  72. mock_pm.send_drying_command.assert_called_once_with(printer.id, 1, 0, 0, mode=0)
  73. @pytest.mark.asyncio
  74. async def test_screen_only_model_rejected_at_schedule_time(async_client, printer_factory):
  75. """A P1S can never run a scheduled dry, so say so in the UI now."""
  76. printer = await printer_factory(model="P1S")
  77. resp = await async_client.post(
  78. "/api/v1/scheduled-dryings",
  79. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
  80. )
  81. assert resp.status_code == 400
  82. assert "screen" in resp.json()["detail"].lower()
  83. @pytest.mark.asyncio
  84. async def test_offline_printer_is_still_schedulable(async_client, printer_factory):
  85. """Firmware is unreadable while offline and may be upgraded before the run."""
  86. printer = await printer_factory()
  87. resp = await async_client.post(
  88. "/api/v1/scheduled-dryings",
  89. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
  90. )
  91. assert resp.status_code == 200, resp.text
  92. @pytest.mark.asyncio
  93. async def test_stale_firmware_rejected_when_printer_is_online(async_client, printer_factory):
  94. printer = await printer_factory()
  95. state = type("S", (), {"firmware_version": "01.05.00.00", "raw_data": {}})()
  96. with patch("backend.app.api.routes.scheduled_dryings.printer_manager") as mock_pm:
  97. mock_pm.get_status.return_value = state
  98. resp = await async_client.post(
  99. "/api/v1/scheduled-dryings",
  100. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
  101. )
  102. assert resp.status_code == 400
  103. @pytest.mark.asyncio
  104. async def test_timestamps_carry_z_suffix(async_client, printer_factory):
  105. """Matches every print_queue route, which the frontend parses the same way."""
  106. printer = await printer_factory()
  107. resp = await async_client.post(
  108. "/api/v1/scheduled-dryings",
  109. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso()},
  110. )
  111. body = resp.json()
  112. assert body["start_after"].endswith("Z")
  113. assert body["created_at"].endswith("Z")
  114. @pytest.mark.asyncio
  115. async def test_failed_rows_are_listed_and_dismissable(async_client, printer_factory, db_session):
  116. """A run that only fails at dispatch has to reach the client, not just the log."""
  117. from backend.app.models.scheduled_drying import ScheduledDrying
  118. printer = await printer_factory()
  119. row = ScheduledDrying(
  120. printer_id=printer.id,
  121. temp=65,
  122. duration_hours=8,
  123. status="failed",
  124. error_message="Drying not supported for this printer model or firmware version",
  125. )
  126. db_session.add(row)
  127. await db_session.commit()
  128. await db_session.refresh(row)
  129. listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
  130. assert [r["id"] for r in listed.json()] == [row.id]
  131. assert listed.json()[0]["error_message"].startswith("Drying not supported")
  132. resp = await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")
  133. assert resp.status_code == 200
  134. assert resp.json()["status"] == "dismissed"
  135. assert (await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")).json() == []
  136. # Dismissing twice is a 404, not a second dismiss: the row is gone.
  137. assert (await async_client.delete(f"/api/v1/scheduled-dryings/{row.id}")).status_code == 404
  138. @pytest.mark.asyncio
  139. async def test_list_orders_by_start_after_then_id(async_client, printer_factory):
  140. printer = await printer_factory()
  141. later = await async_client.post(
  142. "/api/v1/scheduled-dryings",
  143. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso(5)},
  144. )
  145. sooner = await async_client.post(
  146. "/api/v1/scheduled-dryings",
  147. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "start_after": _future_iso(2)},
  148. )
  149. listed = await async_client.get(f"/api/v1/scheduled-dryings?printer_id={printer.id}")
  150. assert [r["id"] for r in listed.json()] == [sooner.json()["id"], later.json()["id"]]
  151. @pytest.mark.asyncio
  152. async def test_over_long_filament_422(async_client, printer_factory):
  153. printer = await printer_factory()
  154. resp = await async_client.post(
  155. "/api/v1/scheduled-dryings",
  156. json={"printer_id": printer.id, "temp": 65, "duration_hours": 8, "filament": "X" * 51},
  157. )
  158. assert resp.status_code == 422