| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365 |
- """Integration tests for POST /api/v1/spoolman/inventory/sync-ams-weights.
- Covers:
- - happy path: synced count incremented, update_spool_full called with correct weight
- - printer offline: assignment skipped
- - spool missing from Spoolman: assignment skipped
- - invalid remain value: assignment skipped
- """
- from unittest.mock import AsyncMock, MagicMock, patch
- import pytest
- from httpx import AsyncClient
- SAMPLE_SPOOL = {
- "id": 42,
- "filament": {
- "id": 1,
- "name": "PLA Basic",
- "material": "PLA",
- "weight": 1000,
- "color_hex": "FF0000",
- "vendor": {"id": 1, "name": "BrandX"},
- },
- "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 sync_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="Sync Printer",
- serial_number="SYNCTEST001",
- ip_address="192.168.1.50",
- access_code="12345678",
- )
- db_session.add(printer)
- await db_session.commit()
- await db_session.refresh(printer)
- return printer
- @pytest.fixture
- async def slot_assignment(db_session, test_printer):
- from backend.app.models.spoolman_slot_assignment import SpoolmanSlotAssignment
- assignment = SpoolmanSlotAssignment(
- printer_id=test_printer.id,
- ams_id=0,
- tray_id=0,
- spoolman_spool_id=42,
- )
- db_session.add(assignment)
- await db_session.commit()
- return assignment
- def _make_spoolman_client(spools=None):
- client = MagicMock()
- client.base_url = "http://localhost:7912"
- client.health_check = AsyncMock(return_value=True)
- client.get_all_spools = AsyncMock(return_value=[SAMPLE_SPOOL] if spools is None else spools)
- client.update_spool_full = AsyncMock(return_value=SAMPLE_SPOOL)
- return client
- def _make_printer_state(remain=75):
- state = MagicMock()
- state.raw_data = {
- "ams": [
- {
- "id": 0,
- "tray": [{"id": 0, "remain": remain}],
- }
- ]
- }
- return state
- class TestSyncSpoolmanAmsWeights:
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_happy_path_synced_count(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """POST /sync-ams-weights syncs one spool, returns synced=1, skipped=0."""
- spoolman_client = _make_spoolman_client()
- printer_state = _make_printer_state(remain=75)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert response.status_code == 200
- body = response.json()
- assert body["synced"] == 1
- assert body["skipped"] == 0
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_weight_calculated_correctly(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """Remaining weight = round(label_weight * remain / 100, 1)."""
- spoolman_client = _make_spoolman_client()
- printer_state = _make_printer_state(remain=75)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- spoolman_client.update_spool_full.assert_called_once_with(42, remaining_weight=750.0)
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_printer_offline_skipped(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """Spools whose printer is offline are counted as skipped, not synced."""
- spoolman_client = _make_spoolman_client()
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=None)
- response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert response.status_code == 200
- body = response.json()
- assert body["synced"] == 0
- assert body["skipped"] == 1
- spoolman_client.update_spool_full.assert_not_called()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_update_spool_full_error_counted_as_skipped(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """update_spool_full raising HTTPException counts as skipped, not synced."""
- from fastapi import HTTPException
- spoolman_client = _make_spoolman_client()
- spoolman_client.update_spool_full = AsyncMock(side_effect=HTTPException(status_code=503))
- printer_state = _make_printer_state(remain=50)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert response.status_code == 200
- body = response.json()
- assert body["synced"] == 0
- assert body["skipped"] == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_invalid_remain_value_skipped(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """Non-numeric remain value in AMS data is counted as skipped."""
- spoolman_client = _make_spoolman_client()
- printer_state = _make_printer_state(remain="notanumber")
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert response.status_code == 200
- body = response.json()
- assert body["synced"] == 0
- assert body["skipped"] == 1
- spoolman_client.update_spool_full.assert_not_called()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_spool_missing_from_spoolman_skipped(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """Spools not present in Spoolman are counted as skipped."""
- spoolman_client = _make_spoolman_client(spools=[]) # empty — spool 42 is gone
- printer_state = _make_printer_state(remain=50)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- response = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert response.status_code == 200
- body = response.json()
- assert body["synced"] == 0
- assert body["skipped"] == 1
- # ---------------------------------------------------------------------------
- # F6: AMS sync edge cases
- # ---------------------------------------------------------------------------
- class TestSyncAmsEdgeCases:
- """F6: Edge cases for remain values and AMS data format."""
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_null_remain_skipped(self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment):
- """remain=null → tray skipped, synced=0 skipped=1."""
- spoolman_client = _make_spoolman_client()
- printer_state = _make_printer_state(remain=None)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert resp.status_code == 200
- body = resp.json()
- assert body["synced"] == 0
- assert body["skipped"] == 1
- spoolman_client.update_spool_full.assert_not_called()
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_remain_above_100_skipped(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """remain=101 → out-of-range, tray skipped."""
- spoolman_client = _make_spoolman_client()
- printer_state = _make_printer_state(remain=101)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert resp.status_code == 200
- body = resp.json()
- assert body["synced"] == 0
- assert body["skipped"] == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_negative_remain_skipped(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """remain=-1 → out-of-range, tray skipped."""
- spoolman_client = _make_spoolman_client()
- printer_state = _make_printer_state(remain=-1)
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=printer_state)
- resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert resp.status_code == 200
- body = resp.json()
- assert body["synced"] == 0
- assert body["skipped"] == 1
- @pytest.mark.asyncio
- @pytest.mark.integration
- async def test_dict_wrapped_ams_format(
- self, async_client: AsyncClient, sync_settings, test_printer, slot_assignment
- ):
- """Double-nested ams format: raw_data['ams'] is a dict with 'ams' key → list."""
- spoolman_client = _make_spoolman_client()
- state = MagicMock()
- state.raw_data = {
- "ams": {
- "ams": [
- {
- "id": 0,
- "tray": [{"id": 0, "remain": 75}],
- }
- ]
- }
- }
- with (
- patch(
- "backend.app.api.routes.spoolman_inventory._get_client",
- AsyncMock(return_value=spoolman_client),
- ),
- patch("backend.app.api.routes.spoolman_inventory.printer_manager") as pm_mock,
- ):
- pm_mock.get_status = MagicMock(return_value=state)
- resp = await async_client.post("/api/v1/spoolman/inventory/sync-ams-weights")
- assert resp.status_code == 200
- body = resp.json()
- assert body["synced"] == 1
- assert body["skipped"] == 0
|