Browse Source

Add Spoolman integration tests and update spool comment

Backend tests:
- 21 integration tests for all Spoolman API endpoints
- Status, connect/disconnect, spools, filaments, link, sync

Frontend tests:
- 20 component tests for SpoolmanSettings
- Rendering, disabled/enabled states, connection status, sync UI

Also changed auto-created spool comment to "Created by Bambuddy"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
maziggy 4 months ago
parent
commit
a1c12331db

+ 1 - 1
backend/app/services/spoolman.py

@@ -595,7 +595,7 @@ class SpoolmanClient:
             filament_id=filament["id"],
             filament_id=filament["id"],
             remaining_weight=remaining,
             remaining_weight=remaining,
             location=location,
             location=location,
-            comment=f"Auto-created from {printer_name} AMS",
+            comment="Created by Bambuddy",
             extra={"tag": json.dumps(tray.tray_uuid)},
             extra={"tag": json.dumps(tray.tray_uuid)},
         )
         )
 
 

+ 423 - 0
backend/tests/integration/test_spoolman_api.py

@@ -0,0 +1,423 @@
+"""Integration tests for Spoolman API endpoints."""
+
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+from httpx import AsyncClient
+
+
+class TestSpoolmanAPI:
+    """Integration tests for /api/v1/spoolman/ endpoints."""
+
+    @pytest.fixture
+    async def spoolman_settings(self, db_session):
+        """Create Spoolman settings in the database (enabled with URL)."""
+        from backend.app.models.settings import Settings
+
+        # Both settings are required for Spoolman to work
+        enabled_setting = Settings(key="spoolman_enabled", value="true")
+        url_setting = Settings(key="spoolman_url", value="http://localhost:7912")
+        db_session.add(enabled_setting)
+        db_session.add(url_setting)
+        await db_session.commit()
+        return {"enabled": enabled_setting, "url": url_setting}
+
+    @pytest.fixture
+    async def spoolman_url_only(self, db_session):
+        """Create only the URL setting (not enabled)."""
+        from backend.app.models.settings import Settings
+
+        setting = Settings(key="spoolman_url", value="http://localhost:7912")
+        db_session.add(setting)
+        await db_session.commit()
+        return setting
+
+    @pytest.fixture
+    def mock_spoolman_client(self):
+        """Mock the Spoolman client functions."""
+        mock_client = MagicMock()
+        mock_client.is_connected = True
+        mock_client.base_url = "http://localhost:7912"
+        mock_client.health_check = AsyncMock(return_value=True)
+        mock_client.get_spools = AsyncMock(return_value=[])
+        mock_client.get_filaments = AsyncMock(return_value=[])
+        mock_client.create_spool = AsyncMock(return_value={"id": 1})
+        mock_client.update_spool = AsyncMock(return_value={"id": 1})
+        mock_client.close = AsyncMock()
+
+        with (
+            patch(
+                "backend.app.api.routes.spoolman.get_spoolman_client",
+                AsyncMock(return_value=mock_client),
+            ),
+            patch(
+                "backend.app.api.routes.spoolman.init_spoolman_client",
+                AsyncMock(return_value=mock_client),
+            ),
+            patch(
+                "backend.app.api.routes.spoolman.close_spoolman_client",
+                AsyncMock(),
+            ),
+        ):
+            yield mock_client
+
+    @pytest.fixture
+    def mock_spoolman_disconnected(self):
+        """Mock the Spoolman client as disconnected (returns None)."""
+        with (
+            patch(
+                "backend.app.api.routes.spoolman.get_spoolman_client",
+                AsyncMock(return_value=None),
+            ),
+            patch(
+                "backend.app.api.routes.spoolman.init_spoolman_client",
+                AsyncMock(return_value=None),
+            ),
+        ):
+            yield
+
+    # =========================================================================
+    # Status Endpoint Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_status_not_configured(self, async_client: AsyncClient):
+        """Verify status shows not enabled when no settings exist."""
+        response = await async_client.get("/api/v1/spoolman/status")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["enabled"] is False
+        assert data["connected"] is False
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_status_url_only_not_enabled(self, async_client: AsyncClient, spoolman_url_only):
+        """Verify status shows not enabled when only URL is set."""
+        response = await async_client.get("/api/v1/spoolman/status")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["enabled"] is False
+        assert data["url"] == "http://localhost:7912"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_status_enabled_and_connected(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify status shows enabled and connected when properly configured."""
+        response = await async_client.get("/api/v1/spoolman/status")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["enabled"] is True
+        assert data["connected"] is True
+        assert data["url"] == "http://localhost:7912"
+
+    # =========================================================================
+    # Connect/Disconnect Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_connect_not_enabled(self, async_client: AsyncClient):
+        """Verify connect fails when not enabled."""
+        response = await async_client.post("/api/v1/spoolman/connect")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_connect_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify successful connection to Spoolman."""
+        response = await async_client.post("/api/v1/spoolman/connect")
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        assert "connected" in data["message"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_disconnect(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify disconnect works."""
+        response = await async_client.post("/api/v1/spoolman/disconnect")
+        assert response.status_code == 200
+        assert "disconnected" in response.json()["message"].lower()
+
+    # =========================================================================
+    # Spools Endpoint Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_spools_not_enabled(self, async_client: AsyncClient):
+        """Verify get spools fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/spools")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_spools_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify get spools returns data in expected format."""
+        mock_spool = {
+            "id": 1,
+            "remaining_weight": 500,
+            "used_weight": 500,
+            "filament": {
+                "id": 1,
+                "name": "PLA Basic",
+                "material": "PLA",
+                "color_hex": "FF0000",
+            },
+            "first_used": "2024-01-01",
+            "last_used": "2024-01-15",
+            "location": "AMS1",
+            "lot_nr": "LOT123",
+            "comment": "Test spool",
+            "extra": {"tag": '"ABC123"'},
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools")
+        assert response.status_code == 200
+        data = response.json()
+        assert "spools" in data
+        assert isinstance(data["spools"], list)
+        assert len(data["spools"]) == 1
+        assert data["spools"][0]["id"] == 1
+
+    # =========================================================================
+    # Unlinked Spools Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_unlinked_spools_not_enabled(self, async_client: AsyncClient):
+        """Verify get unlinked spools fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/spools/unlinked")
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_unlinked_spools_success(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify get unlinked spools returns spools without tags."""
+        # Mock spool without extra.tag (unlinked)
+        mock_spool = {
+            "id": 1,
+            "remaining_weight": 800,
+            "used_weight": 200,
+            "extra": {},  # No tag = unlinked
+            "filament": {
+                "id": 1,
+                "name": "PLA Basic",
+                "material": "PLA",
+                "color_hex": "FF0000",
+            },
+            "location": "Shelf A",
+        }
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool])
+
+        response = await async_client.get("/api/v1/spoolman/spools/unlinked")
+        assert response.status_code == 200
+        data = response.json()
+        assert isinstance(data, list)
+        assert len(data) == 1
+        assert data[0]["id"] == 1
+        assert data[0]["filament_name"] == "PLA Basic"
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_unlinked_spools_excludes_linked(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify linked spools (with tag) are excluded."""
+        # Mock spool with extra.tag (linked)
+        mock_spool_linked = {
+            "id": 1,
+            "remaining_weight": 800,
+            "used_weight": 200,
+            "extra": {"tag": '"ABC123"'},  # Has tag = linked
+            "filament": {"id": 1, "name": "PLA Red", "material": "PLA", "color_hex": "FF0000"},
+        }
+
+        # Mock spool without tag (unlinked)
+        mock_spool_unlinked = {
+            "id": 2,
+            "remaining_weight": 900,
+            "used_weight": 100,
+            "extra": {},  # No tag = unlinked
+            "filament": {"id": 2, "name": "PLA Blue", "material": "PLA", "color_hex": "0000FF"},
+        }
+
+        mock_spoolman_client.get_spools = AsyncMock(return_value=[mock_spool_linked, mock_spool_unlinked])
+
+        response = await async_client.get("/api/v1/spoolman/spools/unlinked")
+        assert response.status_code == 200
+        data = response.json()
+        assert len(data) == 1
+        assert data[0]["id"] == 2  # Only unlinked spool
+
+    # =========================================================================
+    # Link Spool Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_not_enabled(self, async_client: AsyncClient):
+        """Verify link spool fails when not enabled."""
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
+        )
+        assert response.status_code == 400
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_invalid_uuid_length(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify link spool fails with invalid UUID length."""
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "ABC123"},  # Too short
+        )
+        assert response.status_code == 400
+        assert "32 hex characters" in response.json()["detail"]
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_invalid_uuid_format(
+        self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client
+    ):
+        """Verify link spool fails with non-hex UUID."""
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"},  # Not hex
+        )
+        assert response.status_code == 400
+        assert "hex" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_link_spool_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify successfully linking a spool to AMS tray."""
+        mock_spoolman_client.update_spool = AsyncMock(
+            return_value={"id": 1, "extra": {"tag": '"A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"'}}
+        )
+
+        response = await async_client.post(
+            "/api/v1/spoolman/spools/1/link",
+            json={"tray_uuid": "A1B2C3D4E5F6A1B2C3D4E5F6A1B2C3D4"},
+        )
+        assert response.status_code == 200
+        data = response.json()
+        assert data["success"] is True
+        assert "linked" in data["message"].lower()
+
+        # Verify update_spool was called
+        mock_spoolman_client.update_spool.assert_called_once()
+
+    # =========================================================================
+    # Sync Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_printer_not_enabled(self, async_client: AsyncClient, printer_factory):
+        """Verify sync fails when Spoolman not enabled."""
+        printer = await printer_factory()
+        response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_printer_not_found(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify sync fails for non-existent printer."""
+        response = await async_client.post("/api/v1/spoolman/sync/9999")
+        assert response.status_code == 404
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_returns_result_structure(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+        printer_factory,
+    ):
+        """Verify sync returns proper result structure."""
+        printer = await printer_factory()
+
+        # Mock printer manager to return AMS data
+        with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
+            mock_state = MagicMock()
+            mock_state.raw_data = {"ams": [{"id": 0, "tray": []}]}
+            pm_mock.get_status = MagicMock(return_value=mock_state)
+
+            response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
+            assert response.status_code == 200
+            data = response.json()
+            # Verify SyncResult structure
+            assert "success" in data
+            assert "synced_count" in data
+            assert "skipped_count" in data
+            assert "skipped" in data
+            assert "errors" in data
+            assert isinstance(data["skipped"], list)
+            assert isinstance(data["errors"], list)
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_sync_printer_not_connected(
+        self,
+        async_client: AsyncClient,
+        spoolman_settings,
+        mock_spoolman_client,
+        printer_factory,
+    ):
+        """Verify sync fails when printer is not connected (no status)."""
+        printer = await printer_factory()
+
+        with patch("backend.app.api.routes.spoolman.printer_manager") as pm_mock:
+            pm_mock.get_status = MagicMock(return_value=None)
+
+            response = await async_client.post(f"/api/v1/spoolman/sync/{printer.id}")
+            assert response.status_code == 404
+            assert "not connected" in response.json()["detail"].lower()
+
+    # =========================================================================
+    # Filaments Endpoint Tests
+    # =========================================================================
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_filaments_not_enabled(self, async_client: AsyncClient):
+        """Verify get filaments fails when not enabled."""
+        response = await async_client.get("/api/v1/spoolman/filaments")
+        assert response.status_code == 400
+        assert "not enabled" in response.json()["detail"].lower()
+
+    @pytest.mark.asyncio
+    @pytest.mark.integration
+    async def test_get_filaments_success(self, async_client: AsyncClient, spoolman_settings, mock_spoolman_client):
+        """Verify get filaments returns data in expected format."""
+        mock_filament = {
+            "id": 1,
+            "name": "PLA Basic",
+            "material": "PLA",
+            "color_hex": "FF0000",
+            "vendor_id": 1,
+            "weight": 1000,
+        }
+        mock_spoolman_client.get_filaments = AsyncMock(return_value=[mock_filament])
+
+        response = await async_client.get("/api/v1/spoolman/filaments")
+        assert response.status_code == 200
+        data = response.json()
+        assert "filaments" in data
+        assert isinstance(data["filaments"], list)
+        assert len(data["filaments"]) == 1
+        assert data["filaments"][0]["name"] == "PLA Basic"

