| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263 |
- """A dispatch that never sends the print command must leave no expectation.
- ``register_expected_print`` has to run *before* the MQTT command, because the
- printer can report the print before the send returns. So any path that
- registers and then fails to send leaves Bambuddy expecting a print that will
- never arrive: a cancel winning the #1853 CAS race, ``start_print()`` returning
- False, or an exception in between — a PostgreSQL connection failure mid-dispatch
- is the case that surfaced this (#2702 follow-up).
- The two-hour TTL sweep does eventually evict such an entry, but two hours is far
- longer than it takes someone to react to a failed dispatch by pressing print
- again. That reprint would be folded into the *old* archive and inherit its
- ``ams_mapping`` and ``plate_id`` instead of creating a fresh one.
- """
- from __future__ import annotations
- import pytest
- @pytest.fixture
- def expected_print_tables():
- """The module-level registries, emptied around each test."""
- from backend.app import main
- names = (
- "_expected_prints",
- "_expected_print_creators",
- "_expected_print_registered_at",
- "_print_ams_mappings",
- "_print_plate_ids",
- )
- saved = {n: dict(getattr(main, n)) for n in names}
- for n in names:
- getattr(main, n).clear()
- yield main
- for n in names:
- getattr(main, n).clear()
- getattr(main, n).update(saved[n])
- # ---------------------------------------------------------------------------
- # unregister_expected_print is the exact inverse of register_expected_print
- # ---------------------------------------------------------------------------
- def test_unregister_leaves_every_registry_as_it_found_them(expected_print_tables):
- """The strongest form: register then unregister is a round trip to empty."""
- main = expected_print_tables
- main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], created_by_id=7, plate_id=1)
- assert main._expected_prints, "nothing registered — the test proves nothing"
- main.unregister_expected_print(1, "widget.3mf", 298)
- assert main._expected_prints == {}
- assert main._expected_print_creators == {}
- assert main._expected_print_registered_at == {}
- assert main._print_ams_mappings == {}
- assert main._print_plate_ids == {}
- def test_unregister_clears_the_filename_variants_too(expected_print_tables):
- """Registration stores the name three ways; a partial undo still matches."""
- main = expected_print_tables
- main.register_expected_print(1, "widget.3mf", 298)
- main.unregister_expected_print(1, "widget.3mf", 298)
- for key in ((1, "widget.3mf"), (1, "widget"), (1, "widget.gcode")):
- assert key not in main._expected_prints, f"{key} survived"
- def test_unregister_does_not_touch_another_printers_expectation(expected_print_tables):
- main = expected_print_tables
- main.register_expected_print(1, "widget.3mf", 298)
- main.register_expected_print(2, "widget.3mf", 299)
- main.unregister_expected_print(1, "widget.3mf", 298)
- assert main._expected_prints[(2, "widget.3mf")] == 299
- def test_archive_keyed_tables_survive_while_another_file_still_points_at_them(
- expected_print_tables,
- ):
- """Mirrors the TTL sweep's rule, which is the easy thing to get wrong.
- ``_print_ams_mappings`` and ``_print_plate_ids`` are keyed by archive, not
- by file. Two files can be registered against one archive, so dropping them
- on the first unregister would strip usage-tracking data from a print that is
- still expected.
- """
- main = expected_print_tables
- main.register_expected_print(1, "plate1.3mf", 298, ams_mapping=[3], plate_id=1)
- main.register_expected_print(1, "plate2.3mf", 298, ams_mapping=[3], plate_id=2)
- main.unregister_expected_print(1, "plate1.3mf", 298)
- assert main._print_ams_mappings.get(298) == [3]
- assert 298 in main._print_plate_ids
- def test_unregistering_an_unknown_print_is_a_no_op(expected_print_tables):
- """Runs from a ``finally``, so it must tolerate having nothing to do."""
- main = expected_print_tables
- main.unregister_expected_print(99, "never-registered.3mf", 1234)
- assert main._expected_prints == {}
- # ---------------------------------------------------------------------------
- # The scheduler's rollback hook
- # ---------------------------------------------------------------------------
- def test_scheduler_rollback_undoes_a_recorded_registration(expected_print_tables):
- main = expected_print_tables
- from backend.app.services.print_scheduler import PrintScheduler
- sched = PrintScheduler()
- main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
- sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
- sched._rollback_unconfirmed_expected_print(597)
- assert main._expected_prints == {}
- assert sched._unconfirmed_expected_print == {}
- def test_scheduler_rollback_is_a_no_op_after_a_confirmed_send(expected_print_tables):
- """A sent print's expectation must survive — the callback needs it."""
- main = expected_print_tables
- from backend.app.services.print_scheduler import PrintScheduler
- sched = PrintScheduler()
- main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
- sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
- # What `_start_print` does once start_print() returns True.
- sched._unconfirmed_expected_print.pop(597, None)
- sched._rollback_unconfirmed_expected_print(597)
- assert main._expected_prints[(1, "widget.3mf")] == 298
- assert main._print_ams_mappings[298] == [3, 6]
- def test_scheduler_rollback_never_raises(expected_print_tables, monkeypatch):
- """It runs in the ``finally`` of dispatch, usually with an exception already
- propagating — it must not replace it with one of its own."""
- from backend.app.services.print_scheduler import PrintScheduler
- sched = PrintScheduler()
- sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
- monkeypatch.setattr(
- expected_print_tables,
- "unregister_expected_print",
- lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")),
- )
- sched._rollback_unconfirmed_expected_print(597) # must not raise
- assert sched._unconfirmed_expected_print == {}, "entry must be dropped even on failure"
- @pytest.mark.asyncio
- @pytest.mark.unit
- async def test_dispatch_withdraws_the_expectation_when_start_print_raises(expected_print_tables):
- """End to end through `_dispatch_one`, on the reported failure.
- A database error inside `_start_print` must leave no expectation behind, must
- still release the claim, and must not be swallowed — the background-task
- runner logs it, and hiding it here would turn a loud failure into a silent
- one.
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- main = expected_print_tables
- from backend.app.services.print_scheduler import PrintScheduler
- sched = PrintScheduler()
- async def fake_start_print(db, item):
- # What `_start_print` does before the point the real one died.
- main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6], plate_id=1)
- sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
- raise RuntimeError("remaining connection slots are reserved for roles with the SUPERUSER attribute")
- db = MagicMock()
- db.get = AsyncMock(return_value=MagicMock(id=597))
- ctx = MagicMock()
- ctx.__aenter__ = AsyncMock(return_value=db)
- ctx.__aexit__ = AsyncMock(return_value=False)
- with (
- patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
- patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
- patch.object(sched, "_start_print", side_effect=fake_start_print),
- patch.object(sched, "_clear_dispatch_claim", AsyncMock()) as clear,
- pytest.raises(RuntimeError),
- ):
- await sched._dispatch_one(597)
- assert main._expected_prints == {}, "expectation survived a dispatch that never sent a print"
- assert main._print_ams_mappings == {}
- assert main._print_plate_ids == {}
- assert sched._unconfirmed_expected_print == {}
- clear.assert_awaited_once_with(db, 597)
- @pytest.mark.asyncio
- @pytest.mark.unit
- async def test_dispatch_keeps_the_expectation_when_the_print_was_sent(expected_print_tables):
- """The mirror image: a confirmed send must survive dispatch teardown, or the
- print-complete callback would create a duplicate archive."""
- from unittest.mock import AsyncMock, MagicMock, patch
- main = expected_print_tables
- from backend.app.services.print_scheduler import PrintScheduler
- sched = PrintScheduler()
- async def fake_start_print(db, item):
- main.register_expected_print(1, "widget.3mf", 298, ams_mapping=[3, 6])
- sched._unconfirmed_expected_print[597] = (1, "widget.3mf", 298)
- sched._unconfirmed_expected_print.pop(597, None) # start_print() returned True
- db = MagicMock()
- db.get = AsyncMock(return_value=MagicMock(id=597))
- ctx = MagicMock()
- ctx.__aenter__ = AsyncMock(return_value=db)
- ctx.__aexit__ = AsyncMock(return_value=False)
- with (
- patch("backend.app.services.print_scheduler.async_session", return_value=ctx),
- patch.object(sched, "_claim_for_dispatch", AsyncMock(return_value=True)),
- patch.object(sched, "_start_print", side_effect=fake_start_print),
- patch.object(sched, "_clear_dispatch_claim", AsyncMock()),
- ):
- await sched._dispatch_one(597)
- assert main._expected_prints[(1, "widget.3mf")] == 298
- assert main._print_ams_mappings[298] == [3, 6]
- def test_rollback_entries_are_per_item(expected_print_tables):
- """Two dispatches in flight must not roll back each other's registration."""
- main = expected_print_tables
- from backend.app.services.print_scheduler import PrintScheduler
- sched = PrintScheduler()
- main.register_expected_print(1, "a.3mf", 1)
- main.register_expected_print(2, "b.3mf", 2)
- sched._unconfirmed_expected_print[10] = (1, "a.3mf", 1)
- sched._unconfirmed_expected_print[11] = (2, "b.3mf", 2)
- sched._rollback_unconfirmed_expected_print(10)
- assert (1, "a.3mf") not in main._expected_prints
- assert main._expected_prints[(2, "b.3mf")] == 2
|