test_ws_broadcast_to_user.py 4.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144
  1. """WebSocket dispatch-toast routing (#1625 follow-up).
  2. Two contracts pinned here:
  3. 1. ``broadcast_to_user(uid, msg)`` only delivers to connections whose
  4. ``websocket.state.bambuddy_principal_user_id`` matches the target,
  5. and fans out to all when the target is None (auth-disabled path).
  6. 2. The six ``send_queue_item_*`` helpers serialize the right payload
  7. shape — the frontend toast reads exact field names + types.
  8. """
  9. from __future__ import annotations
  10. import json
  11. from types import SimpleNamespace
  12. from unittest.mock import AsyncMock
  13. import pytest
  14. from backend.app.core.printer_scope import ALL_PRINTERS
  15. from backend.app.core.websocket import ConnectionManager
  16. def _mock_conn(user_id: int | None):
  17. """Build a stand-in WebSocket-shaped object with the principal stamp.
  18. Every real connection also gets a printer scope at connect (#1727); these
  19. tests are about user routing, so it is the unrestricted one.
  20. """
  21. conn = SimpleNamespace()
  22. conn.state = SimpleNamespace()
  23. conn.state.bambuddy_principal_user_id = user_id
  24. conn.state.bambuddy_printer_scope = ALL_PRINTERS
  25. conn.send_text = AsyncMock()
  26. return conn
  27. @pytest.mark.asyncio
  28. async def test_broadcast_to_user_filters_by_principal_user_id():
  29. """A targeted broadcast only reaches the principal's connections."""
  30. mgr = ConnectionManager()
  31. alice = _mock_conn(7)
  32. bob = _mock_conn(8)
  33. anon = _mock_conn(None) # auth-disabled session — skipped on targeted path
  34. mgr.active_connections = [alice, bob, anon]
  35. await mgr.broadcast_to_user(7, {"type": "queue_item_uploading", "queue_item_id": 1})
  36. alice.send_text.assert_awaited_once()
  37. bob.send_text.assert_not_awaited()
  38. anon.send_text.assert_not_awaited()
  39. @pytest.mark.asyncio
  40. async def test_broadcast_to_user_none_fans_out_to_all():
  41. """Auth-disabled installs route ``user_id=None`` to every connection
  42. via the regular broadcast — matches the legacy single-user toast
  43. behaviour where there was no per-user routing at all."""
  44. mgr = ConnectionManager()
  45. a = _mock_conn(None)
  46. b = _mock_conn(None)
  47. mgr.active_connections = [a, b]
  48. await mgr.broadcast_to_user(None, {"type": "queue_item_uploading", "queue_item_id": 1})
  49. a.send_text.assert_awaited_once()
  50. b.send_text.assert_awaited_once()
  51. @pytest.mark.asyncio
  52. async def test_send_queue_item_uploading_carries_total_bytes():
  53. mgr = ConnectionManager()
  54. target = _mock_conn(42)
  55. mgr.active_connections = [target]
  56. await mgr.send_queue_item_uploading(
  57. user_id=42,
  58. queue_item_id=11,
  59. printer_id=1,
  60. printer_name="H2D-1",
  61. file_name="cube.3mf",
  62. total_bytes=12345,
  63. )
  64. payload = json.loads(target.send_text.await_args.args[0])
  65. assert payload == {
  66. "type": "queue_item_uploading",
  67. "queue_item_id": 11,
  68. "printer_id": 1,
  69. "printer_name": "H2D-1",
  70. "file_name": "cube.3mf",
  71. "total_bytes": 12345,
  72. }
  73. @pytest.mark.asyncio
  74. async def test_send_queue_item_upload_progress_computes_pct_server_side():
  75. """The toast renders the pct field verbatim — the backend has to
  76. compute it. Avoid divide-by-zero on a zero-byte upload."""
  77. mgr = ConnectionManager()
  78. target = _mock_conn(5)
  79. mgr.active_connections = [target]
  80. await mgr.send_queue_item_upload_progress(
  81. user_id=5,
  82. queue_item_id=3,
  83. bytes_transferred=50,
  84. total_bytes=200,
  85. )
  86. payload = json.loads(target.send_text.await_args.args[0])
  87. assert payload["pct"] == 25
  88. target.send_text.reset_mock()
  89. await mgr.send_queue_item_upload_progress(
  90. user_id=5,
  91. queue_item_id=3,
  92. bytes_transferred=0,
  93. total_bytes=0,
  94. )
  95. payload = json.loads(target.send_text.await_args.args[0])
  96. assert payload["pct"] == 0
  97. @pytest.mark.asyncio
  98. async def test_send_queue_item_failed_carries_reason_key():
  99. """The frontend looks up ``dispatchToast.failed.{reason}`` — so the
  100. backend must hand the toast a reason string the i18n can match."""
  101. mgr = ConnectionManager()
  102. target = _mock_conn(99)
  103. mgr.active_connections = [target]
  104. await mgr.send_queue_item_failed(
  105. user_id=99,
  106. queue_item_id=8,
  107. printer_id=2,
  108. reason="upload_failed",
  109. )
  110. payload = json.loads(target.send_text.await_args.args[0])
  111. assert payload == {
  112. "type": "queue_item_failed",
  113. "queue_item_id": 8,
  114. "printer_id": 2,
  115. "reason": "upload_failed",
  116. }