+ 314 - 0
frontend/src/__tests__/components/SpoolmanSettings.test.tsx

@@ -0,0 +1,314 @@
+/**
+ * Tests for the SpoolmanSettings component.
+ *
+ * Tests the Spoolman integration UI including:
+ * - Enable/disable toggle
+ * - URL configuration
+ * - Connection status
+ * - Sync functionality
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { screen, waitFor } from '@testing-library/react';
+import { render } from '../utils';
+import { SpoolmanSettings } from '../../components/SpoolmanSettings';
+
+// Mock the API client
+vi.mock('../../api/client', () => ({
+  api: {
+    getSettings: vi.fn().mockResolvedValue({}),
+    updateSettings: vi.fn().mockResolvedValue({}),
+    getSpoolmanStatus: vi.fn(),
+    connectSpoolman: vi.fn(),
+    disconnectSpoolman: vi.fn(),
+    syncAllPrintersAms: vi.fn(),
+    syncPrinterAms: vi.fn(),
+    getPrinters: vi.fn(),
+  },
+}));
+
+// Import mocked module
+import { api } from '../../api/client';
+
+// Mock fetch for Spoolman settings endpoints
+const mockFetchResponse = (data: object) => ({
+  ok: true,
+  json: () => Promise.resolve(data),
+});
+
+describe('SpoolmanSettings', () => {
+  beforeEach(() => {
+    vi.clearAllMocks();
+
+    // Default API mocks
+    vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+      enabled: false,
+      connected: false,
+      url: null,
+    });
+    vi.mocked(api.getPrinters).mockResolvedValue([]);
+    vi.mocked(api.connectSpoolman).mockResolvedValue({ success: true, message: 'Connected' });
+    vi.mocked(api.disconnectSpoolman).mockResolvedValue({ success: true, message: 'Disconnected' });
+    vi.mocked(api.syncAllPrintersAms).mockResolvedValue({
+      success: true,
+      synced_count: 3,
+      skipped_count: 1,
+      skipped: [],
+      errors: [],
+    });
+
+    // Default fetch mock for settings - disabled state
+    global.fetch = vi.fn().mockImplementation((url: string) => {
+      if (url.includes('/api/v1/settings/spoolman')) {
+        return Promise.resolve(
+          mockFetchResponse({
+            spoolman_enabled: 'false',
+            spoolman_url: '',
+            spoolman_sync_mode: 'auto',
+          })
+        );
+      }
+      return Promise.reject(new Error('Unknown URL'));
+    }) as any;
+  });
+
+  describe('rendering', () => {
+    it('renders loading state initially', () => {
+      // Delay the fetch response to catch loading state
+      global.fetch = vi.fn().mockImplementation(() => new Promise(() => {})) as any;
+      render(<SpoolmanSettings />);
+
+      // Should show loading spinner
+      expect(document.querySelector('.animate-spin')).toBeInTheDocument();
+    });
+
+    it('renders component title', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+      });
+    });
+
+    it('renders enable toggle', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Enable Spoolman')).toBeInTheDocument();
+      });
+    });
+
+    it('renders URL input', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Spoolman URL')).toBeInTheDocument();
+        expect(screen.getByPlaceholderText('http://192.168.1.100:7912')).toBeInTheDocument();
+      });
+    });
+
+    it('renders sync mode selector', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sync Mode')).toBeInTheDocument();
+      });
+    });
+
+    it('renders info banner about sync', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('How Sync Works')).toBeInTheDocument();
+        expect(screen.getByText(/Only official Bambu Lab spools/)).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('disabled state', () => {
+    it('URL input is disabled when Spoolman is disabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
+        expect(urlInput).toBeDisabled();
+      });
+    });
+
+    it('sync mode selector is disabled when Spoolman is disabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        // Find the select by its display value
+        const selectElement = screen.getByDisplayValue('Automatic');
+        expect(selectElement).toBeDisabled();
+      });
+    });
+
+    it('does not show connection status when disabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Spoolman Integration')).toBeInTheDocument();
+      });
+
+      // Status section should not be visible when disabled
+      expect(screen.queryByText('Status:')).not.toBeInTheDocument();
+    });
+  });
+
+  describe('enabled state', () => {
+    beforeEach(() => {
+      global.fetch = vi.fn().mockImplementation((url: string) => {
+        if (url.includes('/api/v1/settings/spoolman')) {
+          if (url.includes('PUT') || (url as any).method === 'PUT') {
+            return Promise.resolve(
+              mockFetchResponse({
+                spoolman_enabled: 'true',
+                spoolman_url: 'http://localhost:7912',
+                spoolman_sync_mode: 'auto',
+              })
+            );
+          }
+          return Promise.resolve(
+            mockFetchResponse({
+              spoolman_enabled: 'true',
+              spoolman_url: 'http://localhost:7912',
+              spoolman_sync_mode: 'auto',
+            })
+          );
+        }
+        return Promise.reject(new Error('Unknown URL'));
+      }) as any;
+    });
+
+    it('URL input is enabled when Spoolman is enabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        const urlInput = screen.getByPlaceholderText('http://192.168.1.100:7912');
+        expect(urlInput).not.toBeDisabled();
+      });
+    });
+
+    it('shows connection status section when enabled', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Status:')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Disconnected when not connected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: false,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Disconnected')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Connect button when disconnected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: false,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Connect')).toBeInTheDocument();
+      });
+    });
+
+    it('shows Connected and Disconnect button when connected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Connected')).toBeInTheDocument();
+        expect(screen.getByText('Disconnect')).toBeInTheDocument();
+      });
+    });
+
+    it('shows sync section when connected', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByText('Sync AMS Data')).toBeInTheDocument();
+        expect(screen.getByText('Sync')).toBeInTheDocument();
+      });
+    });
+
+    it('shows All Printers option in sync dropdown', async () => {
+      vi.mocked(api.getSpoolmanStatus).mockResolvedValue({
+        enabled: true,
+        connected: true,
+        url: 'http://localhost:7912',
+      });
+
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: 'All Printers' })).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('sync mode options', () => {
+    it('shows Automatic option', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: 'Automatic' })).toBeInTheDocument();
+      });
+    });
+
+    it('shows Manual Only option', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(screen.getByRole('option', { name: 'Manual Only' })).toBeInTheDocument();
+      });
+    });
+  });
+
+  describe('info text', () => {
+    it('shows URL help text', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('URL of your Spoolman server (e.g., http://localhost:7912)')
+        ).toBeInTheDocument();
+      });
+    });
+
+    it('shows sync mode description for auto mode', async () => {
+      render(<SpoolmanSettings />);
+
+      await waitFor(() => {
+        expect(
+          screen.getByText('AMS data syncs automatically when changes are detected')
+        ).toBeInTheDocument();
+      });
+    });
+  });
+});