Files
bambuddy/backend/tests/integration/test_spoolman_filament_patch.py
T
maziggy c145b3ebc1 Use the vendor's empty spool weight as tare in Spoolman mode (#3195)
Spoolman resolves a spool's tare from the spool, then the filament, then
the vendor's empty_spool_weight. Bambuddy skipped the vendor and fell
back to 250 g, so weighing a spool whose tare was set only on its vendor
gave the wrong remaining weight. The inventory weigh action, the
SpoolBuddy scale and the displayed core weight now share one lookup, and
"keep old weight" on a filament change stamps a vendor-inherited tare.
2026-09-30 08:43:21 +02:00

638 lines
29 KiB
Python

"""Integration tests for PATCH /spoolman/inventory/filaments/{filament_id}.
Covers:
- Option A (keep_existing_spools=True): stamps old filament weight onto spools that currently inherit
- Option B (keep_existing_spools=False): clears per-spool overrides in Spoolman so all inherit new value
- Name-only patch: no get_all_spools call
- Edge cases: disabled Spoolman, not found, invalid inputs
- Spool-level tare priority in sync_spool_weight (spoolman_inventory) and update_spool_weight (spoolbuddy)
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
SAMPLE_FILAMENT = {
"id": 7,
"name": "PLA Basic",
"material": "PLA",
"color_hex": "FF0000",
"color_name": "Red",
"weight": 1000,
"spool_weight": 250.0,
"vendor": {"id": 3, "name": "Bambu Lab"},
}
SAMPLE_SPOOL_WITH_FILAMENT_7 = {
"id": 42,
"spool_weight": None, # inheriting from filament
"filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
"remaining_weight": 750.0,
"used_weight": 250.0,
"location": None,
"comment": None,
"archived": False,
"extra": {},
}
SAMPLE_SPOOL_WITH_FILAMENT_99 = {
"id": 55,
"spool_weight": 196.0, # has its own spool-level override
"filament": {"id": 99, "name": "PETG HF", "material": "PETG", "spool_weight": 196.0, "weight": 1000},
"remaining_weight": 500.0,
"used_weight": 500.0,
"location": None,
"comment": None,
"archived": False,
"extra": {},
}
SPOOL_WITH_NULL_FILAMENT = {
"id": 77,
"spool_weight": None,
"filament": None,
"remaining_weight": 100.0,
"used_weight": 900.0,
"location": None,
"comment": None,
"archived": False,
"extra": {},
}
SAMPLE_SPOOL_7_WITH_OVERRIDE = {
"id": 43,
"spool_weight": 300.0, # has its own spool-level override
"filament": {"id": 7, "name": "PLA Basic", "material": "PLA", "spool_weight": 250.0, "weight": 1000},
"remaining_weight": 700.0,
"used_weight": 300.0,
"location": None,
"comment": None,
"archived": False,
"extra": {},
}
@pytest.fixture
async def spoolman_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()
def make_mock_client(filament=None, all_spools=None, patched_filament=None):
mock_client = MagicMock()
mock_client.base_url = "http://localhost:7912"
mock_client.get_filament = AsyncMock(return_value=filament or SAMPLE_FILAMENT)
mock_client.patch_filament = AsyncMock(return_value=patched_filament or SAMPLE_FILAMENT)
mock_client.get_all_spools = AsyncMock(
return_value=all_spools if all_spools is not None else [SAMPLE_SPOOL_WITH_FILAMENT_7]
)
mock_client.update_spool_full = AsyncMock(return_value={})
return mock_client
# ---------------------------------------------------------------------------
# PATCH /filaments/{id} — core scenarios
# ---------------------------------------------------------------------------
class TestPatchFilamentOptionB:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_b_stamps_new_weight_on_all_affected_spools(
self, async_client: AsyncClient, spoolman_settings
):
"""Option B: ALL affected spools (inheriting and overridden alike) get the new weight stamped."""
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": False},
)
assert response.status_code == 200
mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 196.0})
assert mock_client.update_spool_full.call_count == 2
calls = {c.kwargs["spool_id"]: c.kwargs["spool_weight"] for c in mock_client.update_spool_full.call_args_list}
assert calls[42] == pytest.approx(196.0)
assert calls[43] == pytest.approx(196.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_b_stamps_inheriting_spool_with_new_weight(self, async_client: AsyncClient, spoolman_settings):
"""Option B: a spool inheriting (spool_weight=None) gets the new weight explicitly stamped."""
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": False},
)
assert response.status_code == 200
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_b_only_stamps_affected_filament_spools(self, async_client: AsyncClient, spoolman_settings):
"""Option B for filament 7 must not touch spools belonging to other filament types."""
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_WITH_FILAMENT_99])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": False},
)
assert response.status_code == 200
# Only spool 42 (filament 7) should be stamped; spool 55 (filament 99) must not be touched
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(196.0))
class TestPatchFilamentOptionA:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_stamps_old_weight_on_inheriting_spools(self, async_client: AsyncClient, spoolman_settings):
"""Option A: spools inheriting from filament (spool_weight=None) get old weight stamped on them."""
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": True},
)
assert response.status_code == 200
# old_weight = SAMPLE_FILAMENT["spool_weight"] = 250.0
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_does_not_patch_spools_with_existing_override(
self, async_client: AsyncClient, spoolman_settings
):
"""Option A: spools already having their own spool_weight are left unchanged."""
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_7_WITH_OVERRIDE])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": True},
)
assert response.status_code == 200
mock_client.update_spool_full.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_mixed_spools_stamps_only_inheriting(self, async_client: AsyncClient, spoolman_settings):
"""Option A: only inheriting spools (spool_weight=None) get old weight; overridden spools are skipped."""
mock_client = make_mock_client(all_spools=[SAMPLE_SPOOL_WITH_FILAMENT_7, SAMPLE_SPOOL_7_WITH_OVERRIDE])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": True},
)
assert response.status_code == 200
# Only spool 42 (inheriting) should be stamped; spool 43 (has override) must not be touched
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_zero_spools_no_error(self, async_client: AsyncClient, spoolman_settings):
"""Option A with zero spools for this filament: no error, no Spoolman calls."""
mock_client = make_mock_client(all_spools=[])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": True},
)
assert response.status_code == 200
mock_client.update_spool_full.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_filament_no_old_weight_skips_stamping(self, async_client: AsyncClient, spoolman_settings):
"""Option A: if the filament has no old spool_weight, no stamping occurs."""
mock_client = make_mock_client(filament={**SAMPLE_FILAMENT, "spool_weight": None})
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": True},
)
assert response.status_code == 200
mock_client.update_spool_full.assert_not_called()
class TestPatchFilamentNameOnly:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_name_only_patch_no_get_all_spools(self, async_client: AsyncClient, db_session, spoolman_settings):
"""Patching name only must not call get_all_spools."""
mock_client = make_mock_client()
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"name": "PLA Basic Renamed"},
)
assert response.status_code == 200
mock_client.patch_filament.assert_called_once_with(7, {"name": "PLA Basic Renamed"})
mock_client.get_all_spools.assert_not_called()
class TestPatchFilamentEdgeCases:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_null_filament_on_spool_skipped(self, async_client: AsyncClient, db_session, spoolman_settings):
"""Spools with filament=null are skipped without error."""
mock_client = make_mock_client(all_spools=[SPOOL_WITH_NULL_FILAMENT, SAMPLE_SPOOL_WITH_FILAMENT_7])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0},
)
assert response.status_code == 200
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spool_weight_zero_is_valid(self, async_client: AsyncClient, db_session, spoolman_settings):
"""spool_weight=0 is valid (0g tare weight is legitimate)."""
mock_client = make_mock_client()
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 0},
)
assert response.status_code == 200
mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": 0})
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spool_weight_null_removes_weight(self, async_client: AsyncClient, db_session, spoolman_settings):
"""spool_weight=null is forwarded to Spoolman as None."""
mock_client = make_mock_client()
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": None},
)
assert response.status_code == 200
mock_client.patch_filament.assert_called_once_with(7, {"spool_weight": None})
class TestPatchFilamentErrors:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_disabled_returns_400(self, async_client: AsyncClient, db_session):
"""When Spoolman is disabled, PATCH /filaments/{id} returns 400."""
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0},
)
assert response.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_not_found_returns_404(self, async_client: AsyncClient, db_session, spoolman_settings):
"""When get_filament raises SpoolmanNotFoundError, endpoint returns 404."""
from backend.app.services.spoolman import SpoolmanNotFoundError
mock_client = make_mock_client()
mock_client.get_filament = AsyncMock(side_effect=SpoolmanNotFoundError("not found"))
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0},
)
assert response.status_code == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_invalid_id_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
"""filament_id=0 fails Path validation (gt=0) with 422."""
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/0",
json={"spool_weight": 196.0},
)
assert response.status_code == 422
@pytest.mark.asyncio
@pytest.mark.integration
async def test_negative_spool_weight_returns_422(self, async_client: AsyncClient, db_session, spoolman_settings):
"""spool_weight=-1 fails Pydantic validation (ge=0.0) with 422."""
mock_client = make_mock_client()
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": -1},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Spool-level tare priority in sync_spool_weight (spoolman_inventory)
# ---------------------------------------------------------------------------
class TestSyncSpoolWeightPriority:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
"""sync_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
mock_client = make_mock_client()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/spools/42/weight",
json={"weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 100 (spool-level tare) = 500
update_call = mock_client.update_spool_full.call_args
assert update_call.kwargs["remaining_weight"] == pytest.approx(500.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
"""sync_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7} # spool_weight=None → filament fallback 250.0
mock_client = make_mock_client()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/spools/42/weight",
json={"weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 250 (filament.spool_weight) = 350
update_call = mock_client.update_spool_full.call_args
assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
"""spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
mock_client = make_mock_client()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/spools/42/weight",
json={"weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 0 = 600 (not 600 - 250 fallback)
update_call = mock_client.update_spool_full.call_args
assert update_call.kwargs["remaining_weight"] == pytest.approx(600.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_both_levels_none_uses_250g_fallback(self, async_client: AsyncClient, spoolman_settings):
"""When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used."""
spool_data = {
**SAMPLE_SPOOL_WITH_FILAMENT_7,
"spool_weight": None,
"filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
}
mock_client = make_mock_client()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/spools/42/weight",
json={"weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 250 (fallback) = 350
update_call = mock_client.update_spool_full.call_args
assert update_call.kwargs["remaining_weight"] == pytest.approx(350.0)
# ---------------------------------------------------------------------------
# Spool-level tare priority in update_spool_weight (spoolbuddy.py scale endpoint)
# ---------------------------------------------------------------------------
class TestUpdateSpoolWeightPriority:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spool_level_spool_weight_takes_priority(self, async_client: AsyncClient, spoolman_settings):
"""update_spool_weight uses spool.spool_weight over filament.spool_weight for tare."""
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 100.0}
mock_client = MagicMock()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool = AsyncMock(return_value=None)
with patch(
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
AsyncMock(return_value=mock_client),
):
response = await async_client.post(
"/api/v1/spoolbuddy/scale/update-spool-weight",
json={"spool_id": 42, "weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 100 (spool-level tare) = 500
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(500.0))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_filament_spool_weight_used_as_fallback(self, async_client: AsyncClient, spoolman_settings):
"""update_spool_weight falls back to filament.spool_weight when spool.spool_weight is None."""
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7} # spool_weight=None → filament fallback 250.0
mock_client = MagicMock()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool = AsyncMock(return_value=None)
with patch(
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
AsyncMock(return_value=mock_client),
):
response = await async_client.post(
"/api/v1/spoolbuddy/scale/update-spool-weight",
json={"spool_id": 42, "weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 250 (filament.spool_weight) = 350
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spool_level_zero_not_treated_as_missing(self, async_client: AsyncClient, spoolman_settings):
"""spool.spool_weight=0 is a valid 0g tare, not treated as missing."""
spool_data = {**SAMPLE_SPOOL_WITH_FILAMENT_7, "spool_weight": 0}
mock_client = MagicMock()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool = AsyncMock(return_value=None)
with patch(
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
AsyncMock(return_value=mock_client),
):
response = await async_client.post(
"/api/v1/spoolbuddy/scale/update-spool-weight",
json={"spool_id": 42, "weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 0 = 600 (not 600 - 250 fallback)
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(600.0))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_both_levels_none_uses_250g_fallback_and_warns(self, async_client: AsyncClient, spoolman_settings):
"""When both spool.spool_weight and filament.spool_weight are None, 250g fallback is used with a warning."""
spool_data = {
**SAMPLE_SPOOL_WITH_FILAMENT_7,
"spool_weight": None,
"filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None},
}
mock_client = MagicMock()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool = AsyncMock(return_value=None)
with patch(
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
AsyncMock(return_value=mock_client),
):
response = await async_client.post(
"/api/v1/spoolbuddy/scale/update-spool-weight",
json={"spool_id": 42, "weight_grams": 600.0},
)
assert response.status_code == 200
# remaining = 600 - 250 (fallback) = 350
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(350.0))
assert response.json().get("warnings")
# ---------------------------------------------------------------------------
# Vendor-level tare (#3195): spool -> filament -> vendor.empty_spool_weight -> 250 g
# ---------------------------------------------------------------------------
# The reporter's numbers: Sunlu's empty_spool_weight is 211.7 g, the scale reads
# 1177 g, and Spoolman's own /measure gives 965.3 g remaining.
SUNLU_VENDOR = {"id": 5, "name": "Sunlu", "empty_spool_weight": 211.7}
SPOOL_WITH_VENDOR_TARE_ONLY = {
**SAMPLE_SPOOL_WITH_FILAMENT_7,
"spool_weight": None,
"filament": {**SAMPLE_SPOOL_WITH_FILAMENT_7["filament"], "spool_weight": None, "vendor": SUNLU_VENDOR},
}
class TestVendorTare:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_sync_weight_uses_vendor_empty_spool_weight(self, async_client: AsyncClient, spoolman_settings):
mock_client = make_mock_client()
mock_client.get_spool = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
mock_client.update_spool_full = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/spools/42/weight",
json={"weight_grams": 1177.0},
)
assert response.status_code == 200
assert mock_client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(965.3)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_sync_weight_filament_tare_still_beats_vendor(self, async_client: AsyncClient, spoolman_settings):
spool_data = {
**SPOOL_WITH_VENDOR_TARE_ONLY,
"filament": {**SPOOL_WITH_VENDOR_TARE_ONLY["filament"], "spool_weight": 180.0},
}
mock_client = make_mock_client()
mock_client.get_spool = AsyncMock(return_value=spool_data)
mock_client.update_spool_full = AsyncMock(return_value=spool_data)
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/spools/42/weight",
json={"weight_grams": 1177.0},
)
assert response.status_code == 200
assert mock_client.update_spool_full.call_args.kwargs["remaining_weight"] == pytest.approx(997.0)
@pytest.mark.asyncio
@pytest.mark.integration
async def test_spoolbuddy_scale_uses_vendor_tare_without_warning(
self, async_client: AsyncClient, spoolman_settings
):
mock_client = MagicMock()
mock_client.get_spool = AsyncMock(return_value=SPOOL_WITH_VENDOR_TARE_ONLY)
mock_client.update_spool = AsyncMock(return_value=None)
with patch(
"backend.app.api.routes.spoolbuddy._get_spoolman_client_or_none",
AsyncMock(return_value=mock_client),
):
response = await async_client.post(
"/api/v1/spoolbuddy/scale/update-spool-weight",
json={"spool_id": 42, "weight_grams": 1177.0},
)
assert response.status_code == 200
mock_client.update_spool.assert_called_once_with(spool_id=42, remaining_weight=pytest.approx(965.3))
# The vendor tare is a real value, so the 250 g fallback warning must not fire.
assert not response.json().get("warnings")
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_stamps_vendor_tare_when_filament_had_none(
self, async_client: AsyncClient, spoolman_settings
):
"""Inheriting spools weighed against the vendor's tare before the filament
got its own, so keeping them unchanged means stamping the vendor's value."""
filament = {**SAMPLE_FILAMENT, "spool_weight": None, "vendor": SUNLU_VENDOR}
mock_client = make_mock_client(filament=filament, all_spools=[SPOOL_WITH_VENDOR_TARE_ONLY])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": True},
)
assert response.status_code == 200
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(211.7))
@pytest.mark.asyncio
@pytest.mark.integration
async def test_option_a_filament_tare_beats_vendor_tare(self, async_client: AsyncClient, spoolman_settings):
filament = {**SAMPLE_FILAMENT, "spool_weight": 250.0, "vendor": SUNLU_VENDOR}
mock_client = make_mock_client(filament=filament, all_spools=[SPOOL_WITH_VENDOR_TARE_ONLY])
with patch("backend.app.api.routes.spoolman_inventory._get_client", AsyncMock(return_value=mock_client)):
response = await async_client.patch(
"/api/v1/spoolman/inventory/filaments/7",
json={"spool_weight": 196.0, "keep_existing_spools": True},
)
assert response.status_code == 200
mock_client.update_spool_full.assert_called_once_with(spool_id=42, spool_weight=pytest.approx(250.0))