"""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")