diff --git a/spoolman/api/v1/models.py b/spoolman/api/v1/models.py index a24d79f7c..08e5a10ea 100644 --- a/spoolman/api/v1/models.py +++ b/spoolman/api/v1/models.py @@ -406,6 +406,9 @@ class SpoolEvent(Event): payload: Spool = Field(description="Updated spool.") resource: Literal["spool"] = Field(description="Resource type.") + payload_extras: dict[str, float] | None = \ + Field(default=None, + description="Payload extra fields outside of core Spool model") class FilamentEvent(Event): diff --git a/spoolman/database/spool.py b/spoolman/database/spool.py index 5c190ce65..ac1b4750f 100644 --- a/spoolman/database/spool.py +++ b/spoolman/database/spool.py @@ -298,7 +298,7 @@ async def use_weight(db: AsyncSession, spool_id: int, weight: float) -> models.S spool.last_used = datetime.utcnow().replace(microsecond=0) await db.commit() - await spool_changed(spool, EventType.UPDATED) + await spool_changed(spool, EventType.UPDATED, {"weight_delta": weight}) return spool @@ -344,7 +344,7 @@ async def use_length(db: AsyncSession, spool_id: int, length: float) -> models.S spool.last_used = datetime.utcnow().replace(microsecond=0) await db.commit() - await spool_changed(spool, EventType.UPDATED) + await spool_changed(spool, EventType.UPDATED, {"weight_delta": weight}) return spool @@ -436,16 +436,18 @@ async def find_lot_numbers( return [row[0] for row in rows.all() if row[0] is not None] -async def spool_changed(spool: models.Spool, typ: EventType) -> None: +async def spool_changed(spool: models.Spool, typ: EventType, delta: Optional[dict] = None) -> None: """Notify websocket clients that a spool has changed.""" try: + spool = Spool.from_db(spool) await websocket_manager.send( ("spool", str(spool.id)), SpoolEvent( type=typ, resource="spool", date=datetime.utcnow(), - payload=Spool.from_db(spool), + payload=spool, + payload_extras=delta ), ) except Exception: diff --git a/tests_integration/requirements.txt b/tests_integration/requirements.txt index 9c84ad6b0..d23ac877c 100644 --- a/tests_integration/requirements.txt +++ b/tests_integration/requirements.txt @@ -1,3 +1,4 @@ pytest==8.3.2 pytest-asyncio==0.23.8 httpx==0.27.0 +websockets==15.0.1 diff --git a/tests_integration/tests/spool/test_spool_event.py b/tests_integration/tests/spool/test_spool_event.py new file mode 100644 index 000000000..c605933d0 --- /dev/null +++ b/tests_integration/tests/spool/test_spool_event.py @@ -0,0 +1,51 @@ +"""Integration tests for spool websocket events.""" + +import asyncio +import json +from typing import Any + +import httpx +import pytest +import websockets + +from ..conftest import URL + + +@pytest.mark.asyncio +async def test_use_weight_websocket_has_weight_delta(random_filament: dict[str, Any]): + """Test websocket payload extras for spool weight usage.""" + #Setup + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "remaining_weight": 1000}, + ) + result.raise_for_status() + spool = result.json() + spool_id = spool["id"] + ws_url = URL.replace("http://", "ws://").replace("https://", "wss://") + f"/api/v1/spool/{spool_id}" + use_weight = 6.9 + + try: + async with websockets.connect(ws_url) as ws: + # keep the socket loop healthy before triggering update + await ws.send("ping") + check = json.loads(await asyncio.wait_for(ws.recv(), timeout=2)) + assert check["status"] == "healthy" + + #execute + r = httpx.put(f"{URL}/api/v1/spool/{spool_id}/use", json={"use_weight": use_weight}) + r.raise_for_status() + raw = await asyncio.wait_for(ws.recv(), timeout=5) + evt = json.loads(raw) + #verify + assert evt["resource"] == "spool" + assert evt["type"] == "updated" + assert evt["payload"]["id"] == spool_id + assert evt["payload_extras"]["weight_delta"] == pytest.approx(use_weight) + assert "event_delta" not in evt["payload"].get("extra", {}) + #cleanup-ws + await ws.close(code=1000) + await asyncio.sleep(0.6) + finally: + #cleanup + httpx.delete(f"{URL}/api/v1/spool/{spool_id}").raise_for_status()