SettingsPage.test.tsx 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991
  1. /**
  2. * Tests for the SettingsPage component.
  3. */
  4. import { describe, it, expect, beforeEach } from 'vitest';
  5. import { screen, waitFor } from '@testing-library/react';
  6. import userEvent from '@testing-library/user-event';
  7. import { render } from '../utils';
  8. import { SettingsPage } from '../../pages/SettingsPage';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. const mockSettings = {
  12. auto_archive: true,
  13. save_thumbnails: true,
  14. capture_finish_photo: true,
  15. default_filament_cost: 25.0,
  16. currency: 'USD',
  17. ams_humidity_good: 40,
  18. ams_humidity_fair: 60,
  19. ams_temp_good: 30,
  20. ams_temp_fair: 35,
  21. time_format: 'system',
  22. date_format: 'system',
  23. mqtt_enabled: false,
  24. mqtt_host: '',
  25. mqtt_port: 1883,
  26. spoolman_enabled: false,
  27. spoolman_url: '',
  28. ha_enabled: false,
  29. ha_url: '',
  30. ha_token: '',
  31. check_updates: false,
  32. check_printer_firmware: false,
  33. bed_cooled_threshold: 35,
  34. };
  35. describe('SettingsPage', () => {
  36. beforeEach(() => {
  37. // BrowserRouter shares window.location across tests; reset it so a tab
  38. // switch in one test (e.g. clicking "Workflow") doesn't carry into
  39. // sibling tests that expect to land on the default General tab.
  40. window.history.replaceState({}, '', '/');
  41. server.use(
  42. http.get('/api/v1/settings/', () => {
  43. return HttpResponse.json(mockSettings);
  44. }),
  45. http.patch('/api/v1/settings/', async ({ request }) => {
  46. const body = await request.json();
  47. return HttpResponse.json({ ...mockSettings, ...body });
  48. }),
  49. http.get('/api/v1/printers/', () => {
  50. return HttpResponse.json([]);
  51. }),
  52. http.get('/api/v1/smart-plugs/', () => {
  53. return HttpResponse.json([]);
  54. }),
  55. http.get('/api/v1/notifications/', () => {
  56. return HttpResponse.json([]);
  57. }),
  58. http.get('/api/v1/api-keys/', () => {
  59. return HttpResponse.json([]);
  60. }),
  61. http.get('/api/v1/mqtt/status', () => {
  62. return HttpResponse.json({ enabled: false });
  63. }),
  64. http.get('/api/v1/virtual-printer/status', () => {
  65. return HttpResponse.json({ running: false });
  66. }),
  67. http.get('/api/v1/auth/status', () => {
  68. return HttpResponse.json({ auth_enabled: false, requires_setup: false });
  69. })
  70. );
  71. });
  72. describe('rendering', () => {
  73. it('renders the page title', async () => {
  74. render(<SettingsPage />);
  75. await waitFor(() => {
  76. // Use role-based query to avoid conflicts with dropdown options
  77. expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument();
  78. });
  79. });
  80. it('shows settings tabs', async () => {
  81. render(<SettingsPage />);
  82. await waitFor(() => {
  83. // Use getAllByText since "General" appears both as tab and section heading
  84. expect(screen.getAllByText('General').length).toBeGreaterThan(0);
  85. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  86. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  87. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  88. expect(screen.getByText('Network')).toBeInTheDocument();
  89. expect(screen.getByText('API Keys')).toBeInTheDocument();
  90. });
  91. });
  92. });
  93. describe('general settings', () => {
  94. it('shows date format setting', async () => {
  95. render(<SettingsPage />);
  96. await waitFor(() => {
  97. expect(screen.getByText('Date Format')).toBeInTheDocument();
  98. });
  99. });
  100. it('shows time format setting', async () => {
  101. render(<SettingsPage />);
  102. await waitFor(() => {
  103. expect(screen.getByText('Time Format')).toBeInTheDocument();
  104. });
  105. });
  106. it('shows default printer setting', async () => {
  107. render(<SettingsPage />);
  108. await waitFor(() => {
  109. expect(screen.getByText('Default Printer')).toBeInTheDocument();
  110. });
  111. });
  112. it('shows preferred slicer setting on Workflow tab', async () => {
  113. const user = userEvent.setup();
  114. render(<SettingsPage />);
  115. await waitFor(() => {
  116. expect(screen.getByText('Workflow')).toBeInTheDocument();
  117. });
  118. await user.click(screen.getByText('Workflow'));
  119. await waitFor(() => {
  120. expect(screen.getByText('Preferred Slicer')).toBeInTheDocument();
  121. });
  122. });
  123. it('shows slicer dropdown with both options on Workflow tab', async () => {
  124. const user = userEvent.setup();
  125. render(<SettingsPage />);
  126. await waitFor(() => {
  127. expect(screen.getByText('Workflow')).toBeInTheDocument();
  128. });
  129. await user.click(screen.getByText('Workflow'));
  130. await waitFor(() => {
  131. const slicerSelect = screen.getAllByDisplayValue('Bambu Studio');
  132. expect(slicerSelect.length).toBeGreaterThan(0);
  133. });
  134. });
  135. it('shows appearance section', async () => {
  136. render(<SettingsPage />);
  137. await waitFor(() => {
  138. expect(screen.getByText('Appearance')).toBeInTheDocument();
  139. });
  140. });
  141. it('shows updates section with firmware toggle', async () => {
  142. render(<SettingsPage />);
  143. await waitFor(() => {
  144. expect(screen.getByText('Updates')).toBeInTheDocument();
  145. expect(screen.getByText('Check for updates')).toBeInTheDocument();
  146. expect(screen.getByText('Check printer firmware')).toBeInTheDocument();
  147. });
  148. });
  149. });
  150. describe('update CTA per deployment shape', () => {
  151. // The update card branches on the deployment shape returned by
  152. // /updates/check. Each branch is mutually exclusive — verify the right
  153. // one wins so HA addon users never see the docker-compose snippet
  154. // (which they can't run from inside an HA addon container) and Docker
  155. // users never see the in-app Install button (which would no-op).
  156. const renderWithUpdateCheck = async (
  157. checkBody: Record<string, unknown>,
  158. ) => {
  159. server.use(
  160. http.get('/api/v1/settings/', () =>
  161. HttpResponse.json({ ...mockSettings, check_updates: true }),
  162. ),
  163. http.get('/api/v1/updates/check', () => HttpResponse.json(checkBody)),
  164. );
  165. render(<SettingsPage />);
  166. await waitFor(() => {
  167. expect(screen.getByText('Updates')).toBeInTheDocument();
  168. });
  169. };
  170. it('shows the HA Supervisor message when running as an HA addon', async () => {
  171. await renderWithUpdateCheck({
  172. update_available: true,
  173. current_version: '0.2.4',
  174. latest_version: '0.2.5',
  175. release_name: '0.2.5',
  176. release_notes: '',
  177. release_url: 'https://example.invalid/r',
  178. published_at: '2099-01-01T00:00:00Z',
  179. is_docker: true,
  180. is_ha_addon: true,
  181. update_method: 'ha_addon',
  182. });
  183. await waitFor(() => {
  184. expect(
  185. screen.getByText(/Home Assistant Supervisor/i),
  186. ).toBeInTheDocument();
  187. });
  188. // Docker hint must NOT render — HA branch wins.
  189. expect(screen.queryByText('docker compose pull && docker compose up -d')).not.toBeInTheDocument();
  190. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  191. });
  192. it('shows the docker-compose snippet for Docker (non-HA) deployments', async () => {
  193. await renderWithUpdateCheck({
  194. update_available: true,
  195. current_version: '0.2.4',
  196. latest_version: '0.2.5',
  197. release_name: '0.2.5',
  198. release_notes: '',
  199. release_url: 'https://example.invalid/r',
  200. published_at: '2099-01-01T00:00:00Z',
  201. is_docker: true,
  202. is_ha_addon: false,
  203. update_method: 'docker',
  204. });
  205. await waitFor(() => {
  206. expect(screen.getByText('docker compose pull && docker compose up -d')).toBeInTheDocument();
  207. });
  208. expect(screen.queryByText(/Home Assistant Supervisor/i)).not.toBeInTheDocument();
  209. expect(screen.queryByRole('button', { name: /install update/i })).not.toBeInTheDocument();
  210. });
  211. });
  212. describe('tabs navigation', () => {
  213. it('can switch to Network tab', async () => {
  214. const user = userEvent.setup();
  215. render(<SettingsPage />);
  216. // Wait for settings to load first
  217. await waitFor(() => {
  218. expect(screen.getByText('Date Format')).toBeInTheDocument();
  219. });
  220. await user.click(screen.getByText('Network'));
  221. await waitFor(() => {
  222. // Network tab contains MQTT Publishing section
  223. expect(screen.getByText('MQTT Publishing')).toBeInTheDocument();
  224. });
  225. });
  226. it('can switch to Smart Plugs tab', async () => {
  227. const user = userEvent.setup();
  228. render(<SettingsPage />);
  229. await waitFor(() => {
  230. expect(screen.getByText('Smart Plugs')).toBeInTheDocument();
  231. });
  232. await user.click(screen.getByText('Smart Plugs'));
  233. await waitFor(() => {
  234. expect(screen.getByText('Add Smart Plug')).toBeInTheDocument();
  235. });
  236. });
  237. it('can switch to Notifications tab', async () => {
  238. const user = userEvent.setup();
  239. render(<SettingsPage />);
  240. await waitFor(() => {
  241. expect(screen.getAllByText('Notifications').length).toBeGreaterThan(0);
  242. });
  243. // Click the tab button (not the mobile dropdown option)
  244. const notificationButtons = screen.getAllByText('Notifications');
  245. const tabButton = notificationButtons.find(el => el.tagName === 'BUTTON') || notificationButtons[0];
  246. await user.click(tabButton);
  247. await waitFor(() => {
  248. expect(screen.getByText('Add Provider')).toBeInTheDocument();
  249. });
  250. });
  251. it('can switch to Filament tab', async () => {
  252. const user = userEvent.setup();
  253. render(<SettingsPage />);
  254. await waitFor(() => {
  255. expect(screen.getAllByText('Filament').length).toBeGreaterThan(0);
  256. });
  257. await user.click(screen.getAllByText('Filament')[0]);
  258. await waitFor(() => {
  259. expect(screen.getByText('AMS Display Thresholds')).toBeInTheDocument();
  260. });
  261. });
  262. });
  263. describe('Workflow tab', () => {
  264. it('can switch to Workflow tab', async () => {
  265. const user = userEvent.setup();
  266. render(<SettingsPage />);
  267. await waitFor(() => {
  268. expect(screen.getByText('Workflow')).toBeInTheDocument();
  269. });
  270. await user.click(screen.getByText('Workflow'));
  271. await waitFor(() => {
  272. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  273. });
  274. });
  275. it('shows stagger settings on Workflow tab', async () => {
  276. const user = userEvent.setup();
  277. render(<SettingsPage />);
  278. await waitFor(() => {
  279. expect(screen.getByText('Workflow')).toBeInTheDocument();
  280. });
  281. await user.click(screen.getByText('Workflow'));
  282. await waitFor(() => {
  283. expect(screen.getByText('Staggered Start')).toBeInTheDocument();
  284. expect(screen.getByText('Group size')).toBeInTheDocument();
  285. expect(screen.getByText('Interval (minutes)')).toBeInTheDocument();
  286. });
  287. });
  288. it('shows auto-drying settings on Workflow tab', async () => {
  289. const user = userEvent.setup();
  290. render(<SettingsPage />);
  291. await waitFor(() => {
  292. expect(screen.getByText('Workflow')).toBeInTheDocument();
  293. });
  294. await user.click(screen.getByText('Workflow'));
  295. await waitFor(() => {
  296. expect(screen.getByText('Queue Auto-Drying')).toBeInTheDocument();
  297. });
  298. });
  299. it('shows default print options on Workflow tab', async () => {
  300. const user = userEvent.setup();
  301. render(<SettingsPage />);
  302. await waitFor(() => {
  303. expect(screen.getByText('Workflow')).toBeInTheDocument();
  304. });
  305. await user.click(screen.getByText('Workflow'));
  306. await waitFor(() => {
  307. expect(screen.getByText('Default Print Options')).toBeInTheDocument();
  308. expect(screen.getByText('Bed Levelling')).toBeInTheDocument();
  309. expect(screen.getByText('Flow Calibration')).toBeInTheDocument();
  310. expect(screen.getByText('Vibration Calibration')).toBeInTheDocument();
  311. expect(screen.getByText('First Layer Inspection')).toBeInTheDocument();
  312. expect(screen.getByText('Timelapse')).toBeInTheDocument();
  313. });
  314. });
  315. it('shows default print options description', async () => {
  316. const user = userEvent.setup();
  317. render(<SettingsPage />);
  318. await waitFor(() => {
  319. expect(screen.getByText('Workflow')).toBeInTheDocument();
  320. });
  321. await user.click(screen.getByText('Workflow'));
  322. await waitFor(() => {
  323. expect(screen.getByText(/overridden per print in the print dialog/)).toBeInTheDocument();
  324. });
  325. });
  326. });
  327. describe('API Keys tab', () => {
  328. it('can switch to API Keys tab', async () => {
  329. const user = userEvent.setup();
  330. render(<SettingsPage />);
  331. await waitFor(() => {
  332. expect(screen.getByText('API Keys')).toBeInTheDocument();
  333. });
  334. await user.click(screen.getByText('API Keys'));
  335. await waitFor(() => {
  336. // Button text is "Create Key"
  337. expect(screen.getByText('Create Key')).toBeInTheDocument();
  338. });
  339. });
  340. });
  341. describe('SpoolBuddy tab badge', () => {
  342. const baseDevice = {
  343. id: 1,
  344. device_id: 'sb-0001',
  345. hostname: 'sb-kitchen',
  346. ip_address: '10.0.0.1',
  347. backend_url: null,
  348. firmware_version: '1.0.0',
  349. has_nfc: true,
  350. has_scale: true,
  351. tare_offset: 0,
  352. calibration_factor: 1.0,
  353. nfc_reader_type: null,
  354. nfc_connection: null,
  355. display_brightness: 100,
  356. display_blank_timeout: 0,
  357. has_backlight: false,
  358. last_calibrated_at: null,
  359. last_seen: new Date().toISOString(),
  360. pending_command: null,
  361. nfc_ok: true,
  362. scale_ok: true,
  363. uptime_s: 100,
  364. update_status: null,
  365. update_message: null,
  366. system_stats: null,
  367. online: true,
  368. created_at: '2024-01-01T00:00:00Z',
  369. updated_at: '2024-01-01T00:00:00Z',
  370. };
  371. it('shows device count and green bullet when at least one device is online', async () => {
  372. server.use(
  373. http.get('/api/v1/spoolbuddy/devices', () => {
  374. return HttpResponse.json([
  375. { ...baseDevice, id: 1, device_id: 'sb-0001', hostname: 'sb-kitchen', online: true },
  376. { ...baseDevice, id: 2, device_id: 'sb-0002', hostname: 'sb-ghost', online: false },
  377. ]);
  378. })
  379. );
  380. render(<SettingsPage />);
  381. // Find the tab button (not the header) — it's the <button> containing the SpoolBuddy text
  382. const tabButton = await waitFor(() => {
  383. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  384. expect(buttons.length).toBeGreaterThan(0);
  385. return buttons[0];
  386. });
  387. // Count pill rendered
  388. await waitFor(() => {
  389. expect(tabButton.textContent).toContain('2');
  390. });
  391. // Green status bullet (at least one device online)
  392. await waitFor(() => {
  393. expect(tabButton.querySelector('.bg-green-400')).not.toBeNull();
  394. });
  395. });
  396. it('shows gray bullet when all devices are offline', async () => {
  397. server.use(
  398. http.get('/api/v1/spoolbuddy/devices', () => {
  399. return HttpResponse.json([{ ...baseDevice, online: false }]);
  400. })
  401. );
  402. render(<SettingsPage />);
  403. const tabButton = await waitFor(() => {
  404. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  405. expect(buttons.length).toBeGreaterThan(0);
  406. return buttons[0];
  407. });
  408. await waitFor(() => {
  409. expect(tabButton.querySelector('.bg-gray-500')).not.toBeNull();
  410. expect(tabButton.querySelector('.bg-green-400')).toBeNull();
  411. });
  412. });
  413. it('hides the count pill when no devices are registered', async () => {
  414. server.use(
  415. http.get('/api/v1/spoolbuddy/devices', () => HttpResponse.json([]))
  416. );
  417. render(<SettingsPage />);
  418. const tabButton = await waitFor(() => {
  419. const buttons = screen.getAllByRole('button').filter((b) => b.textContent?.includes('SpoolBuddy'));
  420. expect(buttons.length).toBeGreaterThan(0);
  421. return buttons[0];
  422. });
  423. // The only numeric content should NOT be present — tab label only
  424. await waitFor(() => {
  425. expect(tabButton.textContent).toBe('SpoolBuddy');
  426. });
  427. });
  428. });
  429. describe('API Keys tab — delete flow', () => {
  430. // Without setQueryData on success the deleted row stayed visible until a
  431. // manual reload — invalidateQueries didn't reliably trigger a UI swap on
  432. // every browser. Pin the synchronous-removal contract here.
  433. it('removes a deleted key from the list without a page reload', async () => {
  434. const initialKeys = [
  435. {
  436. id: 42,
  437. name: 'CI deploy key',
  438. key_prefix: 'bk_abcd1234',
  439. can_queue: true,
  440. can_control_printer: false,
  441. can_read_status: true,
  442. printer_ids: null,
  443. enabled: true,
  444. last_used: null,
  445. created_at: '2026-01-01T00:00:00Z',
  446. expires_at: null,
  447. },
  448. ];
  449. let deleteCallCount = 0;
  450. server.use(
  451. http.get('/api/v1/api-keys/', () => HttpResponse.json(initialKeys)),
  452. http.delete('/api/v1/api-keys/:id', ({ params }) => {
  453. deleteCallCount += 1;
  454. expect(params.id).toBe('42');
  455. return HttpResponse.json({ message: 'API key deleted' });
  456. })
  457. );
  458. const user = userEvent.setup();
  459. render(<SettingsPage />);
  460. // Switch to API Keys tab. Both desktop tab + mobile dropdown render
  461. // the label, so just grab the button form.
  462. await waitFor(() => {
  463. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  464. });
  465. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  466. expect(tabButton).toBeDefined();
  467. await user.click(tabButton!);
  468. // Key is listed
  469. await waitFor(() => {
  470. expect(screen.getByText('CI deploy key')).toBeInTheDocument();
  471. });
  472. // Click the trash button on the row
  473. const cards = screen.getByText('CI deploy key').closest('.flex.items-center.justify-between');
  474. expect(cards).not.toBeNull();
  475. const trashButton = cards!.querySelectorAll('button');
  476. await user.click(trashButton[trashButton.length - 1]);
  477. // Confirm the deletion in the modal
  478. const confirmButton = await screen.findByRole('button', { name: /delete/i });
  479. await user.click(confirmButton);
  480. // The deleted key disappears from the list immediately — no manual
  481. // reload required. setQueryData drops it before any refetch could fire.
  482. await waitFor(() => {
  483. expect(screen.queryByText('CI deploy key')).not.toBeInTheDocument();
  484. });
  485. expect(deleteCallCount).toBe(1);
  486. });
  487. });
  488. describe('API Keys tab — #1182 cloud access + ownership UI', () => {
  489. // The list now exposes two new bits of information per row:
  490. // - "Cloud" badge when can_access_cloud=true
  491. // - "Legacy" badge when user_id IS NULL (created before per-user ownership)
  492. // These tell the operator at a glance which keys can read /cloud/* data
  493. // and which keys need to be recreated to gain that capability.
  494. it('renders the Cloud badge for keys with can_access_cloud=true and the Legacy badge for ownerless keys', async () => {
  495. const keys = [
  496. {
  497. id: 1,
  498. name: 'cloud-reader',
  499. key_prefix: 'bk_cloud123',
  500. user_id: 7,
  501. can_queue: false,
  502. can_control_printer: false,
  503. can_read_status: true,
  504. can_access_cloud: true,
  505. printer_ids: null,
  506. enabled: true,
  507. last_used: null,
  508. created_at: '2026-04-30T00:00:00Z',
  509. expires_at: null,
  510. },
  511. {
  512. id: 2,
  513. name: 'legacy-key',
  514. key_prefix: 'bk_legacy01',
  515. user_id: null,
  516. can_queue: true,
  517. can_control_printer: false,
  518. can_read_status: true,
  519. can_access_cloud: false,
  520. printer_ids: null,
  521. enabled: true,
  522. last_used: null,
  523. created_at: '2025-01-01T00:00:00Z',
  524. expires_at: null,
  525. },
  526. ];
  527. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  528. const user = userEvent.setup();
  529. render(<SettingsPage />);
  530. await waitFor(() => {
  531. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  532. });
  533. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  534. await user.click(tabButton!);
  535. await waitFor(() => {
  536. expect(screen.getByText('cloud-reader')).toBeInTheDocument();
  537. expect(screen.getByText('legacy-key')).toBeInTheDocument();
  538. });
  539. // Cloud-enabled key gets the Cloud badge but NOT the Legacy badge.
  540. const cloudRow = screen.getByText('cloud-reader').closest('.flex.items-center.justify-between');
  541. expect(cloudRow).not.toBeNull();
  542. expect(cloudRow!.textContent).toContain('Cloud');
  543. expect(cloudRow!.textContent).not.toContain('Legacy');
  544. // Ownerless key gets Legacy but NOT Cloud (can_access_cloud=false).
  545. const legacyRow = screen.getByText('legacy-key').closest('.flex.items-center.justify-between');
  546. expect(legacyRow).not.toBeNull();
  547. expect(legacyRow!.textContent).toContain('Legacy');
  548. // Strip the Cloud-flag check by limiting to badge area — the
  549. // "Allow cloud access" text from the create form isn't visible here.
  550. expect(legacyRow!.querySelector('.bg-purple-500\\/20')).toBeNull();
  551. });
  552. it('passes can_access_cloud through to the create call when the toggle is checked', async () => {
  553. let posted: { name?: string; can_access_cloud?: boolean } | null = null;
  554. server.use(
  555. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  556. http.post('/api/v1/api-keys/', async ({ request }) => {
  557. posted = (await request.json()) as { name?: string; can_access_cloud?: boolean };
  558. return HttpResponse.json({
  559. id: 99,
  560. key: 'bk_returnedkey',
  561. name: posted.name,
  562. key_prefix: 'bk_returne',
  563. user_id: 1,
  564. can_queue: true,
  565. can_control_printer: false,
  566. can_read_status: true,
  567. can_access_cloud: posted.can_access_cloud ?? false,
  568. printer_ids: null,
  569. enabled: true,
  570. last_used: null,
  571. created_at: '2026-05-01T00:00:00Z',
  572. expires_at: null,
  573. });
  574. })
  575. );
  576. const user = userEvent.setup();
  577. render(<SettingsPage />);
  578. await waitFor(() => {
  579. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  580. });
  581. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  582. await user.click(tabButton!);
  583. // Open the create form. With an empty key list the empty-state card
  584. // shows "Create Your First Key" — click that to open the form.
  585. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  586. await user.click(openButton);
  587. // Tick the new "Allow cloud access" checkbox. The label wraps the
  588. // input AND a sibling description div, so getByLabelText doesn't
  589. // resolve via implicit-label traversal — locate via text + closest
  590. // label, then grab the checkbox from the same scope.
  591. const cloudLabelText = await screen.findByText(/Allow cloud access/i);
  592. const cloudLabel = cloudLabelText.closest('label');
  593. expect(cloudLabel).not.toBeNull();
  594. const cloudCheckbox = cloudLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  595. expect(cloudCheckbox).not.toBeNull();
  596. await user.click(cloudCheckbox);
  597. // Submit. Two "Create Key" buttons exist once the form is open (header
  598. // CTA + form footer); the form-footer one is the actual submit and
  599. // calls the mutation — find it by walking up from the cloud checkbox
  600. // we just clicked, since both share the same form container.
  601. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  602. // Footer submit is the one inside the same form section as the
  603. // checkbox. The header CTA is in a separate flex row.
  604. const formSubmit = submitButtons.find(
  605. (b) => b.closest('div')?.contains(cloudCheckbox) || cloudLabel?.parentElement?.parentElement?.contains(b),
  606. );
  607. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  608. await waitFor(() => {
  609. expect(posted).not.toBeNull();
  610. expect(posted!.can_access_cloud).toBe(true);
  611. });
  612. });
  613. });
  614. describe('API Keys tab — #1356 energy-cost write scope', () => {
  615. /**
  616. * The narrowly-scoped settings-write toggle. We pin two contracts here:
  617. *
  618. * 1. The "Energy" badge renders for keys that have can_update_energy_cost=true.
  619. * Without a visible signal, an operator can't tell which key in their
  620. * list is the one their HA automation depends on.
  621. * 2. The create form sends can_update_energy_cost=true to the backend
  622. * when the toggle is checked. The whole point of #1356 is that the
  623. * flag must actually be persisted — a UI that drops it silently
  624. * would put us right back where the bug started.
  625. */
  626. it('renders the Energy badge for keys with can_update_energy_cost=true', async () => {
  627. const keys = [
  628. {
  629. id: 1,
  630. name: 'tariff-pusher',
  631. key_prefix: 'bk_tariff01',
  632. user_id: 7,
  633. can_queue: false,
  634. can_control_printer: false,
  635. can_read_status: true,
  636. can_access_cloud: false,
  637. can_update_energy_cost: true,
  638. printer_ids: null,
  639. enabled: true,
  640. last_used: null,
  641. created_at: '2026-05-15T00:00:00Z',
  642. expires_at: null,
  643. },
  644. ];
  645. server.use(http.get('/api/v1/api-keys/', () => HttpResponse.json(keys)));
  646. const user = userEvent.setup();
  647. render(<SettingsPage />);
  648. await waitFor(() => {
  649. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  650. });
  651. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  652. await user.click(tabButton!);
  653. await waitFor(() => {
  654. expect(screen.getByText('tariff-pusher')).toBeInTheDocument();
  655. });
  656. const row = screen.getByText('tariff-pusher').closest('.flex.items-center.justify-between');
  657. expect(row).not.toBeNull();
  658. expect(row!.textContent).toContain('Energy');
  659. });
  660. it('passes can_update_energy_cost through to the create call when the toggle is checked', async () => {
  661. let posted: { name?: string; can_update_energy_cost?: boolean } | null = null;
  662. server.use(
  663. http.get('/api/v1/api-keys/', () => HttpResponse.json([])),
  664. http.post('/api/v1/api-keys/', async ({ request }) => {
  665. posted = (await request.json()) as { name?: string; can_update_energy_cost?: boolean };
  666. return HttpResponse.json({
  667. id: 99,
  668. key: 'bk_returnedkey',
  669. name: posted.name,
  670. key_prefix: 'bk_returne',
  671. user_id: 1,
  672. can_queue: true,
  673. can_control_printer: false,
  674. can_read_status: true,
  675. can_access_cloud: false,
  676. can_update_energy_cost: posted.can_update_energy_cost ?? false,
  677. printer_ids: null,
  678. enabled: true,
  679. last_used: null,
  680. created_at: '2026-05-15T00:00:00Z',
  681. expires_at: null,
  682. });
  683. })
  684. );
  685. const user = userEvent.setup();
  686. render(<SettingsPage />);
  687. await waitFor(() => {
  688. expect(screen.getAllByText('API Keys').length).toBeGreaterThan(0);
  689. });
  690. const tabButton = screen.getAllByText('API Keys').find((el) => el.tagName === 'BUTTON');
  691. await user.click(tabButton!);
  692. const openButton = await screen.findByRole('button', { name: /Create Your First Key/i });
  693. await user.click(openButton);
  694. const energyLabelText = await screen.findByText(/Update electricity price/i);
  695. const energyLabel = energyLabelText.closest('label');
  696. expect(energyLabel).not.toBeNull();
  697. const energyCheckbox = energyLabel!.querySelector('input[type="checkbox"]') as HTMLInputElement;
  698. expect(energyCheckbox).not.toBeNull();
  699. await user.click(energyCheckbox);
  700. const submitButtons = screen.getAllByRole('button', { name: /^Create Key$/i });
  701. const formSubmit = submitButtons.find(
  702. (b) => b.closest('div')?.contains(energyCheckbox) || energyLabel?.parentElement?.parentElement?.contains(b),
  703. );
  704. await user.click(formSubmit ?? submitButtons[submitButtons.length - 1]);
  705. await waitFor(() => {
  706. expect(posted).not.toBeNull();
  707. expect(posted!.can_update_energy_cost).toBe(true);
  708. });
  709. });
  710. });
  711. describe('external camera snapshot URL override (#1177)', () => {
  712. /**
  713. * The snapshot URL input only appears for stream camera types where the
  714. * MJPEG warm-up problem can occur (mjpeg / rtsp / usb). Pure HTTP
  715. * snapshot sources don't need an override since their stream URL is
  716. * already a single-frame endpoint.
  717. */
  718. const mjpegPrinter = {
  719. id: 7,
  720. name: 'go2rtc Cam',
  721. serial_number: 'TEST123',
  722. ip_address: '192.168.1.100',
  723. access_code: 'XXXX',
  724. model: 'P1S',
  725. location: null,
  726. nozzle_count: 1,
  727. is_active: true,
  728. auto_archive: true,
  729. external_camera_url: 'http://192.168.1.61:1984/api/stream.mjpeg?src=printer',
  730. external_camera_type: 'mjpeg',
  731. external_camera_enabled: true,
  732. external_camera_snapshot_url: null,
  733. camera_rotation: 0,
  734. plate_detection_enabled: false,
  735. created_at: '2026-01-01T00:00:00Z',
  736. updated_at: '2026-01-01T00:00:00Z',
  737. };
  738. it('renders the snapshot URL input when camera_type is mjpeg', async () => {
  739. server.use(
  740. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  741. );
  742. render(<SettingsPage />);
  743. await waitFor(() => {
  744. expect(screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).toBeInTheDocument();
  745. });
  746. });
  747. it('hides the snapshot URL input when camera_type is snapshot (already a single-frame source)', async () => {
  748. server.use(
  749. http.get('/api/v1/printers/', () =>
  750. HttpResponse.json([{ ...mjpegPrinter, external_camera_type: 'snapshot' }]),
  751. ),
  752. );
  753. render(<SettingsPage />);
  754. // Wait for the live-stream URL placeholder to render so we know the
  755. // camera section finished mounting before asserting absence of the
  756. // snapshot input below.
  757. await waitFor(() => {
  758. expect(screen.getByPlaceholderText(/Camera URL/i)).toBeInTheDocument();
  759. });
  760. expect(screen.queryByPlaceholderText(/api\/frame\.jpeg\?src=printer/)).not.toBeInTheDocument();
  761. });
  762. it(
  763. 'PATCHes the printer with external_camera_snapshot_url when the user types into the input',
  764. async () => {
  765. let receivedBody: Record<string, unknown> | null = null;
  766. server.use(
  767. http.get('/api/v1/printers/', () => HttpResponse.json([mjpegPrinter])),
  768. http.patch('/api/v1/printers/7', async ({ request }) => {
  769. receivedBody = (await request.json()) as Record<string, unknown>;
  770. return HttpResponse.json({ ...mjpegPrinter, ...receivedBody });
  771. }),
  772. );
  773. render(<SettingsPage />);
  774. const input = await waitFor(() =>
  775. screen.getByPlaceholderText(/api\/frame\.jpeg\?src=printer/),
  776. );
  777. const user = userEvent.setup();
  778. await user.type(input, 'http://192.168.1.61:1984/api/frame.jpeg?src=printer');
  779. // Save is debounced by 800ms; assert the PATCH eventually fires with
  780. // the typed snapshot URL.
  781. await waitFor(
  782. () => {
  783. expect(receivedBody).not.toBeNull();
  784. expect(receivedBody!.external_camera_snapshot_url).toBe(
  785. 'http://192.168.1.61:1984/api/frame.jpeg?src=printer',
  786. );
  787. },
  788. { timeout: 5000 },
  789. );
  790. },
  791. // Per-test timeout raised to 15s — `user.type()` of a 49-char URL plus
  792. // the 800ms save debounce fits in 5s locally (~2.3s typical) but blows
  793. // past it on slow GitHub Actions runners (5000ms timeout was the failure
  794. // mode on PR #1263).
  795. 15_000,
  796. );
  797. });
  798. describe('theme mode buttons', () => {
  799. it('renders Dark, Light, and System buttons', async () => {
  800. render(<SettingsPage />);
  801. await waitFor(() => {
  802. expect(screen.getByRole('button', { name: 'Dark' })).toBeInTheDocument();
  803. expect(screen.getByRole('button', { name: 'Light' })).toBeInTheDocument();
  804. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  805. });
  806. });
  807. it('highlights the active mode button with green border', async () => {
  808. render(<SettingsPage />);
  809. const user = userEvent.setup();
  810. await waitFor(() => {
  811. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  812. });
  813. await user.click(screen.getByRole('button', { name: 'System' }));
  814. await waitFor(() => {
  815. const systemBtn = screen.getByRole('button', { name: 'System' });
  816. expect(systemBtn.className).toContain('border-bambu-green');
  817. });
  818. });
  819. it('clicking a theme button switches mode', async () => {
  820. localStorage.setItem('theme-mode', 'dark');
  821. render(<SettingsPage />);
  822. const user = userEvent.setup();
  823. await waitFor(() => {
  824. const darkBtn = screen.getByRole('button', { name: 'Dark' });
  825. expect(darkBtn.className).toContain('border-bambu-green');
  826. });
  827. const lightBtn = screen.getByRole('button', { name: 'Light' });
  828. await user.click(lightBtn);
  829. await waitFor(() => {
  830. expect(lightBtn.className).toContain('border-bambu-green');
  831. });
  832. });
  833. it('shows a toast when theme button is clicked', async () => {
  834. render(<SettingsPage />);
  835. const user = userEvent.setup();
  836. await waitFor(() => {
  837. expect(screen.getByRole('button', { name: 'System' })).toBeInTheDocument();
  838. });
  839. await user.click(screen.getByRole('button', { name: 'System' }));
  840. await waitFor(() => {
  841. expect(screen.getByText('Settings saved')).toBeInTheDocument();
  842. });
  843. });
  844. });
  845. });