/** * Tests for the SettingsPage component. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { act, fireEvent, render as rtlRender, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { BrowserRouter } from 'react-router-dom'; import { render } from '../utils'; import { ThemeProvider } from '../../contexts/ThemeContext'; import { ToastProvider } from '../../contexts/ToastContext'; import { AuthProvider } from '../../contexts/AuthContext'; import { SettingsPage } from '../../pages/SettingsPage'; import { http, HttpResponse } from 'msw'; import { server } from '../mocks/server'; import { SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, SIDEBAR_ORDER_KEY } from '../../utils/sidebarLayout'; import { setAuthToken } from '../../api/client'; const mockSettings = { auto_archive: true, save_thumbnails: true, capture_finish_photo: true, default_filament_cost: 25.0, currency: 'USD', ams_humidity_good: 40, ams_humidity_fair: 60, ams_temp_good: 30, ams_temp_fair: 35, time_format: 'system', date_format: 'system', mqtt_enabled: false, mqtt_host: '', mqtt_port: 1883, spoolman_enabled: false, spoolman_url: '', ha_enabled: false, ha_url: '', ha_token: '', check_updates: false, check_printer_firmware: false, bed_cooled_threshold: 35, }; describe('SettingsPage', () => { beforeEach(() => { // BrowserRouter shares window.location across tests; reset it so a tab // switch in one test (e.g. clicking "Workflow") doesn't carry into // sibling tests that expect to land on the default General tab. window.history.replaceState({}, '', '/'); vi.mocked(localStorage.getItem).mockReset(); vi.mocked(localStorage.setItem).mockReset(); vi.mocked(localStorage.removeItem).mockReset(); vi.mocked(localStorage.clear).mockReset(); localStorage.clear(); setAuthToken(null); server.use( http.get('/api/v1/settings/', () => { return HttpResponse.json(mockSettings); }), http.put('/api/v1/settings/', async ({ request }) => { const body = await request.json(); return HttpResponse.json({ ...mockSettings, ...body }); }), http.get('/api/v1/printers/', () => { return HttpResponse.json([]); }), http.get('/api/v1/smart-plugs/', () => { return HttpResponse.json([]); }), http.get('/api/v1/notifications/', () => { return HttpResponse.json([]); }), http.get('/api/v1/api-keys/', () => { return HttpResponse.json([]); }), http.get('/api/v1/mqtt/status', () => { return HttpResponse.json({ enabled: false }); }), http.get('/api/v1/virtual-printer/status', () => { return HttpResponse.json({ running: false }); }), http.get('/api/v1/auth/status', () => { return HttpResponse.json({ auth_enabled: false, requires_setup: false }); }), http.get('/api/v1/external-links/', () => { return HttpResponse.json([]); }) ); }); describe('rendering', () => { it('renders the page title', async () => { render(); await waitFor(() => { // Use role-based query to avoid conflicts with dropdown options expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument(); }); }); it('shows settings tabs', async () => { render(); await waitFor(() => { // Use getAllByText since "General" appears both as tab and section heading expect(screen.getAllByText('General').length).toBeGreaterThan(0); expect(screen.getByText('Smart Plugs')).toBeInTheDocument(); expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0); expect(screen.getAllByText('Filament').length).toBeGreaterThan(0); expect(screen.getByText('Network')).toBeInTheDocument(); expect(screen.getByText('API Keys')).toBeInTheDocument(); }); }); }); describe('finish photo plate restore (#2547)', () => { const restoreLabel = 'Restore plate for finish photo'; it('offers the toggle while finish photos are enabled', async () => { render(); expect(await screen.findByText(restoreLabel)).toBeInTheDocument(); }); it('hides the toggle when finish photos are switched off', async () => { // It only describes how the finish photo is framed, so it is meaningless // when no finish photo is taken at all. server.use( http.get('/api/v1/settings/', () => HttpResponse.json({ ...mockSettings, capture_finish_photo: false }) ) ); render(); await screen.findByRole('heading', { name: 'Settings' }); await waitFor(() => { expect(screen.queryByText(restoreLabel)).not.toBeInTheDocument(); }); }); it('defaults to on when the backend has never stored the setting', async () => { // Existing installs have no row for it; the UI must not read that as off. render(); const label = await screen.findByText(restoreLabel); const row = label.closest('div')!.parentElement!; expect(within(row).getByRole('checkbox')).toBeChecked(); }); it('sends the new value on save', async () => { let saved: Record | null = null; server.use( http.put('/api/v1/settings/', async ({ request }) => { saved = (await request.json()) as Record; return HttpResponse.json({ ...mockSettings, ...saved }); }) ); render(); const label = await screen.findByText(restoreLabel); // The page suppresses auto-save for 100ms after the settings load, so a // click landing inside that window is swallowed with no re-trigger. await new Promise((resolve) => setTimeout(resolve, 200)); const row = label.closest('div')!.parentElement!; await userEvent.click(within(row).getByRole('checkbox')); // The page auto-saves on a 500ms debounce, so the default 1s waitFor // window is only just wide enough — give the request room to land. await waitFor(() => { expect(saved).not.toBeNull(); }, { timeout: 3000 }); expect(saved!.finish_photo_restore_plate).toBe(false); }); }); describe('general settings', () => { it('shows date format setting', async () => { render(); await waitFor(() => { expect(screen.getByText('Date Format')).toBeInTheDocument(); }); }); it('shows time format setting', async () => { render(); await waitFor(() => { expect(screen.getByText('Time Format')).toBeInTheDocument(); }); }); it('shows default printer setting', async () => { render(); await waitFor(() => { expect(screen.getByText('Default Printer')).toBeInTheDocument(); }); }); it('shows preferred slicer setting on Workflow tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { expect(screen.getByText('Preferred Slicer')).toBeInTheDocument(); }); }); it('shows slicer dropdown with both options on Workflow tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { const slicerSelect = screen.getAllByDisplayValue('Bambu Studio'); expect(slicerSelect.length).toBeGreaterThan(0); }); }); it('shows appearance section', async () => { render(); await waitFor(() => { expect(screen.getByText('Appearance')).toBeInTheDocument(); }); }); it('shows updates section with firmware toggle', async () => { render(); await waitFor(() => { expect(screen.getByText('Updates')).toBeInTheDocument(); expect(screen.getByText('Check for updates')).toBeInTheDocument(); expect(screen.getByText('Check printer firmware')).toBeInTheDocument(); }); }); it('hides a Bambuddy sidebar page from Sidebar', async () => { const user = userEvent.setup(); render(); await screen.findByRole('heading', { name: 'Sidebar' }); await screen.findAllByText('Visible in sidebar'); vi.mocked(localStorage.setItem).mockClear(); await user.click((await screen.findAllByLabelText('Hide page'))[0]); expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify(['printers'])); expect(screen.getByText('Hidden from sidebar')).toBeInTheDocument(); }); it('shows a previously hidden Bambuddy sidebar page from Sidebar', async () => { vi.mocked(localStorage.getItem).mockImplementation((key) => { if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers']); return null; }); const user = userEvent.setup(); render(); await screen.findByRole('heading', { name: 'Sidebar' }); await screen.findByText('Hidden from sidebar'); vi.mocked(localStorage.setItem).mockClear(); await user.click(await screen.findByLabelText('Show page')); expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([])); expect(screen.getAllByText('Visible in sidebar').length).toBeGreaterThan(0); }); it('does not allow Settings to be hidden from Sidebar', async () => { render(); await screen.findByRole('heading', { name: 'Sidebar' }); await screen.findByText('Required in sidebar'); const settingsVisibilityButton = await screen.findByLabelText('Settings cannot be hidden'); expect(settingsVisibilityButton).toBeDisabled(); expect(screen.getByText('Required in sidebar')).toBeInTheDocument(); }); it('presents external links and Bambuddy pages in saved sidebar order', async () => { vi.mocked(localStorage.getItem).mockImplementation((key) => { if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'printers', 'settings']); return null; }); server.use( http.get('/api/v1/external-links/', () => HttpResponse.json([ { id: 7, name: 'Docs', url: 'https://docs.example.test', icon: 'Link', open_in_new_tab: true, custom_icon: null, sort_order: 0, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ]), ), ); render(); await screen.findByRole('heading', { name: 'Sidebar' }); const docs = await screen.findByText('Docs'); const printers = screen.getAllByText('Printers').find(element => element.closest('[draggable="true"]')); expect(printers).toBeDefined(); expect(docs.compareDocumentPosition(printers) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); it('saves mixed Sidebar order when items are dragged', async () => { server.use( http.get('/api/v1/external-links/', () => HttpResponse.json([ { id: 7, name: 'Docs', url: 'https://docs.example.test', icon: 'Link', open_in_new_tab: true, custom_icon: null, sort_order: 0, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ]), ), ); render(); await screen.findByRole('heading', { name: 'Sidebar' }); const docsRow = (await screen.findByText('Docs')).closest('[draggable="true"]'); const printersRow = screen.getAllByText('Printers') .find(element => element.closest('[draggable="true"]')) ?.closest('[draggable="true"]'); expect(docsRow).not.toBeNull(); expect(printersRow).not.toBeNull(); vi.mocked(localStorage.setItem).mockClear(); const dataTransfer = { effectAllowed: '', dropEffect: '', setData: vi.fn(), }; fireEvent.dragStart(docsRow!, { dataTransfer }); fireEvent.dragOver(printersRow!, { dataTransfer }); fireEvent.drop(printersRow!, { dataTransfer }); expect(localStorage.setItem).toHaveBeenCalledWith( SIDEBAR_ORDER_KEY, JSON.stringify(['ext-7', 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings']), ); }); it('resets Sidebar to all pages first and configured links at the bottom', async () => { vi.mocked(localStorage.getItem).mockImplementation((key) => { if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['printers', 'stats']); if (key === SIDEBAR_ORDER_KEY) return JSON.stringify(['ext-7', 'settings', 'printers']); return null; }); server.use( http.get('/api/v1/external-links/', () => HttpResponse.json([ { id: 7, name: 'Docs', url: 'https://docs.example.test', icon: 'Link', open_in_new_tab: true, custom_icon: null, sort_order: 0, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', }, ]), ), ); const user = userEvent.setup(); render(); const heading = await screen.findByRole('heading', { name: 'Sidebar' }); const card = heading.closest('#card-sidebar-links'); expect(card).not.toBeNull(); await screen.findByText('Docs'); vi.mocked(localStorage.setItem).mockClear(); await user.click(within(card as HTMLElement).getByRole('button', { name: /reset/i })); expect(localStorage.setItem).toHaveBeenCalledWith(SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY, JSON.stringify([])); expect(localStorage.setItem).toHaveBeenCalledWith( SIDEBAR_ORDER_KEY, JSON.stringify(['printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', 'ext-7']), ); const settingsRow = screen.getAllByText('Settings') .find(element => element.closest('[draggable="true"]')) ?.closest('[draggable="true"]'); const docsRow = screen.getByText('Docs').closest('[draggable="true"]'); expect(settingsRow).not.toBeNull(); expect(docsRow).not.toBeNull(); expect(settingsRow!.compareDocumentPosition(docsRow!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); expect(screen.queryByText('Hidden from sidebar')).not.toBeInTheDocument(); }); it('sets the current Sidebar order as the backend default for settings admins', async () => { let defaultSidebarOrderPayload: string | null = null; vi.mocked(localStorage.getItem).mockImplementation((key) => { if (key === SIDEBAR_HIDDEN_SYSTEM_ITEMS_KEY) return JSON.stringify(['stats']); return null; }); server.use( http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: true, requires_setup: false }), ), http.get('/api/v1/auth/me', () => HttpResponse.json({ id: 1, username: 'admin', role: 'admin', is_active: true, is_admin: false, groups: [{ id: 1, name: 'Administrators' }], permissions: ['settings:update'], created_at: '2026-01-01T00:00:00Z', }), ), http.get('/api/v1/settings/', () => HttpResponse.json({ ...mockSettings, default_sidebar_order: '' }), ), http.put('/api/v1/settings/', async ({ request }) => { const body = await request.json() as { default_sidebar_order?: string }; defaultSidebarOrderPayload = body.default_sidebar_order ?? null; return HttpResponse.json({ ...mockSettings, ...body }); }), ); setAuthToken('test-token'); const user = userEvent.setup(); render(); const heading = await screen.findByRole('heading', { name: 'Sidebar' }); const card = heading.closest('#card-sidebar-links'); expect(card).not.toBeNull(); await user.click(within(card as HTMLElement).getByRole('switch', { name: 'Set Default' })); await waitFor(() => { expect(defaultSidebarOrderPayload).not.toBeNull(); }); expect(JSON.parse(defaultSidebarOrderPayload!)).toEqual({ order: [ 'printers', 'inventory', 'archives', 'queue', 'projects', 'files', 'makerworld', 'profiles', 'maintenance', 'stats', 'notifications', 'settings', ], hiddenSystemItemIds: ['stats'], }); }); }); describe('update CTA per deployment shape', () => { // The update card branches on the deployment shape returned by // /updates/check. Each branch is mutually exclusive — verify the right // one wins so HA addon users never see the docker-compose snippet // (which they can't run from inside an HA addon container) and Docker // users never see the in-app Install button (which would no-op). const renderWithUpdateCheck = async ( checkBody: Record, ) => { server.use( http.get('/api/v1/settings/', () => HttpResponse.json({ ...mockSettings, check_updates: true }), ), http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)), ); render(); await waitFor(() => { expect(screen.getByText('Updates')).toBeInTheDocument(); }); }; it('shows the HA Supervisor message when running as an HA addon', async () => { await renderWithUpdateCheck({ update_available: true, current_version: '0.2.4', latest_version: '0.2.5', release_name: '0.2.5', release_notes: '', release_url: 'https://example.invalid/r', published_at: '2099-01-01T00:00:00Z', is_docker: true, is_ha_addon: true, update_method: 'ha_addon', }); await waitFor(() => { expect( screen.getByText(/Home Assistant Supervisor/i), ).toBeInTheDocument(); }); // Docker hint must NOT render — HA branch wins. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument(); }); it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => { await renderWithUpdateCheck({ update_available: true, current_version: '0.2.4', latest_version: '0.2.5', release_name: '0.2.5', release_notes: '', release_url: 'https://example.invalid/r', published_at: '2099-01-01T00:00:00Z', is_docker: true, is_ha_addon: false, update_method: 'docker', }); await waitFor(() => { expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument(); }); expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument(); }); it('shows the installer-download link for Windows installer installs', async () => { const downloadUrl = 'https://github.com/maziggy/bambuddy/releases/download/v0.2.5/bambuddy-0.2.5-windows-x64-setup.exe'; await renderWithUpdateCheck({ update_available: true, current_version: '0.2.4', latest_version: '0.2.5', release_name: '0.2.5', release_notes: '', release_url: 'https://github.com/maziggy/bambuddy/releases/tag/v0.2.5', published_at: '2099-01-01T00:00:00Z', is_docker: false, is_ha_addon: false, is_windows_installer: true, update_method: 'windows_installer', installer_download_url: downloadUrl, }); const link = await screen.findByRole('link', { name: /download installer for v0\.2\.5/i }); expect(link).toHaveAttribute('href', downloadUrl); expect(link).toHaveAttribute('target', '_blank'); expect(link).toHaveAttribute('rel', expect.stringContaining('noopener')); // The in-app update button must NOT render — the git-fetch path can't // work from an installer payload. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument(); expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument(); expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument(); }); }); describe('tabs navigation', () => { it('can switch to Network tab', async () => { const user = userEvent.setup(); render(); // Wait for settings to load first await waitFor(() => { expect(screen.getByText('Date Format')).toBeInTheDocument(); }); await user.click(screen.getByText('Network')); await waitFor(() => { // Network tab contains MQTT Publishing section expect(screen.getByText('MQTT Publishing')).toBeInTheDocument(); }); }); it('can switch to Smart Plugs tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Smart Plugs')).toBeInTheDocument(); }); await user.click(screen.getByText('Smart Plugs')); await waitFor(() => { expect(screen.getByText('Add Smart Plug')).toBeInTheDocument(); }); }); it('can switch to Notifications tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0); }); // Click the tab button (not the mobile dropdown option) const notificationButtons = screen.getAllByText('Notifications'); const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0]; await user.click(tabButton); await waitFor(() => { expect(screen.getByText('Add Provider')).toBeInTheDocument(); }); }); it('can switch to Filament tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getAllByText('Filament').length).toBeGreaterThan(0); }); await user.click(screen.getAllByText('Filament')[0]); await waitFor(() => { expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument(); }); }); }); describe('Workflow tab', () => { it('can switch to Workflow tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { expect(screen.getByText('Staggered Start')).toBeInTheDocument(); }); }); it('shows stagger settings on Workflow tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { expect(screen.getByText('Staggered Start')).toBeInTheDocument(); expect(screen.getByText('Group size')).toBeInTheDocument(); expect(screen.getByText('Interval (minutes)')).toBeInTheDocument(); }); }); it('shows auto-drying settings on Workflow tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument(); }); }); it('shows per-filament humidity threshold editor on Workflow tab (#1605)', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { expect(screen.getByText('Humidity Thresholds')).toBeInTheDocument(); // Default row is unique to the humidity editor (drying presets has no // default row), so we can pin it without disambiguating from the // adjacent drying-presets table that also lists PLA/ASA/etc. expect(screen.getByText('Default (unknown types)')).toBeInTheDocument(); // Filament rows render in both tables — assert by count instead of // a single getByText. 8 default filaments × 2 tables = 16 PLAs etc. expect(screen.getAllByText('PLA').length).toBeGreaterThanOrEqual(2); expect(screen.getAllByText('ASA').length).toBeGreaterThanOrEqual(2); }); }); it('shows default print options on Workflow tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { expect(screen.getByText('Default Print Options')).toBeInTheDocument(); expect(screen.getByText('Bed Levelling')).toBeInTheDocument(); expect(screen.getByText('Flow Calibration')).toBeInTheDocument(); expect(screen.getByText('Vibration Calibration')).toBeInTheDocument(); expect(screen.getByText('First Layer Inspection')).toBeInTheDocument(); expect(screen.getByText('Timelapse')).toBeInTheDocument(); }); }); it('shows default print options description', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('Workflow')).toBeInTheDocument(); }); await user.click(screen.getByText('Workflow')); await waitFor(() => { expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument(); }); }); }); describe('API Keys tab', () => { it('can switch to API Keys tab', async () => { const user = userEvent.setup(); render(); await waitFor(() => { expect(screen.getByText('API Keys')).toBeInTheDocument(); }); await user.click(screen.getByText('API Keys')); await waitFor(() => { // Button text is "Create Key" expect(screen.getByText('Create Key')).toBeInTheDocument(); }); }); }); describe('SpoolBuddy tab badge', () => { const baseDevice = { id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', ip_address: '10.0.0.1', backend_url: null, firmware_version: '1.0.0', has_nfc: true, has_scale: true, tare_offset: 0, calibration_factor: 1.0, nfc_reader_type: null, nfc_connection: null, display_brightness: 100, display_blank_timeout: 0, has_backlight: false, last_calibrated_at: null, last_seen: new Date().toISOString(), pending_command: null, nfc_ok: true, scale_ok: true, uptime_s: 100, update_status: null, update_message: null, system_stats: null, online: true, created_at: '2024-01-01T00:00:00Z', updated_at: '2024-01-01T00:00:00Z', }; it('shows device count and green bullet when at least one device is online', async () => { server.use( http.get('/api/v1/spoolbuddy/devices', () => { return HttpResponse.json([ { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true }, { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false }, ]); }) ); render(); // Find the tab button (not the header) — it's the