mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 11:12:35 +02:00
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.
139 lines
4.2 KiB
Python
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",
|
|
}
|