diff --git a/spoolman/database/filament.py b/spoolman/database/filament.py index caa42e7e..5bffef80 100644 --- a/spoolman/database/filament.py +++ b/spoolman/database/filament.py @@ -191,9 +191,26 @@ async def update( setattr(filament, k, v) await db.commit() await filament_changed(filament, EventType.UPDATED) + # A spool's response carries fields derived from its filament (remaining_length, + # and the initial_weight/price fall-backs to the filament's values). Those change + # when the filament changes, yet no spool row was touched — so subscribers to the + # affected spools would keep stale derived values until each spool is next edited. + # Re-broadcast the filament's spools so websocket clients refresh them. + await _notify_filament_spools(db, filament_id) return filament +async def _notify_filament_spools(db: AsyncSession, filament_id: int) -> None: + """Emit a spool 'updated' event for every spool belonging to a filament.""" + # Local import: spool.py imports this module at load time, so importing it at the + # top here would be a circular import. + from spoolman.database import spool # noqa: PLC0415 + + spools, _ = await spool.find(db=db, filament_id=filament_id, allow_archived=True) + for s in spools: + await spool.spool_changed(s, EventType.UPDATED) + + async def delete(db: AsyncSession, filament_id: int) -> None: """Delete a filament object.""" filament = await get_by_id(db, filament_id) diff --git a/tests_integration/requirements.txt b/tests_integration/requirements.txt index 0d2da6db..fec45362 100644 --- a/tests_integration/requirements.txt +++ b/tests_integration/requirements.txt @@ -1,3 +1,4 @@ pytest==9.1.1 pytest-asyncio==1.4.0 httpx==0.27.0 +websockets==13.1 diff --git a/tests_integration/tests/filament/test_notify_spools.py b/tests_integration/tests/filament/test_notify_spools.py new file mode 100644 index 00000000..e4c7ef8f --- /dev/null +++ b/tests_integration/tests/filament/test_notify_spools.py @@ -0,0 +1,56 @@ +"""Integration tests: a filament update re-broadcasts its spools over websockets. + +Spool responses carry fields derived from the filament (e.g. remaining_length, and the +initial_weight/price fall-backs). Editing the filament changes those derived values +without touching any spool row, so the server must emit a spool 'updated' event for +every spool of the filament — otherwise websocket subscribers keep stale values until +the spool itself is next edited. +""" + +import asyncio +import json +from typing import Any + +import httpx +import pytest +from websockets.asyncio.client import connect + +from ..conftest import URL + +WS_URL = URL.replace("http://", "ws://", 1) + + +@pytest.mark.asyncio +async def test_filament_update_notifies_spools(random_filament: dict[str, Any]) -> None: + """Patching a filament's density emits a spool 'updated' event with fresh derived fields.""" + # Setup: one spool for this filament. + result = httpx.post( + f"{URL}/api/v1/spool", + json={"filament_id": random_filament["id"], "remaining_weight": 500}, + ) + result.raise_for_status() + spool = result.json() + original_length = spool["remaining_length"] + + try: + async with connect(f"{WS_URL}/api/v1/spool") as ws: + # The subscription is registered synchronously right after the handshake, + # but give the server a beat before triggering the event. + await asyncio.sleep(0.2) + + new_density = random_filament["density"] * 2 + httpx.patch( + f"{URL}/api/v1/filament/{random_filament['id']}", + json={"density": new_density}, + ).raise_for_status() + + event = json.loads(await asyncio.wait_for(ws.recv(), timeout=10)) + + assert event["type"] == "updated" + assert event["resource"] == "spool" + assert event["payload"]["id"] == spool["id"] + # The embedded filament and the derived length must reflect the new density. + assert event["payload"]["filament"]["density"] == new_density + assert event["payload"]["remaining_length"] != original_length + finally: + httpx.delete(f"{URL}/api/v1/spool/{spool['id']}").raise_for_status()