AddNotificationModal.test.tsx 13 KB

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