AddNotificationModal.test.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388
  1. /**
  2. * Frontend tests for the AddNotificationModal — focused on the per-event
  3. * ntfy Priority section (#990).
  4. *
  5. * Coverage:
  6. * - Priority section renders only for ntfy provider type.
  7. * - Section lists ONLY events the user has enabled, not the whole catalogue.
  8. * - Save round-trips event_priorities into config.
  9. * - Editing an existing ntfy provider pre-fills priorities from config.
  10. * - Switching off a toggle drops the matching row from the priority section.
  11. * - For non-ntfy providers, event_priorities never appears in the saved config.
  12. */
  13. import { describe, it, expect, afterEach, vi } from 'vitest';
  14. import { screen, waitFor, within } from '@testing-library/react';
  15. import userEvent from '@testing-library/user-event';
  16. import { http, HttpResponse } from 'msw';
  17. import { render } from '../utils';
  18. import { server } from '../mocks/server';
  19. import { AddNotificationModal } from '../../components/AddNotificationModal';
  20. import type { NotificationProvider } from '../../api/client';
  21. afterEach(() => {
  22. server.resetHandlers();
  23. vi.restoreAllMocks();
  24. });
  25. function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
  26. return {
  27. id: 1,
  28. name: 'My ntfy',
  29. provider_type: 'ntfy',
  30. enabled: true,
  31. config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
  32. on_print_start: false,
  33. on_print_complete: true,
  34. on_print_failed: true,
  35. on_print_stopped: true,
  36. on_print_progress: false,
  37. on_print_missing_spool_assignment: false,
  38. on_printer_offline: false,
  39. on_printer_error: false,
  40. on_ai_failure_detection: false,
  41. on_filament_low: false,
  42. on_maintenance_due: false,
  43. on_ams_humidity_high: false,
  44. on_ams_temperature_high: false,
  45. on_ams_ht_humidity_high: false,
  46. on_ams_ht_temperature_high: false,
  47. on_plate_not_empty: true,
  48. on_bed_cooled: false,
  49. on_first_layer_complete: false,
  50. on_queue_job_added: false,
  51. on_queue_job_assigned: false,
  52. on_queue_job_started: false,
  53. on_queue_job_waiting: true,
  54. on_queue_job_skipped: true,
  55. on_queue_job_failed: true,
  56. on_queue_completed: false,
  57. on_stock_reorder_alert: false,
  58. on_stock_break_alert: false,
  59. quiet_hours_enabled: false,
  60. quiet_hours_start: null,
  61. quiet_hours_end: null,
  62. daily_digest_enabled: false,
  63. daily_digest_time: null,
  64. printer_id: null,
  65. last_success: null,
  66. last_error: null,
  67. last_error_at: null,
  68. created_at: '2026-04-25T00:00:00Z',
  69. updated_at: '2026-04-25T00:00:00Z',
  70. ...overrides,
  71. };
  72. }
  73. describe('AddNotificationModal — ntfy Priority (#990)', () => {
  74. it('renders the ntfy Priority section listing only enabled events', async () => {
  75. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  76. // Section header present, then scope every label query to it — the same
  77. // labels also appear in the toggle grid above.
  78. const sectionHeader = await screen.findByText(/ntfy priority/i);
  79. const sectionRoot = sectionHeader.closest('div')!;
  80. // Defaults from buildProvider(): complete + failed + stopped enabled;
  81. // start + progress + offline disabled. The priority list mirrors that.
  82. expect(within(sectionRoot).getByText('Complete')).toBeInTheDocument();
  83. expect(within(sectionRoot).getByText('Failed')).toBeInTheDocument();
  84. expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
  85. // Disabled events must not appear in the priority block.
  86. expect(within(sectionRoot).queryByText('Start')).not.toBeInTheDocument();
  87. expect(within(sectionRoot).queryByText('Progress')).not.toBeInTheDocument();
  88. expect(within(sectionRoot).queryByText('Offline')).not.toBeInTheDocument();
  89. });
  90. it('does not render the Priority section for non-ntfy providers', async () => {
  91. render(
  92. <AddNotificationModal
  93. provider={buildProvider({ provider_type: 'telegram', config: { bot_token: 'x', chat_id: 'y' } })}
  94. onClose={() => undefined}
  95. />,
  96. );
  97. // Wait for the modal to settle.
  98. await screen.findByDisplayValue('My ntfy');
  99. expect(screen.queryByText(/ntfy priority/i)).not.toBeInTheDocument();
  100. });
  101. it('persists event_priorities into config on save', async () => {
  102. let captured: unknown = null;
  103. server.use(
  104. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  105. captured = await request.json();
  106. return HttpResponse.json({ id: 1 });
  107. }),
  108. );
  109. const onClose = vi.fn();
  110. const user = userEvent.setup();
  111. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  112. // Pick "Urgent" (5) for the on_print_failed row.
  113. const sectionHeader = await screen.findByText(/ntfy priority/i);
  114. const sectionRoot = sectionHeader.closest('div')!;
  115. const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
  116. const select = within(failedRow).getByRole('combobox');
  117. await user.selectOptions(select, '5');
  118. await user.click(screen.getByRole('button', { name: /^save$/i }));
  119. await waitFor(() => expect(onClose).toHaveBeenCalled());
  120. expect(captured).not.toBeNull();
  121. const payload = captured as { config: Record<string, unknown> };
  122. expect(payload.config).toMatchObject({
  123. server: 'https://ntfy.sh',
  124. topic: 'bambuddy',
  125. event_priorities: { on_print_failed: 5 },
  126. });
  127. });
  128. it('pre-fills priorities from existing provider.config.event_priorities', async () => {
  129. const provider = buildProvider({
  130. config: {
  131. server: 'https://ntfy.sh',
  132. topic: 'bambuddy',
  133. event_priorities: { on_print_failed: 5, on_print_complete: 2 },
  134. },
  135. });
  136. render(<AddNotificationModal provider={provider} onClose={() => undefined} />);
  137. const sectionHeader = await screen.findByText(/ntfy priority/i);
  138. const sectionRoot = sectionHeader.closest('div')!;
  139. const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
  140. expect((within(failedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('5');
  141. const completeRow = within(sectionRoot).getByText('Complete').closest('div')!;
  142. expect((within(completeRow).getByRole('combobox') as HTMLSelectElement).value).toBe('2');
  143. // Stopped is enabled but has no override → defaults to 3.
  144. const stoppedRow = within(sectionRoot).getByText('Stopped').closest('div')!;
  145. expect((within(stoppedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('3');
  146. });
  147. it('drops events from the priority section when their toggle is disabled', async () => {
  148. const user = userEvent.setup();
  149. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  150. const sectionHeader = await screen.findByText(/ntfy priority/i);
  151. const sectionRoot = sectionHeader.closest('div')!;
  152. // Stopped is initially enabled → row visible.
  153. expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
  154. // Find the Stopped toggle in the events grid (a separate area). Its label
  155. // appears in the priority section AND the toggle grid; we need the toggle
  156. // one. The toggle is a sibling of the label inside an event-row div.
  157. const allStoppedNodes = screen.getAllByText('Stopped');
  158. // The first occurrence is in the Print Events grid; the second is in the
  159. // Priority section. Click the toggle next to the first one.
  160. const togglesGridStopped = allStoppedNodes[0];
  161. const toggleRow = togglesGridStopped.closest('div')!;
  162. const toggle = within(toggleRow).getByRole('switch');
  163. await user.click(toggle);
  164. // Row drops out of the priority section.
  165. await waitFor(() => {
  166. const stillSection = screen.getByText(/ntfy priority/i).closest('div')!;
  167. expect(within(stillSection).queryByText('Stopped')).not.toBeInTheDocument();
  168. });
  169. });
  170. it('omits event_priorities for non-ntfy providers on save', async () => {
  171. let captured: unknown = null;
  172. server.use(
  173. http.post('*/api/v1/notifications/', async ({ request }) => {
  174. captured = await request.json();
  175. return HttpResponse.json({ id: 99 });
  176. }),
  177. );
  178. const onClose = vi.fn();
  179. const user = userEvent.setup();
  180. render(<AddNotificationModal onClose={onClose} />);
  181. // Default new-provider type is email. Fill required fields and save.
  182. await user.type(screen.getByPlaceholderText(/My Notifications/i), 'Test');
  183. await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.example.com');
  184. const fromInputs = screen.getAllByPlaceholderText('your@email.com');
  185. await user.type(fromInputs[fromInputs.length - 1], 'me@example.com');
  186. await user.type(screen.getByPlaceholderText('recipient@email.com'), 'them@example.com');
  187. await user.click(screen.getByRole('button', { name: /^add$/i }));
  188. await waitFor(() => expect(onClose).toHaveBeenCalled());
  189. const payload = captured as { provider_type: string; config: Record<string, unknown> };
  190. expect(payload.provider_type).toBe('email');
  191. expect(payload.config).not.toHaveProperty('event_priorities');
  192. });
  193. });
  194. describe('AddNotificationModal — stock alert toggles', () => {
  195. it('renders Inventory Alerts section with both stock alert toggles', async () => {
  196. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  197. const section = await screen.findByText(/inventory alerts/i);
  198. const sectionRoot = section.closest('div')!;
  199. expect(section).toBeInTheDocument();
  200. expect(sectionRoot.textContent).toMatch(/reorder alert/i);
  201. expect(sectionRoot.textContent).toMatch(/stock break alert/i);
  202. });
  203. it('pre-fills toggles from existing provider values', async () => {
  204. render(
  205. <AddNotificationModal
  206. provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: false })}
  207. onClose={() => undefined}
  208. />,
  209. );
  210. await screen.findByText(/inventory alerts/i);
  211. // Reorder alert switch should be ON, break alert switch OFF
  212. const switches = screen.getAllByRole('switch');
  213. const reorderSwitch = switches.find((s) => {
  214. const row = s.closest('div');
  215. return row?.textContent?.match(/reorder alert/i);
  216. });
  217. const breakSwitch = switches.find((s) => {
  218. const row = s.closest('div');
  219. return row?.textContent?.match(/stock break alert/i);
  220. });
  221. expect(reorderSwitch).toHaveAttribute('aria-checked', 'true');
  222. expect(breakSwitch).toHaveAttribute('aria-checked', 'false');
  223. });
  224. it('persists on_stock_reorder_alert on save', async () => {
  225. let captured: unknown = null;
  226. server.use(
  227. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  228. captured = await request.json();
  229. return HttpResponse.json({ id: 1 });
  230. }),
  231. );
  232. const onClose = vi.fn();
  233. const user = userEvent.setup();
  234. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  235. await screen.findByText(/inventory alerts/i);
  236. // Enable the reorder alert toggle
  237. const switches = screen.getAllByRole('switch');
  238. const reorderSwitch = switches.find((s) => {
  239. const row = s.closest('div');
  240. return row?.textContent?.match(/reorder alert/i);
  241. })!;
  242. await user.click(reorderSwitch);
  243. await user.click(screen.getByRole('button', { name: /^save$/i }));
  244. await waitFor(() => expect(onClose).toHaveBeenCalled());
  245. const payload = captured as Record<string, unknown>;
  246. expect(payload.on_stock_reorder_alert).toBe(true);
  247. });
  248. it('persists on_stock_break_alert on save', async () => {
  249. let captured: unknown = null;
  250. server.use(
  251. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  252. captured = await request.json();
  253. return HttpResponse.json({ id: 1 });
  254. }),
  255. );
  256. const onClose = vi.fn();
  257. const user = userEvent.setup();
  258. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  259. await screen.findByText(/inventory alerts/i);
  260. const switches = screen.getAllByRole('switch');
  261. const breakSwitch = switches.find((s) => {
  262. const row = s.closest('div');
  263. return row?.textContent?.match(/stock break alert/i);
  264. })!;
  265. await user.click(breakSwitch);
  266. await user.click(screen.getByRole('button', { name: /^save$/i }));
  267. await waitFor(() => expect(onClose).toHaveBeenCalled());
  268. const payload = captured as Record<string, unknown>;
  269. expect(payload.on_stock_break_alert).toBe(true);
  270. });
  271. it('stock alert events appear in ntfy priority section when enabled', async () => {
  272. const user = userEvent.setup();
  273. render(
  274. <AddNotificationModal
  275. provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: true })}
  276. onClose={() => undefined}
  277. />,
  278. );
  279. const priorityHeader = await screen.findByText(/ntfy priority/i);
  280. const priorityRoot = priorityHeader.closest('div')!;
  281. // Both stock alert events should appear in the priority list since they are enabled
  282. expect(within(priorityRoot).getByText('Reorder Alert')).toBeInTheDocument();
  283. expect(within(priorityRoot).getByText('Stock Break Alert')).toBeInTheDocument();
  284. void user; // referenced to avoid unused-var lint warning
  285. });
  286. });
  287. describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
  288. it('renders the toggle in the Printer Status section', async () => {
  289. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  290. expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
  291. });
  292. it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
  293. let captured: Record<string, unknown> | null = null;
  294. server.use(
  295. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  296. captured = (await request.json()) as Record<string, unknown>;
  297. return HttpResponse.json({ id: 1 });
  298. }),
  299. );
  300. const onClose = vi.fn();
  301. const user = userEvent.setup();
  302. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  303. const label = await screen.findByText('AI Failure Detection');
  304. const row = label.closest('div.flex')!;
  305. const toggle = within(row).getByRole('switch');
  306. await user.click(toggle);
  307. await user.click(screen.getByRole('button', { name: /^save$/i }));
  308. await waitFor(() => expect(onClose).toHaveBeenCalled());
  309. expect(captured).not.toBeNull();
  310. expect(captured!.on_ai_failure_detection).toBe(true);
  311. // Critical regression guard: don't accidentally flip the legacy multiplexed field.
  312. expect(captured!.on_printer_error).toBe(false);
  313. });
  314. it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
  315. render(
  316. <AddNotificationModal
  317. provider={buildProvider({ on_ai_failure_detection: true })}
  318. onClose={() => undefined}
  319. />,
  320. );
  321. const priorityHeader = await screen.findByText(/ntfy priority/i);
  322. const priorityRoot = priorityHeader.closest('div')!;
  323. expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
  324. });
  325. });