Files
bambuddy/backend/tests/integration/test_spoolman_slot_concurrency.py
maziggy 12b0c138f7 fix(spoolman): clear stale fallback-tag links on assign + link, prefer slot-assignment over tag-link in UI (#1457)
Reporter on a P1S with non-RFID spools saw an old, almost-empty spool in
  the AMS hover card's "Spulen-ID" block while the "Zugewiesen" block
  correctly showed the freshly assigned full spool. Two layers compounded:

    (1) Non-RFID slots fall back to a deterministic per-slot tag
        (hash(serial) + ams_id + tray_id). The Link / Assign routes wrote
        that tag to Spoolman extra.tag but never cleared it from the
        previous holder on re-binding.

    (2) The frontend's hover-card resolver preferred the (stale) tag-link
        over the user's explicit slot-assignment. Same precedence bug in
        SpoolBuddy's fill-bar resolver and slot-action picker.

  Frontend: swap precedence at 5 sites — slot-assignment outranks tag-link
  everywhere. FilamentHoverCard's existing match-dedupe then collapses the
  two "Open in Inventory" buttons back into one.

  Backend: new _clear_stale_tag_links() in spoolman_inventory.py, called
  from POST /spoolman/inventory/slot-assignments (with the slot's
  deterministic fallback tag) and POST /spoolman/spools/{id}/link (with
  the literal tag being bound — works for RFID and fallback). Best-effort:
  Spoolman 5xx and per-spool patch failures log + continue, never wedge
  the bind. get_fallback_spool_tag_for_slot promoted to a public helper
  mirroring the frontend's signature exactly.
2026-05-20 11:00:54 +02:00

148 lines
4.7 KiB
Python

"""T-Gap 3: Concurrency test for POST /slot-assignments upsert+cleanup race."""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select
SAMPLE_SPOOL = {
"id": 10,
"filament": {
"id": 1,
"name": "PLA Basic",
"material": "PLA",
"color_hex": "FF0000",
"weight": 1000,
"vendor": {"id": 1, "name": "Test Brand"},
},
"remaining_weight": 800.0,
"used_weight": 200.0,
"location": None,
"comment": None,
"first_used": None,
"last_used": None,
"registered": "2024-01-01T00:00:00+00:00",
"archived": False,
"price": None,
"extra": {},
}
@pytest.fixture
async def slot_settings(db_session):
from backend.app.models.settings import Settings
db_session.add(Settings(key="spoolman_enabled", value="true"))
db_session.add(Settings(key="spoolman_url", value="http://localhost:7912"))
await db_session.commit()
@pytest.fixture
async def test_printer(db_session):
from backend.app.models.printer import Printer
printer = Printer(
name="Concurrency Test Printer",
serial_number="CONCTEST001",
ip_address="192.168.1.99",
access_code="12345678",
)
db_session.add(printer)
await db_session.commit()
await db_session.refresh(printer)
return printer
@pytest.fixture
def mock_client():
client = MagicMock()
client.base_url = "http://localhost:7912"
client.health_check = AsyncMock(return_value=True)
client.get_spool = AsyncMock(return_value=SAMPLE_SPOOL)
# #1457: assign route enumerates spools to clear stale fallback-tag links.
client.get_spools = AsyncMock(return_value=[])
client.merge_spool_extra = AsyncMock(return_value={"id": 0, "extra": {}})
with patch(
"backend.app.api.routes.spoolman_inventory._get_client",
AsyncMock(return_value=client),
):
yield client
class TestSlotAssignmentConcurrency:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_concurrent_assign_same_slot_idempotent(
self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
):
"""Concurrent POST requests for the same slot must not produce duplicate rows."""
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
payload = {
"spoolman_spool_id": 10,
"printer_id": test_printer.id,
"ams_id": 0,
"tray_id": 0,
}
async def assign():
return await async_client.post(
"/api/v1/spoolman/inventory/slot-assignments",
json=payload,
)
responses = await asyncio.gather(assign(), assign(), assign())
for resp in responses:
assert resp.status_code == 200
# Exactly one row for this (printer, ams, tray) combination
result = await db_session.execute(
select(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.printer_id == test_printer.id,
SpoolmanSlotAssignment.ams_id == 0,
SpoolmanSlotAssignment.tray_id == 0,
)
)
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].spoolman_spool_id == 10
@pytest.mark.asyncio
@pytest.mark.integration
async def test_reassign_slot_updates_spool_id(
self, async_client: AsyncClient, slot_settings, test_printer, mock_client, db_session
):
"""Re-assigning a slot to a different spool updates the existing row."""
from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
base = {"printer_id": test_printer.id, "ams_id": 1, "tray_id": 2}
resp1 = await async_client.post(
"/api/v1/spoolman/inventory/slot-assignments",
json={**base, "spoolman_spool_id": 10},
)
assert resp1.status_code == 200
# Re-assign same slot to a different spool
mock_client.get_spool.return_value = {**SAMPLE_SPOOL, "id": 20}
resp2 = await async_client.post(
"/api/v1/spoolman/inventory/slot-assignments",
json={**base, "spoolman_spool_id": 20},
)
assert resp2.status_code == 200
# Only one row; spool_id updated to 20
result = await db_session.execute(
select(SpoolmanSlotAssignment).where(
SpoolmanSlotAssignment.printer_id == test_printer.id,
SpoolmanSlotAssignment.ams_id == 1,
SpoolmanSlotAssignment.tray_id == 2,
)
)
rows = result.scalars().all()
assert len(rows) == 1
assert rows[0].spoolman_spool_id == 20