test_ws_broadcast_to_user.py 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  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.websocket import ConnectionManager
  15. def _mock_conn(user_id: int | None):
  16. """Build a stand-in WebSocket-shaped object with the principal stamp."""
  17. conn = SimpleNamespace()
  18. conn.state = SimpleNamespace()
  19. conn.state.bambuddy_principal_user_id = user_id
  20. conn.send_text = AsyncMock()
  21. return conn
  22. @pytest.mark.asyncio
  23. async def test_broadcast_to_user_filters_by_principal_user_id():
  24. """A targeted broadcast only reaches the principal's connections."""
  25. mgr = ConnectionManager()
  26. alice = _mock_conn(7)
  27. bob = _mock_conn(8)
  28. anon = _mock_conn(None) # auth-disabled session — skipped on targeted path
  29. mgr.active_connections = [alice, bob, anon]
  30. await mgr.broadcast_to_user(7, {"type": "queue_item_uploading", "queue_item_id": 1})
  31. alice.send_text.assert_awaited_once()
  32. bob.send_text.assert_not_awaited()
  33. anon.send_text.assert_not_awaited()
  34. @pytest.mark.asyncio
  35. async def test_broadcast_to_user_none_fans_out_to_all():
  36. """Auth-disabled installs route ``user_id=None`` to every connection
  37. via the regular broadcast — matches the legacy single-user toast
  38. behaviour where there was no per-user routing at all."""
  39. mgr = ConnectionManager()
  40. a = _mock_conn(None)
  41. b = _mock_conn(None)
  42. mgr.active_connections = [a, b]
  43. await mgr.broadcast_to_user(None, {"type": "queue_item_uploading", "queue_item_id": 1})
  44. a.send_text.assert_awaited_once()
  45. b.send_text.assert_awaited_once()
  46. @pytest.mark.asyncio
  47. async def test_send_queue_item_uploading_carries_total_bytes():
  48. mgr = ConnectionManager()
  49. target = _mock_conn(42)
  50. mgr.active_connections = [target]
  51. await mgr.send_queue_item_uploading(
  52. user_id=42,
  53. queue_item_id=11,
  54. printer_id=1,
  55. printer_name="H2D-1",
  56. file_name="cube.3mf",
  57. total_bytes=12345,
  58. )
  59. payload = json.loads(target.send_text.await_args.args[0])
  60. assert payload == {
  61. "type": "queue_item_uploading",
  62. "queue_item_id": 11,
  63. "printer_id": 1,
  64. "printer_name": "H2D-1",
  65. "file_name": "cube.3mf",
  66. "total_bytes": 12345,
  67. }
  68. @pytest.mark.asyncio
  69. async def test_send_queue_item_upload_progress_computes_pct_server_side():
  70. """The toast renders the pct field verbatim — the backend has to
  71. compute it. Avoid divide-by-zero on a zero-byte upload."""
  72. mgr = ConnectionManager()
  73. target = _mock_conn(5)
  74. mgr.active_connections = [target]
  75. await mgr.send_queue_item_upload_progress(
  76. user_id=5,
  77. queue_item_id=3,
  78. bytes_transferred=50,
  79. total_bytes=200,
  80. )
  81. payload = json.loads(target.send_text.await_args.args[0])
  82. assert payload["pct"] == 25
  83. target.send_text.reset_mock()
  84. await mgr.send_queue_item_upload_progress(
  85. user_id=5,
  86. queue_item_id=3,
  87. bytes_transferred=0,
  88. total_bytes=0,
  89. )
  90. payload = json.loads(target.send_text.await_args.args[0])
  91. assert payload["pct"] == 0
  92. @pytest.mark.asyncio
  93. async def test_send_queue_item_failed_carries_reason_key():
  94. """The frontend looks up ``dispatchToast.failed.{reason}`` — so the
  95. backend must hand the toast a reason string the i18n can match."""
  96. mgr = ConnectionManager()
  97. target = _mock_conn(99)
  98. mgr.active_connections = [target]
  99. await mgr.send_queue_item_failed(
  100. user_id=99,
  101. queue_item_id=8,
  102. printer_id=2,
  103. reason="upload_failed",
  104. )
  105. payload = json.loads(target.send_text.await_args.args[0])
  106. assert payload == {
  107. "type": "queue_item_failed",
  108. "queue_item_id": 8,
  109. "printer_id": 2,
  110. "reason": "upload_failed",
  111. }