FailureDetectionSettings.test.tsx 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. /**
  2. * Tests for the Failure Detection settings component (#172).
  3. */
  4. import { describe, it, expect, vi, beforeEach } from 'vitest';
  5. import { screen, waitFor, within } 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. bambuddy_internal_url: '',
  25. obico_ml_token: '',
  26. obico_sensitivity: 'medium',
  27. obico_action: 'notify',
  28. obico_poll_interval: 10,
  29. obico_enabled_printers: '',
  30. };
  31. const baseStatus = {
  32. is_running: true,
  33. last_error: null,
  34. per_printer: {},
  35. thresholds: { low: 0.38, high: 0.78 },
  36. history: [],
  37. enabled: false,
  38. ml_url: '',
  39. sensitivity: 'medium',
  40. action: 'notify',
  41. poll_interval: 10,
  42. };
  43. describe('FailureDetectionSettings', () => {
  44. beforeEach(() => {
  45. vi.clearAllMocks();
  46. server.use(
  47. http.get('/api/v1/settings/', () => HttpResponse.json(baseSettings)),
  48. http.get('/api/v1/obico/status', () => HttpResponse.json(baseStatus)),
  49. http.get('/api/v1/printers', () => HttpResponse.json([])),
  50. );
  51. });
  52. it('renders headings and fields', async () => {
  53. render(<FailureDetectionSettings />);
  54. await waitFor(() => {
  55. expect(screen.getByText(/AI Failure Detection|Failure Detection/i)).toBeInTheDocument();
  56. });
  57. expect(screen.getByText(/Obico ML API URL/i)).toBeInTheDocument();
  58. expect(screen.getByText(/Sensitivity/i)).toBeInTheDocument();
  59. expect(screen.getByPlaceholderText('http://bambuddy:8000')).toBeDisabled();
  60. });
  61. it('test button calls the test-connection endpoint and shows success', async () => {
  62. let called = false;
  63. server.use(
  64. http.get('/api/v1/settings/', () =>
  65. HttpResponse.json({ ...baseSettings, obico_enabled: true, obico_ml_url: 'http://obico:3333' }),
  66. ),
  67. http.post('/api/v1/obico/test-connection', async ({ request }) => {
  68. called = true;
  69. const body = (await request.json()) as { url: string };
  70. expect(body.url).toBe('http://obico:3333');
  71. return HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null });
  72. }),
  73. );
  74. render(<FailureDetectionSettings />);
  75. const testBtn = await screen.findByRole('button', { name: /test/i });
  76. await userEvent.click(testBtn);
  77. await waitFor(() => {
  78. expect(called).toBe(true);
  79. });
  80. expect(await screen.findByText(/ML API reachable/i)).toBeInTheDocument();
  81. });
  82. it.each([
  83. ['', 'http://print-server:8000'],
  84. ['http://print-server:8000', ''],
  85. ])('auto-saves a Bambuddy Internal URL change from "%s" to "%s"', async (initial, next) => {
  86. let settings = { ...baseSettings, obico_enabled: true, bambuddy_internal_url: initial };
  87. let saved: Record<string, unknown> | null = null;
  88. server.use(
  89. http.get('/api/v1/settings/', () => HttpResponse.json(settings)),
  90. http.put('/api/v1/settings/', async ({ request }) => {
  91. saved = (await request.json()) as Record<string, unknown>;
  92. settings = { ...settings, ...saved };
  93. return HttpResponse.json(settings);
  94. }),
  95. );
  96. render(<FailureDetectionSettings />);
  97. const input = await screen.findByPlaceholderText('http://bambuddy:8000');
  98. await waitFor(() => {
  99. expect(input).not.toBeDisabled();
  100. expect(input).toHaveValue(initial);
  101. });
  102. await userEvent.clear(input);
  103. if (next) await userEvent.type(input, next);
  104. await waitFor(() => expect(saved?.bambuddy_internal_url).toBe(next), { timeout: 3000 });
  105. });
  106. it('flags a Bambuddy Internal URL without a scheme and does not save it', async () => {
  107. const puts: Record<string, unknown>[] = [];
  108. server.use(
  109. http.get('/api/v1/settings/', () =>
  110. HttpResponse.json({ ...baseSettings, obico_enabled: true, bambuddy_internal_url: '' }),
  111. ),
  112. http.put('/api/v1/settings/', async ({ request }) => {
  113. const body = (await request.json()) as Record<string, unknown>;
  114. puts.push(body);
  115. return HttpResponse.json({ ...baseSettings, ...body });
  116. }),
  117. );
  118. render(<FailureDetectionSettings />);
  119. const input = await screen.findByPlaceholderText('http://bambuddy:8000');
  120. await waitFor(() => expect(input).not.toBeDisabled());
  121. await userEvent.type(input, 'bambuddy:8000');
  122. expect(await screen.findByText(/Must start with http:\/\/ or https:\/\//)).toBeInTheDocument();
  123. expect(input).toHaveAttribute('aria-invalid', 'true');
  124. await new Promise((resolve) => setTimeout(resolve, 800));
  125. expect(puts.filter((body) => 'bambuddy_internal_url' in body)).toEqual([]);
  126. });
  127. describe('ML API token (#2733)', () => {
  128. const enabledWithToken = {
  129. ...baseSettings,
  130. obico_enabled: true,
  131. obico_ml_url: 'http://obico:3333',
  132. obico_ml_token: 's3cret',
  133. };
  134. it('renders the token as a masked field populated from settings', async () => {
  135. server.use(http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)));
  136. render(<FailureDetectionSettings />);
  137. const input = await screen.findByDisplayValue('s3cret');
  138. expect(input).toHaveAttribute('type', 'password');
  139. expect(screen.getByText(/ML API Token/i)).toBeInTheDocument();
  140. });
  141. it('sends the token with the test-connection request', async () => {
  142. let sent: { url: string; token?: string } | null = null;
  143. server.use(
  144. http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
  145. http.post('/api/v1/obico/test-connection', async ({ request }) => {
  146. sent = (await request.json()) as { url: string; token?: string };
  147. return HttpResponse.json({
  148. ok: true,
  149. status_code: 200,
  150. body: 'ok',
  151. error: null,
  152. auth_ok: true,
  153. });
  154. }),
  155. );
  156. render(<FailureDetectionSettings />);
  157. await screen.findByDisplayValue('http://obico:3333');
  158. await userEvent.click(screen.getByRole('button', { name: /test/i }));
  159. await waitFor(() => expect(sent).not.toBeNull());
  160. // The value in the box, not the saved one — so a token can be checked
  161. // before it is committed.
  162. expect(sent!.token).toBe('s3cret');
  163. });
  164. it('reports a rejected token instead of a bare success', async () => {
  165. server.use(
  166. http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
  167. http.post('/api/v1/obico/test-connection', () =>
  168. HttpResponse.json({
  169. ok: false,
  170. status_code: 401,
  171. body: 'ok',
  172. error: 'The ML API is reachable but rejected the token.',
  173. auth_ok: false,
  174. }),
  175. ),
  176. );
  177. render(<FailureDetectionSettings />);
  178. await screen.findByDisplayValue('http://obico:3333');
  179. await userEvent.click(screen.getByRole('button', { name: /test/i }));
  180. expect(await screen.findByText(/rejected the token/i)).toBeInTheDocument();
  181. });
  182. it('does not claim the token works when it could not be checked', async () => {
  183. server.use(
  184. http.get('/api/v1/settings/', () => HttpResponse.json(enabledWithToken)),
  185. http.post('/api/v1/obico/test-connection', () =>
  186. HttpResponse.json({ ok: true, status_code: 200, body: 'ok', error: null, auth_ok: null }),
  187. ),
  188. );
  189. render(<FailureDetectionSettings />);
  190. await screen.findByDisplayValue('http://obico:3333');
  191. await userEvent.click(screen.getByRole('button', { name: /test/i }));
  192. expect(await screen.findByText(/token could not be checked/i)).toBeInTheDocument();
  193. });
  194. it('auto-saves the token', async () => {
  195. let saved: Record<string, unknown> | null = null;
  196. server.use(
  197. http.get('/api/v1/settings/', () => HttpResponse.json({ ...enabledWithToken, obico_ml_token: '' })),
  198. http.put('/api/v1/settings/', async ({ request }) => {
  199. saved = (await request.json()) as Record<string, unknown>;
  200. return HttpResponse.json({ ...enabledWithToken, obico_ml_token: 'typed' });
  201. }),
  202. );
  203. render(<FailureDetectionSettings />);
  204. const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
  205. // Every field stays disabled until the settings query lands.
  206. await waitFor(() => expect(input).not.toBeDisabled());
  207. await userEvent.type(input, 'typed');
  208. await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
  209. expect(saved!.obico_ml_token).toBe('typed');
  210. });
  211. });
  212. describe('printers that are gone', () => {
  213. const limited = { ...baseSettings, obico_enabled: true, obico_enabled_printers: '[1,2]' };
  214. const printerOne = [{ id: 1, name: 'A', serial_number: 'S1', model: 'X1C', is_active: true }];
  215. it('sends back printers this user cannot see', async () => {
  216. // GET /printers lists only the caller's printers; one missing from it may
  217. // just be hidden. The server drops deleted ones itself.
  218. let saved: Record<string, unknown> | null = null;
  219. server.use(
  220. http.get('/api/v1/settings/', () => HttpResponse.json(limited)),
  221. http.get('/api/v1/printers', () => HttpResponse.json(printerOne)),
  222. http.put('/api/v1/settings/', async ({ request }) => {
  223. saved = (await request.json()) as Record<string, unknown>;
  224. return HttpResponse.json({ ...limited, ...saved });
  225. }),
  226. );
  227. render(<FailureDetectionSettings />);
  228. const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
  229. await waitFor(() => expect(input).not.toBeDisabled());
  230. await screen.findByText('A');
  231. await userEvent.type(input, 'typed');
  232. await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
  233. expect(saved!.obico_enabled_printers).toBe('[1,2]');
  234. });
  235. it('takes back what the server refused, so the next save goes through', async () => {
  236. // Printer 5 was deleted elsewhere after this page listed it.
  237. const stored = { ...limited, obico_enabled_printers: '[1]' };
  238. let printerCalls = 0;
  239. let saved: Record<string, unknown> | null = null;
  240. server.use(
  241. http.get('/api/v1/settings/', () => HttpResponse.json(stored)),
  242. http.get('/api/v1/printers', () =>
  243. HttpResponse.json(
  244. printerCalls++ === 0
  245. ? [...printerOne, { id: 5, name: 'E', serial_number: 'S5', model: 'X1C', is_active: true }]
  246. : printerOne,
  247. ),
  248. ),
  249. http.put('/api/v1/settings/', async ({ request }) => {
  250. const body = (await request.json()) as Record<string, unknown>;
  251. if (body.obico_enabled_printers === '[1,5]') {
  252. return HttpResponse.json({ detail: 'Unknown printer: 5' }, { status: 400 });
  253. }
  254. saved = body;
  255. return HttpResponse.json({ ...stored, ...body });
  256. }),
  257. );
  258. render(<FailureDetectionSettings />);
  259. const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
  260. await waitFor(() => expect(input).not.toBeDisabled());
  261. await userEvent.click(within((await screen.findByText('E')).closest('label')!).getByRole('checkbox'));
  262. expect(await screen.findByText(/Unknown printer: 5/, {}, { timeout: 3000 })).toBeInTheDocument();
  263. await waitFor(() => expect(screen.queryByText('E')).not.toBeInTheDocument());
  264. await userEvent.type(input, 'typed');
  265. await waitFor(() => expect(saved).not.toBeNull(), { timeout: 3000 });
  266. expect(saved!.obico_enabled_printers).toBe('[1]');
  267. });
  268. it('keeps an address being typed when the server refuses it as unfinished', async () => {
  269. server.use(
  270. http.get('/api/v1/settings/', () => HttpResponse.json({ ...baseSettings, obico_enabled: true })),
  271. http.put('/api/v1/settings/', () =>
  272. HttpResponse.json({ detail: [{ msg: 'URL must include a hostname' }] }, { status: 422 }),
  273. ),
  274. );
  275. render(<FailureDetectionSettings />);
  276. const input = await screen.findByPlaceholderText('http://192.168.1.10:3333');
  277. await waitFor(() => expect(input).not.toBeDisabled());
  278. await userEvent.type(input, 'http://');
  279. expect(await screen.findByText(/Settings not saved/, {}, { timeout: 3000 })).toBeInTheDocument();
  280. await new Promise((resolve) => setTimeout(resolve, 300));
  281. expect(input).toHaveValue('http://');
  282. });
  283. it('says so when the save is refused', async () => {
  284. server.use(
  285. http.get('/api/v1/settings/', () => HttpResponse.json(limited)),
  286. http.put('/api/v1/settings/', () => HttpResponse.json({ detail: 'Unknown printer: 2' }, { status: 400 })),
  287. );
  288. render(<FailureDetectionSettings />);
  289. const input = await screen.findByPlaceholderText(/ML_API_TOKEN/i);
  290. await waitFor(() => expect(input).not.toBeDisabled());
  291. await userEvent.type(input, 'typed');
  292. expect(await screen.findByText(/Unknown printer: 2/, {}, { timeout: 3000 })).toBeInTheDocument();
  293. });
  294. });
  295. it('shows failure class history entries with red styling', async () => {
  296. server.use(
  297. http.get('/api/v1/obico/status', () =>
  298. HttpResponse.json({
  299. ...baseStatus,
  300. history: [
  301. {
  302. printer_id: 1,
  303. task_name: 'test.3mf',
  304. timestamp: '2026-04-13T10:00:00Z',
  305. current_p: 0.9,
  306. score: 0.85,
  307. class: 'failure',
  308. detections: 1,
  309. },
  310. ],
  311. }),
  312. ),
  313. );
  314. render(<FailureDetectionSettings />);
  315. // Match the history row's score-and-class text, which looks like "failure 0.850"
  316. expect(await screen.findByText(/failure\s+0\.850/)).toBeInTheDocument();
  317. });
  318. });