test_notify_no_archive_duration.py 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. """A completion with no archive is notified about its own queue job, or none.
  2. When a completion matches no archive, the notification used to borrow the
  3. queue item this printer had finished in the last five minutes. For a print
  4. started outside Bambuddy that was some earlier, unrelated job: its owner got
  5. the "your print is done" email, and the notification took that job's data. It
  6. now uses only the queue item the completion itself closed (the one
  7. _completion_belongs_to_queue_item accepted), with that job's real duration.
  8. """
  9. from datetime import datetime, timedelta, timezone
  10. from unittest.mock import AsyncMock, MagicMock, patch
  11. import pytest
  12. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
  13. from backend.app.models.library import LibraryFile
  14. from backend.app.models.print_queue import PrintQueueItem
  15. from backend.app.models.user import User
  16. async def _complete_without_archive(test_engine, printer_id: int, status: str = "completed", **extra):
  17. """Run on_print_complete for a print no archive matches.
  18. Returns (archive_data the notification got, user-email mock). The queue
  19. reconciliation and the spawned notification run against the test database;
  20. the archive lookup sees an empty session, so the no-archive path is taken.
  21. """
  22. from backend.app.main import on_print_complete
  23. real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
  24. empty_session = AsyncMock()
  25. empty_session.__aenter__ = AsyncMock(return_value=empty_session)
  26. empty_session.__aexit__ = AsyncMock()
  27. empty_session.execute = AsyncMock(
  28. return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None), scalars=MagicMock())
  29. )
  30. with (
  31. patch("backend.app.main.async_session", MagicMock(return_value=empty_session)),
  32. patch("backend.app.core.database.async_session", real_db),
  33. patch("backend.app.main.ws_manager") as mock_ws,
  34. patch("backend.app.main.printer_manager") as mock_pm,
  35. patch("backend.app.main.mqtt_relay") as mock_relay,
  36. patch("backend.app.main.spawn_background_task") as mock_spawn,
  37. patch("backend.app.main.clear_3mf_cache"),
  38. ):
  39. mock_ws.send_print_complete = AsyncMock()
  40. mock_relay.on_print_complete = AsyncMock()
  41. mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
  42. mock_pm.get_current_print_user = MagicMock(return_value=None)
  43. mock_pm.clear_current_print_user = MagicMock()
  44. mock_pm.set_awaiting_plate_clear = MagicMock()
  45. await on_print_complete(printer_id, {"filename": "", "subtask_name": "Benchy", "status": status, **extra})
  46. spawned = [c for c in mock_spawn.call_args_list if c.kwargs.get("name") == "notify-no-archive"]
  47. assert len(spawned) == 1, mock_spawn.call_args_list
  48. for other in mock_spawn.call_args_list:
  49. if other is not spawned[0] and hasattr(other.args[0], "close"):
  50. other.args[0].close()
  51. with (
  52. patch("backend.app.main.async_session", real_db),
  53. patch("backend.app.main.notification_service.on_print_complete", new_callable=AsyncMock) as sent,
  54. patch("backend.app.main._dispatch_user_print_email", new_callable=AsyncMock) as email,
  55. ):
  56. await spawned[0].args[0]
  57. sent.assert_awaited_once()
  58. return sent.await_args.kwargs["archive_data"], email
  59. async def _user(db_session) -> int:
  60. user = User(username="owner", password_hash="x", role="user", is_active=True)
  61. db_session.add(user)
  62. await db_session.commit()
  63. return user.id
  64. async def _library_file(db_session, metadata: dict | None) -> int:
  65. lib = LibraryFile(
  66. filename="benchy.3mf", file_path="library/benchy.3mf", file_type="3mf", file_size=1, file_metadata=metadata
  67. )
  68. db_session.add(lib)
  69. await db_session.commit()
  70. return lib.id
  71. async def _queue_item(db_session, printer_id: int, **fields) -> None:
  72. db_session.add(PrintQueueItem(printer_id=printer_id, **fields))
  73. await db_session.commit()
  74. class TestAPrintFromOutsideBambuddy:
  75. @pytest.mark.asyncio
  76. async def test_is_not_attributed_to_the_job_finished_before_it(self, test_engine, db_session, printer_factory):
  77. printer = await printer_factory()
  78. owner = await _user(db_session)
  79. lib_id = await _library_file(db_session, {"print_time_seconds": 5400})
  80. await _queue_item(
  81. db_session,
  82. printer.id,
  83. status="completed",
  84. created_by_id=owner,
  85. library_file_id=lib_id,
  86. print_time_seconds=5400,
  87. completed_at=datetime.now(timezone.utc) - timedelta(minutes=2),
  88. )
  89. archive_data, email = await _complete_without_archive(test_engine, printer.id)
  90. assert not (archive_data or {}).get("created_by_id")
  91. assert "print_time_seconds" not in (archive_data or {})
  92. email.assert_not_awaited()
  93. class TestAQueueJobWhoseArchiveWasNotFound:
  94. @pytest.mark.asyncio
  95. async def test_is_notified_with_its_owner_and_real_duration(self, test_engine, db_session, printer_factory):
  96. printer = await printer_factory()
  97. owner = await _user(db_session)
  98. lib_id = await _library_file(db_session, {"print_time_seconds": 5400})
  99. await _queue_item(
  100. db_session,
  101. printer.id,
  102. status="printing",
  103. created_by_id=owner,
  104. library_file_id=lib_id,
  105. started_at=datetime.now(timezone.utc) - timedelta(minutes=30),
  106. )
  107. archive_data, email = await _complete_without_archive(test_engine, printer.id, status="failed")
  108. assert archive_data["created_by_id"] == owner
  109. assert archive_data["print_time_seconds"] == 5400
  110. assert archive_data["actual_time_seconds"] == pytest.approx(1800, abs=60)
  111. email.assert_awaited_once()
  112. @pytest.mark.asyncio
  113. async def test_the_queue_items_cached_estimate_comes_first(self, test_engine, db_session, printer_factory):
  114. printer = await printer_factory()
  115. lib_id = await _library_file(db_session, {"print_time_seconds": 5400})
  116. await _queue_item(db_session, printer.id, status="printing", library_file_id=lib_id, print_time_seconds=4200)
  117. archive_data, _ = await _complete_without_archive(test_engine, printer.id)
  118. assert archive_data["print_time_seconds"] == 4200
  119. assert "actual_time_seconds" not in archive_data # no start time recorded
  120. class TestACompletionReconciledAtReconnect:
  121. @pytest.mark.asyncio
  122. async def test_has_no_actual_duration(self, test_engine, db_session, printer_factory):
  123. """DB-C9: the printer was offline, the print ended at some unknown time,
  124. and the reconnect closes the job now -- the gap is not how long it ran."""
  125. printer = await printer_factory()
  126. await _queue_item(
  127. db_session,
  128. printer.id,
  129. status="printing",
  130. print_time_seconds=4200,
  131. started_at=datetime.now(timezone.utc) - timedelta(hours=14),
  132. )
  133. archive_data, _ = await _complete_without_archive(test_engine, printer.id, status="aborted", _reconciled=True)
  134. assert archive_data["print_time_seconds"] == 4200
  135. assert "actual_time_seconds" not in archive_data