backend: notify spools when their filament changes

Spool responses carry fields derived from the filament (remaining_length,
plus the initial_weight/price fall-backs). Editing a filament emitted only a
filament WS event, so subscribers to that filament's spools kept stale derived
values until each spool was next touched — visible in client_v2 as a length
inconsistent with the displayed density, but affecting every consumer.

filament.update now re-broadcasts the filament's spools (archived included)
as spool "updated" events after commit. Adds a WS integration test asserting
a density patch reaches /api/v1/spool with the new density and changed length.

Claude-Session: https://claude.ai/code/session_014Cwk5Yyze9yPvkYkUtMAP4
This commit is contained in:
Donkie committed 2026-07-21 10:58:09 +02:00
1 parent 990f16a8e9
commit 6ade40517f
3 files changed
+74

No files matched your search

+17
View File
@@ -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)
+1
View File
@@ -1,3 +1,4 @@
pytest==9.1.1
pytest-asyncio==1.4.0
httpx==0.27.0
websockets==13.1
@@ -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()