Files
bambuddy/backend/tests/unit/test_ws_broadcast_to_user.py
maziggy 9033b0f81e feat(toast): restore upload-progress toast for scheduler dispatches (#1625 follow-up)
FTP push to the printer into the server-side scheduler tick. That
removed the browser-side upload the old XHR-progress modal listened
to — users only saw the queue item flip to "active" with no visibility
into the FTP push + the H2D/H2D Pro 80-210 s project_file digestion
window before the printer actually started.

Port the legacy bg-dispatch toast rendering from
0b43ac0d:frontend/src/contexts/ToastContext.tsx lines 510-650 back in
place verbatim — same DOM tree, same Tailwind classes, same
formatFileSize bytes line, same uppercase status chip, same collapse
chevron, same awaitingPrinter derivation, same auto-dismiss. The only
adapt is the event ingestion: a useEffect maps the four scheduler-side
WS events to the legacy DispatchToastJob shape.

The toast materializes when the FTP push to the printer ACTUALLY
STARTS (queue_item_uploading) — NOT on POST /queue. A draft that
fired at queue-add made the toast jump to "Dispatched" before any
upload had happened.

Four backend WS events drive it: uploading (carries printer_name +
total_bytes), upload_progress (throttled at 200 ms / 256 KB to match
legacy background_dispatch.py:614-615 1:1, first call always emits,
completion always emits; an _UploadProgressBridge bridges from the
FTP executor thread to the asyncio loop), acked (printer transitioned
out of pre_state), failed (with a reason key the toast looks up as
dispatchToast.failed.{reason}). No queue_item_dispatched event: the
legacy path kept status=processing from upload start until printer
ack, "Awaiting printer..." derives from upload_progress_pct >= 99.9
(legacy uploadDoneAwaitingPrinter trick).

Per-user routing: WS connect resolves the principal username to
User.id once and stashes it on websocket.state, so
ws_manager.broadcast_to_user filters O(connections). Auth-disabled
installs route user_id=None to all connections — matches the legacy
single-user behaviour. The watchdog receives created_by_id through a
new kwarg so the static method can still emit acked without
re-fetching the queue item.
2026-06-27 11:28:00 +02:00

139 lines
4.2 KiB
Python

"""WebSocket dispatch-toast routing (#1625 follow-up).
Two contracts pinned here:
1. ``broadcast_to_user(uid, msg)`` only delivers to connections whose
``websocket.state.bambuddy_principal_user_id`` matches the target,
and fans out to all when the target is None (auth-disabled path).
2. The six ``send_queue_item_*`` helpers serialize the right payload
shape — the frontend toast reads exact field names + types.
"""
from __future__ import annotations
import json
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from backend.app.core.websocket import ConnectionManager
def _mock_conn(user_id: int | None):
"""Build a stand-in WebSocket-shaped object with the principal stamp."""
conn = SimpleNamespace()
conn.state = SimpleNamespace()
conn.state.bambuddy_principal_user_id = user_id
conn.send_text = AsyncMock()
return conn
@pytest.mark.asyncio
async def test_broadcast_to_user_filters_by_principal_user_id():
"""A targeted broadcast only reaches the principal's connections."""
mgr = ConnectionManager()
alice = _mock_conn(7)
bob = _mock_conn(8)
anon = _mock_conn(None) # auth-disabled session — skipped on targeted path
mgr.active_connections = [alice, bob, anon]
await mgr.broadcast_to_user(7, {"type": "queue_item_uploading", "queue_item_id": 1})
alice.send_text.assert_awaited_once()
bob.send_text.assert_not_awaited()
anon.send_text.assert_not_awaited()
@pytest.mark.asyncio
async def test_broadcast_to_user_none_fans_out_to_all():
"""Auth-disabled installs route ``user_id=None`` to every connection
via the regular broadcast — matches the legacy single-user toast
behaviour where there was no per-user routing at all."""
mgr = ConnectionManager()
a = _mock_conn(None)
b = _mock_conn(None)
mgr.active_connections = [a, b]
await mgr.broadcast_to_user(None, {"type": "queue_item_uploading", "queue_item_id": 1})
a.send_text.assert_awaited_once()
b.send_text.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_queue_item_uploading_carries_total_bytes():
mgr = ConnectionManager()
target = _mock_conn(42)
mgr.active_connections = [target]
await mgr.send_queue_item_uploading(
user_id=42,
queue_item_id=11,
printer_id=1,
printer_name="H2D-1",
file_name="cube.3mf",
total_bytes=12345,
)
payload = json.loads(target.send_text.await_args.args[0])
assert payload == {
"type": "queue_item_uploading",
"queue_item_id": 11,
"printer_id": 1,
"printer_name": "H2D-1",
"file_name": "cube.3mf",
"total_bytes": 12345,
}
@pytest.mark.asyncio
async def test_send_queue_item_upload_progress_computes_pct_server_side():
"""The toast renders the pct field verbatim — the backend has to
compute it. Avoid divide-by-zero on a zero-byte upload."""
mgr = ConnectionManager()
target = _mock_conn(5)
mgr.active_connections = [target]
await mgr.send_queue_item_upload_progress(
user_id=5,
queue_item_id=3,
bytes_transferred=50,
total_bytes=200,
)
payload = json.loads(target.send_text.await_args.args[0])
assert payload["pct"] == 25
target.send_text.reset_mock()
await mgr.send_queue_item_upload_progress(
user_id=5,
queue_item_id=3,
bytes_transferred=0,
total_bytes=0,
)
payload = json.loads(target.send_text.await_args.args[0])
assert payload["pct"] == 0
@pytest.mark.asyncio
async def test_send_queue_item_failed_carries_reason_key():
"""The frontend looks up ``dispatchToast.failed.{reason}`` — so the
backend must hand the toast a reason string the i18n can match."""
mgr = ConnectionManager()
target = _mock_conn(99)
mgr.active_connections = [target]
await mgr.send_queue_item_failed(
user_id=99,
queue_item_id=8,
printer_id=2,
reason="upload_failed",
)
payload = json.loads(target.send_text.await_args.args[0])
assert payload == {
"type": "queue_item_failed",
"queue_item_id": 8,
"printer_id": 2,
"reason": "upload_failed",
}