Files
bambuddy/backend/tests/integration/test_spoolman_inventory_bulk.py
maziggy fb3821630f feat(inventory): batch / mass edit on the Filament tab (#1795)
Bulk operations on the Inventory page in both built-in and Spoolman modes.
  Reporter wanted ten-of-the-same-spool edits without ten round-trips through
  the per-spool editor.

  Frontend
  - New checkbox column on the inventory table (header / row / group). Sticky
    toolbar appears when at least one row is selected with Edit / Print labels /
    Reset usage / Archive (or Restore in the Archived tab) / Delete / Clear.
    Selection clears on any filter / tab / search change so the count can't
    drift from what is on screen.
  - BulkEditSpoolsModal is a three-state-per-field form. The user opts in per
    field by ticking its checkbox or just typing into it; only ticked + non-
    empty fields are sent. Clearing fields in bulk is intentionally NOT
    supported per the issue discussion.
  - A new SearchableSelect renders all categorical fields (material, sub-type,
    brand, category, slicer preset name, slicer filament, storage location)
    with the same dropdown pattern the per-spool editor uses - text input +
    chevron + filtered button list, click-outside / Escape closes. No native
    select anywhere in the modal. Options merge the canonical constants from
    spool-form/constants.ts with whatever already exists in the user's
    inventory. Slicer-preset dropdowns fetch the same sources as the per-spool
    form (Bambu Cloud + Orca Cloud + local + built-in) through buildFilament
    Options() and three useQuery calls gated on isOpen.
  - onSuccess handlers surface three outcomes: all-succeeded (green toast),
    partial-success (yellow toast with ok / failed counts), all-failed (red
    toast that keeps the selection and modal open so the user can retry).
    The first cut silently dropped errors / not_found arrays - audited and
    fixed before merge.
  - Invalid rgba hex is flagged inline with a red border + helper text and
    the Apply button is gated on a hasDroppedTickedField guard, so silently
    dropping a ticked field is no longer possible.
  - bulkResetConsumedCounterMutation.onSuccess now closes the confirm modal +
    clears selection, matching the other three bulk mutations.

  Backend
  - Four new endpoints per inventory mode (eight total):
      POST /api/v1/inventory/spools/bulk-update         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-delete         INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-archive        INVENTORY_UPDATE
      POST /api/v1/inventory/spools/bulk-restore        INVENTORY_UPDATE
      POST /api/v1/spoolman/inventory/spools/bulk-*     FILAMENTS_UPDATE
  - Built-in update runs the same prepare_internal_spool_payload(...) +
    weight_used / weight_locked auto-stamp as the per-spool PATCH.
  - Spoolman update loops the per-spool update_spool route function so the
    filament re-linking / extra-dict / extra-lock / shared-filament rules
    stay byte-identical to single-spool edits.
  - Per-spool failures inside the batch are collected. Spoolman bulk-delete /
    archive / restore now catch non-HTTPException too (matches bulk-update) -
    a mid-batch httpx.ConnectError or TimeoutError no longer aborts the route
    with a 500 and skips the WS broadcast.
  - Both modes broadcast a single inventory_changed WS event at the end of
    the batch.
2026-06-23 11:34:15 +02:00

201 lines
6.9 KiB
Python

"""Bulk Spoolman inventory endpoint coverage for the batch-edit feature (#1795).
Endpoints under test:
- POST /api/v1/spoolman/inventory/spools/bulk-update
- POST /api/v1/spoolman/inventory/spools/bulk-delete
- POST /api/v1/spoolman/inventory/spools/bulk-archive
- POST /api/v1/spoolman/inventory/spools/bulk-restore
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import HTTPException
from httpx import AsyncClient
SAMPLE_SPOOLMAN_SPOOL = {
"id": 42,
"filament": {
"id": 7,
"name": "PLA Basic",
"material": "PLA",
"color_hex": "FF0000",
"weight": 1000,
"vendor": {"id": 3, "name": "Bambu Lab"},
},
"remaining_weight": 750.0,
"used_weight": 250.0,
"location": "Printer1 - AMS A1",
"comment": "test note",
"first_used": "2024-01-01T00:00:00+00:00",
"last_used": "2024-02-01T00:00:00+00:00",
"registered": "2024-01-01T00:00:00+00:00",
"archived": False,
"price": None,
"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()
@pytest.fixture
def mock_spoolman_client():
mock = MagicMock()
mock.base_url = "http://localhost:7912"
mock.health_check = AsyncMock(return_value=True)
mock.get_spool = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
mock.delete_spool = AsyncMock(return_value=True)
mock.set_spool_archived = AsyncMock(
side_effect=lambda spool_id, archived: {**SAMPLE_SPOOLMAN_SPOOL, "archived": archived}
)
mock.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
mock.merge_spool_extra = AsyncMock(return_value=SAMPLE_SPOOLMAN_SPOOL)
mock.is_filament_shared = AsyncMock(return_value=False)
mock.patch_filament = AsyncMock(return_value={"id": 7})
mock.find_or_create_filament = AsyncMock(return_value=7)
mock.find_or_create_vendor = AsyncMock(return_value=3)
mock.ensure_extra_field = AsyncMock(return_value=True)
mock.get_distinct_locations = AsyncMock(return_value=[])
class _Lock:
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return False
mock.extra_lock = lambda spool_id: _Lock()
with (
patch(
"backend.app.api.routes.spoolman_inventory.get_spoolman_client",
AsyncMock(return_value=mock),
),
patch(
"backend.app.api.routes.spoolman_inventory.init_spoolman_client",
AsyncMock(return_value=mock),
),
):
yield mock
class TestSpoolmanBulkUpdate:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_calls_per_spool_update_for_each_id(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk-update",
json={"ids": [42, 43, 44], "update": {"note": "From bulk edit"}},
)
assert resp.status_code == 200
body = resp.json()
assert body["updated"] == 3
assert body["errors"] == []
# update_spool route loops through each, which calls update_spool_full once per ID
assert mock_spoolman_client.update_spool_full.await_count == 3
@pytest.mark.asyncio
@pytest.mark.integration
async def test_collects_per_spool_errors_without_aborting_batch(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
# First two succeed, third raises
mock_spoolman_client.update_spool_full.side_effect = [
SAMPLE_SPOOLMAN_SPOOL,
SAMPLE_SPOOLMAN_SPOOL,
HTTPException(status_code=404, detail="Spool 999 not found"),
]
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk-update",
json={"ids": [42, 43, 999], "update": {"note": "Batched"}},
)
assert resp.status_code == 200
body = resp.json()
assert body["updated"] == 2
assert len(body["errors"]) == 1
assert body["errors"][0]["id"] == 999
assert body["errors"][0]["status"] == 404
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_update_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk-update",
json={"ids": [42], "update": {}},
)
assert resp.status_code == 400
@pytest.mark.asyncio
@pytest.mark.integration
async def test_empty_ids_rejected(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk-update",
json={"ids": [], "update": {"note": "X"}},
)
assert resp.status_code == 422
class TestSpoolmanBulkDelete:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_deletes_listed_spools(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk-delete",
json={"ids": [42, 43, 44]},
)
assert resp.status_code == 200
body = resp.json()
assert body["deleted"] == 3
assert body["errors"] == []
assert mock_spoolman_client.delete_spool.await_count == 3
class TestSpoolmanBulkArchiveRestore:
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_archive_calls_per_spool(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk-archive",
json={"ids": [42, 43]},
)
assert resp.status_code == 200
body = resp.json()
assert body["archived"] == 2
# set_spool_archived(spool_id, archived=True) called for each id
assert mock_spoolman_client.set_spool_archived.await_count == 2
for call in mock_spoolman_client.set_spool_archived.call_args_list:
assert call.kwargs.get("archived") is True
@pytest.mark.asyncio
@pytest.mark.integration
async def test_bulk_restore_calls_per_spool(
self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
):
resp = await async_client.post(
"/api/v1/spoolman/inventory/spools/bulk-restore",
json={"ids": [42, 43]},
)
assert resp.status_code == 200
body = resp.json()
assert body["restored"] == 2
assert mock_spoolman_client.set_spool_archived.await_count == 2
for call in mock_spoolman_client.set_spool_archived.call_args_list:
assert call.kwargs.get("archived") is False