mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-10-18 11:56:01 +02:00
fix(vp): close #1780 race — bump slicer-MQTT wait to 5s + retroactive stamp
Round 2 (166e9f9e) fixed the stash-key mismatch, but @mkoreen's
2026-06-23 bundle showed BS's MQTT project_file arrived 85 ms past the
2.0 s wait timeout (FTP done 00:42:02.509, "No slicer options cached"
00:42:04.509, MQTT 00:42:04.594). Queue item was committed with
settings defaults; nozzle_mapping never made it onto the wire.
Three pieces:
1. _SLICER_OPTIONS_WAIT_TIMEOUT module constant, 2.0 -> 5.0 s. Covers
wireless / loaded-Pi jitter; one-time +3 s cost only for legacy
slicers that never send MQTT.
2. _RECENT_QUEUE_ITEM_TTL fallback: on_print_command retroactively
UPDATEs slicer-driven fields on a recently-committed queue item
when the event wait already gave up. Tracked via
_recent_queue_items dict (30 s TTL, evicted on every queue-add).
Gated on status='pending' so we never race the dispatcher.
Multi-plate covered via WHERE id IN (...).
3. Post-commit last-chance pop. Audit caught a race in (2): MQTT could
arrive during any await inside _add_to_print_queue (wait_for,
archive_print, db.flush, db.commit), and on_print_command would
stash data with no event consumer AND no _recent_queue_items entry
yet. After populating _recent_queue_items, _add_to_print_queue now
pops _slicer_print_options[file_path.name] one last time and
routes any hit through _restamp inline.
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""Bulk inventory endpoint coverage for the batch-edit feature (#1795).
|
||||
|
||||
Endpoints under test:
|
||||
- POST /api/v1/inventory/spools/bulk-update
|
||||
- POST /api/v1/inventory/spools/bulk-delete
|
||||
- POST /api/v1/inventory/spools/bulk-archive
|
||||
- POST /api/v1/inventory/spools/bulk-restore
|
||||
|
||||
The Spoolman-mode equivalents live in test_spoolman_inventory_api.py.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from backend.app.models.spool import Spool
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def spool_factory(db_session: AsyncSession):
|
||||
async def _create(**kwargs):
|
||||
defaults = {
|
||||
"material": "PLA",
|
||||
"subtype": "Basic",
|
||||
"brand": "Bambu",
|
||||
"color_name": "Red",
|
||||
"rgba": "FF0000FF",
|
||||
"label_weight": 1000,
|
||||
"core_weight": 250,
|
||||
"weight_used": 0,
|
||||
"weight_used_baseline": 0,
|
||||
"weight_locked": False,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
spool = Spool(**defaults)
|
||||
db_session.add(spool)
|
||||
await db_session.commit()
|
||||
await db_session.refresh(spool)
|
||||
return spool
|
||||
|
||||
return _create
|
||||
|
||||
|
||||
class TestBulkUpdate:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_applies_patch_to_all_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory(brand="Bambu", note=None)
|
||||
b = await spool_factory(brand="Bambu", note=None)
|
||||
c = await spool_factory(brand="Bambu", note=None)
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [a.id, b.id, c.id], "update": {"brand": "Sunlu", "note": "From bulk edit"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["updated"] == 3
|
||||
assert body["not_found"] == []
|
||||
|
||||
for spool in (a, b, c):
|
||||
await db_session.refresh(spool)
|
||||
assert spool.brand == "Sunlu"
|
||||
assert spool.note == "From bulk edit"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reports_unknown_ids_in_not_found(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
real = await spool_factory(brand="Bambu")
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [real.id, 999_999], "update": {"brand": "Sunlu"}},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["updated"] == 1
|
||||
assert body["not_found"] == [999_999]
|
||||
|
||||
await db_session.refresh(real)
|
||||
assert real.brand == "Sunlu"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_update_rejected(self, async_client: AsyncClient, spool_factory):
|
||||
a = await spool_factory()
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [a.id], "update": {}},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_setting_weight_used_auto_locks(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory(weight_locked=False, weight_used=0.0)
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [a.id], "update": {"weight_used": 250.5}},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
await db_session.refresh(a)
|
||||
assert a.weight_used == 250.5
|
||||
assert a.weight_locked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_empty_ids_list_rejected(self, async_client: AsyncClient):
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-update",
|
||||
json={"ids": [], "update": {"brand": "X"}},
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
class TestBulkDelete:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_deletes_listed_spools(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory()
|
||||
b = await spool_factory()
|
||||
kept = await spool_factory()
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-delete",
|
||||
json={"ids": [a.id, b.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] == 2
|
||||
assert body["not_found"] == []
|
||||
|
||||
remaining = (await db_session.execute(select(Spool.id))).scalars().all()
|
||||
assert kept.id in remaining
|
||||
assert a.id not in remaining
|
||||
assert b.id not in remaining
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_reports_unknown_ids(self, async_client: AsyncClient, spool_factory):
|
||||
a = await spool_factory()
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-delete",
|
||||
json={"ids": [a.id, 999_999]},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["deleted"] == 1
|
||||
assert body["not_found"] == [999_999]
|
||||
|
||||
|
||||
class TestBulkArchiveRestore:
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_archive_sets_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
a = await spool_factory()
|
||||
b = await spool_factory()
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-archive",
|
||||
json={"ids": [a.id, b.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["archived"] == 2
|
||||
assert body["already_archived"] == []
|
||||
assert body["not_found"] == []
|
||||
|
||||
for s in (a, b):
|
||||
await db_session.refresh(s)
|
||||
assert s.archived_at is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_archive_skips_already_archived(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
active = await spool_factory()
|
||||
already = await spool_factory(archived_at=datetime.now(timezone.utc))
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-archive",
|
||||
json={"ids": [active.id, already.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["archived"] == 1
|
||||
assert body["already_archived"] == [already.id]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_bulk_restore_clears_archived_at(self, async_client: AsyncClient, spool_factory, db_session):
|
||||
archived = await spool_factory(archived_at=datetime.now(timezone.utc))
|
||||
active = await spool_factory(archived_at=None)
|
||||
|
||||
resp = await async_client.post(
|
||||
"/api/v1/inventory/spools/bulk-restore",
|
||||
json={"ids": [archived.id, active.id]},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["restored"] == 1
|
||||
assert body["already_active"] == [active.id]
|
||||
|
||||
await db_session.refresh(archived)
|
||||
assert archived.archived_at is None
|
||||
@@ -0,0 +1,200 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user