| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167 |
- """A completion with no archive is notified about its own queue job, or none.
- When a completion matches no archive, the notification used to borrow the
- queue item this printer had finished in the last five minutes. For a print
- started outside Bambuddy that was some earlier, unrelated job: its owner got
- the "your print is done" email, and the notification took that job's data. It
- now uses only the queue item the completion itself closed (the one
- _completion_belongs_to_queue_item accepted), with that job's real duration.
- """
- from datetime import datetime, timedelta, timezone
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
- from backend.app.models.library import LibraryFile
- from backend.app.models.print_queue import PrintQueueItem
- from backend.app.models.user import User
- async def _complete_without_archive(test_engine, printer_id: int, status: str = "completed", **extra):
- """Run on_print_complete for a print no archive matches.
- Returns (archive_data the notification got, user-email mock). The queue
- reconciliation and the spawned notification run against the test database;
- the archive lookup sees an empty session, so the no-archive path is taken.
- """
- from backend.app.main import on_print_complete
- real_db = async_sessionmaker(test_engine, class_=AsyncSession, expire_on_commit=False)
- empty_session = AsyncMock()
- empty_session.__aenter__ = AsyncMock(return_value=empty_session)
- empty_session.__aexit__ = AsyncMock()
- empty_session.execute = AsyncMock(
- return_value=MagicMock(scalar_one_or_none=MagicMock(return_value=None), scalars=MagicMock())
- )
- with (
- patch("backend.app.main.async_session", MagicMock(return_value=empty_session)),
- patch("backend.app.core.database.async_session", real_db),
- patch("backend.app.main.ws_manager") as mock_ws,
- patch("backend.app.main.printer_manager") as mock_pm,
- patch("backend.app.main.mqtt_relay") as mock_relay,
- patch("backend.app.main.spawn_background_task") as mock_spawn,
- patch("backend.app.main.clear_3mf_cache"),
- ):
- mock_ws.send_print_complete = AsyncMock()
- mock_relay.on_print_complete = AsyncMock()
- mock_pm.get_printer = MagicMock(return_value=MagicMock(name="Test", serial_number="TEST123"))
- mock_pm.get_current_print_user = MagicMock(return_value=None)
- mock_pm.clear_current_print_user = MagicMock()
- mock_pm.set_awaiting_plate_clear = MagicMock()
- await on_print_complete(printer_id, {"filename": "", "subtask_name": "Benchy", "status": status, **extra})
- spawned = [c for c in mock_spawn.call_args_list if c.kwargs.get("name") == "notify-no-archive"]
- assert len(spawned) == 1, mock_spawn.call_args_list
- for other in mock_spawn.call_args_list:
- if other is not spawned[0] and hasattr(other.args[0], "close"):
- other.args[0].close()
- with (
- patch("backend.app.main.async_session", real_db),
- patch("backend.app.main.notification_service.on_print_complete", new_callable=AsyncMock) as sent,
- patch("backend.app.main._dispatch_user_print_email", new_callable=AsyncMock) as email,
- ):
- await spawned[0].args[0]
- sent.assert_awaited_once()
- return sent.await_args.kwargs["archive_data"], email
- async def _user(db_session) -> int:
- user = User(username="owner", password_hash="x", role="user", is_active=True)
- db_session.add(user)
- await db_session.commit()
- return user.id
- async def _library_file(db_session, metadata: dict | None) -> int:
- lib = LibraryFile(
- filename="benchy.3mf", file_path="library/benchy.3mf", file_type="3mf", file_size=1, file_metadata=metadata
- )
- db_session.add(lib)
- await db_session.commit()
- return lib.id
- async def _queue_item(db_session, printer_id: int, **fields) -> None:
- db_session.add(PrintQueueItem(printer_id=printer_id, **fields))
- await db_session.commit()
- class TestAPrintFromOutsideBambuddy:
- @pytest.mark.asyncio
- async def test_is_not_attributed_to_the_job_finished_before_it(self, test_engine, db_session, printer_factory):
- printer = await printer_factory()
- owner = await _user(db_session)
- lib_id = await _library_file(db_session, {"print_time_seconds": 5400})
- await _queue_item(
- db_session,
- printer.id,
- status="completed",
- created_by_id=owner,
- library_file_id=lib_id,
- print_time_seconds=5400,
- completed_at=datetime.now(timezone.utc) - timedelta(minutes=2),
- )
- archive_data, email = await _complete_without_archive(test_engine, printer.id)
- assert not (archive_data or {}).get("created_by_id")
- assert "print_time_seconds" not in (archive_data or {})
- email.assert_not_awaited()
- class TestAQueueJobWhoseArchiveWasNotFound:
- @pytest.mark.asyncio
- async def test_is_notified_with_its_owner_and_real_duration(self, test_engine, db_session, printer_factory):
- printer = await printer_factory()
- owner = await _user(db_session)
- lib_id = await _library_file(db_session, {"print_time_seconds": 5400})
- await _queue_item(
- db_session,
- printer.id,
- status="printing",
- created_by_id=owner,
- library_file_id=lib_id,
- started_at=datetime.now(timezone.utc) - timedelta(minutes=30),
- )
- archive_data, email = await _complete_without_archive(test_engine, printer.id, status="failed")
- assert archive_data["created_by_id"] == owner
- assert archive_data["print_time_seconds"] == 5400
- assert archive_data["actual_time_seconds"] == pytest.approx(1800, abs=60)
- email.assert_awaited_once()
- @pytest.mark.asyncio
- async def test_the_queue_items_cached_estimate_comes_first(self, test_engine, db_session, printer_factory):
- printer = await printer_factory()
- lib_id = await _library_file(db_session, {"print_time_seconds": 5400})
- await _queue_item(db_session, printer.id, status="printing", library_file_id=lib_id, print_time_seconds=4200)
- archive_data, _ = await _complete_without_archive(test_engine, printer.id)
- assert archive_data["print_time_seconds"] == 4200
- assert "actual_time_seconds" not in archive_data # no start time recorded
- class TestACompletionReconciledAtReconnect:
- @pytest.mark.asyncio
- async def test_has_no_actual_duration(self, test_engine, db_session, printer_factory):
- """DB-C9: the printer was offline, the print ended at some unknown time,
- and the reconnect closes the job now -- the gap is not how long it ran."""
- printer = await printer_factory()
- await _queue_item(
- db_session,
- printer.id,
- status="printing",
- print_time_seconds=4200,
- started_at=datetime.now(timezone.utc) - timedelta(hours=14),
- )
- archive_data, _ = await _complete_without_archive(test_engine, printer.id, status="aborted", _reconciled=True)
- assert archive_data["print_time_seconds"] == 4200
- assert "actual_time_seconds" not in archive_data
|