FailureDetectionSettings.test.tsx 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206
  1. /**
  2. * Tests for the Failure Detection settings component (#172).
  3. */
  4. import { describe, it, expect, vi, 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 { FailureDetectionSettings } from '../../components/FailureDetectionSettings';
  9. import { http, HttpResponse } from 'msw';
  10. import { server } from '../mocks/server';
  11. const baseSettings = {
  12. auto_archive: true,
  13. save_thumbnails: true,
  14. capture_finish_photo: true,
  15. default_filament_cost: 25,
  16. currency: 'USD',
  17. energy_cost_per_kwh: 0.15,
  18. energy_tracking_mode: 'total',
  19. check_updates: true,
  20. check_printer_firmware: true,
  21. include_beta_updates: false,
  22. obico_enabled: false,
  23. obico_ml_url: '',
  24. obico_ml_token: '',
  25. obico_sensitivity: 'medium',
  26. obico_action: 'notify',
  27. obico_poll_interval: 10,
  28. obico_enabled_printers: '',
  29. };
  30. const baseStatus = {
  31. is_running: true,
  32. last_error: null,
  33. per_printer: {},
  34. thresholds: { low: 0.38, high: 0.78 },
  35. history: [],
  36. enabled: false,
  37. ml_url: '',
  38. sensitivity: 'medium',
  39. action: 'notify',
  40. poll_interval: 10,
  41. };
  42. describe('FailureDetectionSettings', () => {
  43. beforeEach(() => {
  44. vi.clearAllMocks();
  45. server.use(
  46. http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
  47. http.get('/api/v1/obico/status', () => HttpResponse.json(baseStatus)),
  48. http.get('/api/v1/printers', () => HttpResponse.json([])),
  49. );
  50. });
  51. it('renders headings and fields', async () => {
  52. render(<FailureDetectionSettings />);
  53. await waitFor(() => {
  54. expect(screen.getByText(/AI Failure Detection|Failure Detection/i)).toBeInTheDocument();
  55. });
  56. expect(screen.getByText(/Obico ML API URL/i)).toBeInTheDocument();
  57. expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument();
  58. });
  59. it('test button calls the test-connection endpoint and shows success', async () => {
  60. let called = false;
  61. server.use(
  62. http.get('/api/v1/settings/', () =>
  63. HttpResponse.json({ ...baseSettings, obico_enabled: true, obico_ml_url: 'http://obico:3333' }),
  64. ),
  65. http.post('/api/v1/obico/test-connection', async ({ request }) => {
  66. called = true;
  67. const body = (await request.json()) as { url: string };
  68. expect(body.url).toBe('http://obico:3333');
  69. return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null });
  70. }),
  71. );
  72. render(<FailureDetectionSettings />);
  73. const testBtn = await screen.findByRole('button', { name: /test/i });
  74. await userEvent.click(testBtn);
  75. await waitFor(() => {
  76. expect(called).toBe(true);
  77. });
  78. expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
  79. });
  80. describe('ML API token (#2733)', () => {
  81. const enabledWithToken = {
  82. ...baseSettings,
  83. obico_enabled: true,
  84. obico_ml_url: 'http://obico:3333',
  85. obico_ml_token: 's3cret',
  86. };
  87. it('renders the token as a masked field populated from settings', async () => {
  88. server.use(http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)));
  89. render(<FailureDetectionSettings />);
  90. const input = await screen.findByDisplayValue('s3cret');
  91. expect(input).toHaveAttribute('type', 'password');
  92. expect(screen.getByText(/ML API Token/i)).toBeInTheDocument();
  93. });
  94. it('sends the token with the test-connection request', async () => {
  95. let sent: { url: string; token?: string } | null = null;
  96. server.use(
  97. http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
  98. http.post('/api/v1/obico/test-connection', async ({ request }) => {
  99. sent = (await request.json()) as { url: string; token?: string };
  100. return HttpResponse.json({
  101. ok: true,
  102. status_code: 200,
  103. body: 'ok',
  104. error: null,
  105. auth_ok: true,
  106. });
  107. }),
  108. );
  109. render(<FailureDetectionSettings />);
  110. await screen.findByDisplayValue('http://obico:3333');
  111. await userEvent.click(screen.getByRole('button', { name: /test/i }));
  112. await waitFor(() => expect(sent).not.toBeNull());
  113. // The value in the box, not the saved one — so a token can be checked
  114. // before it is committed.
  115. expect(sent!.token).toBe('s3cret');
  116. });
  117. it('reports a rejected token instead of a bare success', async () => {
  118. server.use(
  119. http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
  120. http.post('/api/v1/obico/test-connection', () =>
  121. HttpResponse.json({
  122. ok: false,
  123. status_code: 401,
  124. body: 'ok',
  125. error: 'The ML API is reachable but rejected the token.',
  126. auth_ok: false,
  127. }),
  128. ),
  129. );
  130. render(<FailureDetectionSettings />);
  131. await screen.findByDisplayValue('http://obico:3333');
  132. await userEvent.click(screen.getByRole('button', { name: /test/i }));
  133. expect(await screen.findByText(/rejected the token/i)).toBeInTheDocument();
  134. });
  135. it('does not claim the token works when it could not be checked', async () => {
  136. server.use(
  137. http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
  138. http.post('/api/v1/obico/test-connection', () =>
  139. HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null, auth_ok: null }),
  140. ),
  141. );
  142. render(<FailureDetectionSettings />);
  143. await screen.findByDisplayValue('http://obico:3333');
  144. await userEvent.click(screen.getByRole('button', { name: /test/i }));
  145. expect(await screen.findByText(/token could not be checked/i)).toBeInTheDocument();
  146. });
  147. it('auto-saves the token', async () => {
  148. let saved: Record<string, unknown> | null = null;
  149. server.use(
  150. http.get('/api/v1/settings/', () => HttpResponse.json({ ...enabledWithToken, obico_ml_token: '' })),
  151. http.put('/api/v1/settings/', async ({ request }) => {
  152. saved = (await request.json()) as Record<string, unknown>;
  153. return HttpResponse.json({ ...enabledWithToken, obico_ml_token: 'typed' });
  154. }),
  155. );
  156. render(<FailureDetectionSettings />);
  157. const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
  158. // Every field stays disabled until the settings query lands.
  159. await waitFor(() => expect(input).not.toBeDisabled());
  160. await userEvent.type(input, 'typed');
  161. await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
  162. expect(saved!.obico_ml_token).toBe('typed');
  163. });
  164. });
  165. it('shows failure class history entries with red styling', async () => {
  166. server.use(
  167. http.get('/api/v1/obico/status', () =>
  168. HttpResponse.json({
  169. ...baseStatus,
  170. history: [
  171. {
  172. printer_id: 1,
  173. task_name: 'test.3mf',
  174. timestamp: '2026-04-13T10:00:00Z',
  175. current_p: 0.9,
  176. score: 0.85,
  177. class: 'failure',
  178. detections: 1,
  179. },
  180. ],
  181. }),
  182. ),
  183. );
  184. render(<FailureDetectionSettings />);
  185. // Match the history row's score-and-class text, which looks like "failure 0.850"
  186. expect(await screen.findByText(/failure\s+0\.850/)).toBeInTheDocument();
  187. });
  188. });