AddNotificationModal.test.tsx 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
  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_plate_clear_required: false,
  49. on_bed_cooled: false,
  50. on_first_layer_complete: false,
  51. on_queue_job_added: false,
  52. on_queue_job_assigned: false,
  53. on_queue_job_started: false,
  54. on_queue_job_waiting: true,
  55. on_queue_job_skipped: true,
  56. on_queue_job_failed: true,
  57. on_queue_completed: false,
  58. on_stock_reorder_alert: false,
  59. on_stock_break_alert: false,
  60. quiet_hours_enabled: false,
  61. quiet_hours_start: null,
  62. quiet_hours_end: null,
  63. daily_digest_enabled: false,
  64. daily_digest_time: null,
  65. printer_id: null,
  66. last_success: null,
  67. last_error: null,
  68. last_error_at: null,
  69. created_at: '2026-04-25T00:00:00Z',
  70. updated_at: '2026-04-25T00:00:00Z',
  71. ...overrides,
  72. };
  73. }
  74. describe('AddNotificationModal — ntfy Priority (#990)', () => {
  75. it('renders the ntfy Priority section listing only enabled events', async () => {
  76. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  77. // Section header present, then scope every label query to it — the same
  78. // labels also appear in the toggle grid above.
  79. const sectionHeader = await screen.findByText(/ntfy priority/i);
  80. const sectionRoot = sectionHeader.closest('div')!;
  81. // Defaults from buildProvider(): complete + failed + stopped enabled;
  82. // start + progress + offline disabled. The priority list mirrors that.
  83. expect(within(sectionRoot).getByText('Complete')).toBeInTheDocument();
  84. expect(within(sectionRoot).getByText('Failed')).toBeInTheDocument();
  85. expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
  86. // Disabled events must not appear in the priority block.
  87. expect(within(sectionRoot).queryByText('Start')).not.toBeInTheDocument();
  88. expect(within(sectionRoot).queryByText('Progress')).not.toBeInTheDocument();
  89. expect(within(sectionRoot).queryByText('Offline')).not.toBeInTheDocument();
  90. });
  91. it('does not render the Priority section for non-ntfy providers', async () => {
  92. render(
  93. <AddNotificationModal
  94. provider={buildProvider({ provider_type: 'telegram', config: { bot_token: 'x', chat_id: 'y' } })}
  95. onClose={() => undefined}
  96. />,
  97. );
  98. // Wait for the modal to settle.
  99. await screen.findByDisplayValue('My ntfy');
  100. expect(screen.queryByText(/ntfy priority/i)).not.toBeInTheDocument();
  101. });
  102. it('persists event_priorities into config on save', async () => {
  103. let captured: unknown = null;
  104. server.use(
  105. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  106. captured = await request.json();
  107. return HttpResponse.json({ id: 1 });
  108. }),
  109. );
  110. const onClose = vi.fn();
  111. const user = userEvent.setup();
  112. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  113. // Pick "Urgent" (5) for the on_print_failed row.
  114. const sectionHeader = await screen.findByText(/ntfy priority/i);
  115. const sectionRoot = sectionHeader.closest('div')!;
  116. const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
  117. const select = within(failedRow).getByRole('combobox');
  118. await user.selectOptions(select, '5');
  119. await user.click(screen.getByRole('button', { name: /^save$/i }));
  120. await waitFor(() => expect(onClose).toHaveBeenCalled());
  121. expect(captured).not.toBeNull();
  122. const payload = captured as { config: Record<string, unknown> };
  123. expect(payload.config).toMatchObject({
  124. server: 'https://ntfy.sh',
  125. topic: 'bambuddy',
  126. event_priorities: { on_print_failed: 5 },
  127. });
  128. });
  129. it('pre-fills priorities from existing provider.config.event_priorities', async () => {
  130. const provider = buildProvider({
  131. config: {
  132. server: 'https://ntfy.sh',
  133. topic: 'bambuddy',
  134. event_priorities: { on_print_failed: 5, on_print_complete: 2 },
  135. },
  136. });
  137. render(<AddNotificationModal provider={provider} onClose={() => undefined} />);
  138. const sectionHeader = await screen.findByText(/ntfy priority/i);
  139. const sectionRoot = sectionHeader.closest('div')!;
  140. const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
  141. expect((within(failedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('5');
  142. const completeRow = within(sectionRoot).getByText('Complete').closest('div')!;
  143. expect((within(completeRow).getByRole('combobox') as HTMLSelectElement).value).toBe('2');
  144. // Stopped is enabled but has no override → defaults to 3.
  145. const stoppedRow = within(sectionRoot).getByText('Stopped').closest('div')!;
  146. expect((within(stoppedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('3');
  147. });
  148. it('drops events from the priority section when their toggle is disabled', async () => {
  149. const user = userEvent.setup();
  150. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  151. const sectionHeader = await screen.findByText(/ntfy priority/i);
  152. const sectionRoot = sectionHeader.closest('div')!;
  153. // Stopped is initially enabled → row visible.
  154. expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
  155. // Find the Stopped toggle in the events grid (a separate area). Its label
  156. // appears in the priority section AND the toggle grid; we need the toggle
  157. // one. The toggle is a sibling of the label inside an event-row div.
  158. const allStoppedNodes = screen.getAllByText('Stopped');
  159. // The first occurrence is in the Print Events grid; the second is in the
  160. // Priority section. Click the toggle next to the first one.
  161. const togglesGridStopped = allStoppedNodes[0];
  162. const toggleRow = togglesGridStopped.closest('div')!;
  163. const toggle = within(toggleRow).getByRole('switch');
  164. await user.click(toggle);
  165. // Row drops out of the priority section.
  166. await waitFor(() => {
  167. const stillSection = screen.getByText(/ntfy priority/i).closest('div')!;
  168. expect(within(stillSection).queryByText('Stopped')).not.toBeInTheDocument();
  169. });
  170. });
  171. it('omits event_priorities for non-ntfy providers on save', async () => {
  172. let captured: unknown = null;
  173. server.use(
  174. http.post('*/api/v1/notifications/', async ({ request }) => {
  175. captured = await request.json();
  176. return HttpResponse.json({ id: 99 });
  177. }),
  178. );
  179. const onClose = vi.fn();
  180. const user = userEvent.setup();
  181. render(<AddNotificationModal onClose={onClose} />);
  182. // Default new-provider type is email. Fill required fields and save.
  183. await user.type(screen.getByPlaceholderText(/My Notifications/i), 'Test');
  184. await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.example.com');
  185. const fromInputs = screen.getAllByPlaceholderText('your@email.com');
  186. await user.type(fromInputs[fromInputs.length - 1], 'me@example.com');
  187. await user.type(screen.getByPlaceholderText('recipient@email.com'), 'them@example.com');
  188. await user.click(screen.getByRole('button', { name: /^add$/i }));
  189. await waitFor(() => expect(onClose).toHaveBeenCalled());
  190. const payload = captured as { provider_type: string; config: Record<string, unknown> };
  191. expect(payload.provider_type).toBe('email');
  192. expect(payload.config).not.toHaveProperty('event_priorities');
  193. });
  194. });
  195. describe('AddNotificationModal — plate clear required (#2525)', () => {
  196. it('renders the toggle off by default', async () => {
  197. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  198. await screen.findByDisplayValue('My ntfy');
  199. const toggle = screen
  200. .getAllByRole('switch')
  201. .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i));
  202. expect(toggle).toBeDefined();
  203. expect(toggle).toHaveAttribute('aria-checked', 'false');
  204. });
  205. it('pre-fills the toggle from the existing provider value', async () => {
  206. render(
  207. <AddNotificationModal
  208. provider={buildProvider({ on_plate_clear_required: true })}
  209. onClose={() => undefined}
  210. />,
  211. );
  212. await screen.findByDisplayValue('My ntfy');
  213. const toggle = screen
  214. .getAllByRole('switch')
  215. .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
  216. expect(toggle).toHaveAttribute('aria-checked', 'true');
  217. });
  218. it('persists on_plate_clear_required on save', async () => {
  219. let captured: unknown = null;
  220. server.use(
  221. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  222. captured = await request.json();
  223. return HttpResponse.json({ id: 1 });
  224. }),
  225. );
  226. const onClose = vi.fn();
  227. const user = userEvent.setup();
  228. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  229. await screen.findByDisplayValue('My ntfy');
  230. const toggle = screen
  231. .getAllByRole('switch')
  232. .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
  233. await user.click(toggle);
  234. await user.click(screen.getByRole('button', { name: /^save$/i }));
  235. await waitFor(() => expect(onClose).toHaveBeenCalled());
  236. const payload = captured as Record<string, unknown>;
  237. expect(payload.on_plate_clear_required).toBe(true);
  238. });
  239. it('lists the event in the ntfy priority section once enabled', async () => {
  240. render(
  241. <AddNotificationModal
  242. provider={buildProvider({ on_plate_clear_required: true })}
  243. onClose={() => undefined}
  244. />,
  245. );
  246. const sectionHeader = await screen.findByText(/ntfy priority/i);
  247. const sectionRoot = sectionHeader.closest('div')!;
  248. expect(within(sectionRoot).getByText(/plate clear required/i)).toBeInTheDocument();
  249. });
  250. });
  251. describe('AddNotificationModal — stock alert toggles', () => {
  252. it('renders Inventory Alerts section with both stock alert toggles', async () => {
  253. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  254. const section = await screen.findByText(/inventory alerts/i);
  255. const sectionRoot = section.closest('div')!;
  256. expect(section).toBeInTheDocument();
  257. expect(sectionRoot.textContent).toMatch(/reorder alert/i);
  258. expect(sectionRoot.textContent).toMatch(/stock break alert/i);
  259. });
  260. it('pre-fills toggles from existing provider values', async () => {
  261. render(
  262. <AddNotificationModal
  263. provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: false })}
  264. onClose={() => undefined}
  265. />,
  266. );
  267. await screen.findByText(/inventory alerts/i);
  268. // Reorder alert switch should be ON, break alert switch OFF
  269. const switches = screen.getAllByRole('switch');
  270. const reorderSwitch = switches.find((s) => {
  271. const row = s.closest('div');
  272. return row?.textContent?.match(/reorder alert/i);
  273. });
  274. const breakSwitch = switches.find((s) => {
  275. const row = s.closest('div');
  276. return row?.textContent?.match(/stock break alert/i);
  277. });
  278. expect(reorderSwitch).toHaveAttribute('aria-checked', 'true');
  279. expect(breakSwitch).toHaveAttribute('aria-checked', 'false');
  280. });
  281. it('persists on_stock_reorder_alert on save', async () => {
  282. let captured: unknown = null;
  283. server.use(
  284. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  285. captured = await request.json();
  286. return HttpResponse.json({ id: 1 });
  287. }),
  288. );
  289. const onClose = vi.fn();
  290. const user = userEvent.setup();
  291. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  292. await screen.findByText(/inventory alerts/i);
  293. // Enable the reorder alert toggle
  294. const switches = screen.getAllByRole('switch');
  295. const reorderSwitch = switches.find((s) => {
  296. const row = s.closest('div');
  297. return row?.textContent?.match(/reorder alert/i);
  298. })!;
  299. await user.click(reorderSwitch);
  300. await user.click(screen.getByRole('button', { name: /^save$/i }));
  301. await waitFor(() => expect(onClose).toHaveBeenCalled());
  302. const payload = captured as Record<string, unknown>;
  303. expect(payload.on_stock_reorder_alert).toBe(true);
  304. });
  305. it('persists on_stock_break_alert on save', async () => {
  306. let captured: unknown = null;
  307. server.use(
  308. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  309. captured = await request.json();
  310. return HttpResponse.json({ id: 1 });
  311. }),
  312. );
  313. const onClose = vi.fn();
  314. const user = userEvent.setup();
  315. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  316. await screen.findByText(/inventory alerts/i);
  317. const switches = screen.getAllByRole('switch');
  318. const breakSwitch = switches.find((s) => {
  319. const row = s.closest('div');
  320. return row?.textContent?.match(/stock break alert/i);
  321. })!;
  322. await user.click(breakSwitch);
  323. await user.click(screen.getByRole('button', { name: /^save$/i }));
  324. await waitFor(() => expect(onClose).toHaveBeenCalled());
  325. const payload = captured as Record<string, unknown>;
  326. expect(payload.on_stock_break_alert).toBe(true);
  327. });
  328. it('stock alert events appear in ntfy priority section when enabled', async () => {
  329. const user = userEvent.setup();
  330. render(
  331. <AddNotificationModal
  332. provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: true })}
  333. onClose={() => undefined}
  334. />,
  335. );
  336. const priorityHeader = await screen.findByText(/ntfy priority/i);
  337. const priorityRoot = priorityHeader.closest('div')!;
  338. // Both stock alert events should appear in the priority list since they are enabled
  339. expect(within(priorityRoot).getByText('Reorder Alert')).toBeInTheDocument();
  340. expect(within(priorityRoot).getByText('Stock Break Alert')).toBeInTheDocument();
  341. void user; // referenced to avoid unused-var lint warning
  342. });
  343. });
  344. describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
  345. it('renders the toggle in the Printer Status section', async () => {
  346. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  347. expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
  348. });
  349. it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
  350. let captured: Record<string, unknown> | null = null;
  351. server.use(
  352. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  353. captured = (await request.json()) as Record<string, unknown>;
  354. return HttpResponse.json({ id: 1 });
  355. }),
  356. );
  357. const onClose = vi.fn();
  358. const user = userEvent.setup();
  359. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  360. const label = await screen.findByText('AI Failure Detection');
  361. const row = label.closest('div.flex')!;
  362. const toggle = within(row).getByRole('switch');
  363. await user.click(toggle);
  364. await user.click(screen.getByRole('button', { name: /^save$/i }));
  365. await waitFor(() => expect(onClose).toHaveBeenCalled());
  366. expect(captured).not.toBeNull();
  367. expect(captured!.on_ai_failure_detection).toBe(true);
  368. // Critical regression guard: don't accidentally flip the legacy multiplexed field.
  369. expect(captured!.on_printer_error).toBe(false);
  370. });
  371. it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
  372. render(
  373. <AddNotificationModal
  374. provider={buildProvider({ on_ai_failure_detection: true })}
  375. onClose={() => undefined}
  376. />,
  377. );
  378. const priorityHeader = await screen.findByText(/ntfy priority/i);
  379. const priorityRoot = priorityHeader.closest('div')!;
  380. expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
  381. });
  382. });
  383. describe('AddNotificationModal — Home Assistant custom data (#1441)', () => {
  384. const haProvider = () =>
  385. buildProvider({
  386. provider_type: 'homeassistant',
  387. config: { service: 'notify.mobile_app_myphone' },
  388. });
  389. it('renders the Data (JSON) textarea for the homeassistant provider', async () => {
  390. render(<AddNotificationModal provider={haProvider()} onClose={() => undefined} />);
  391. await screen.findByDisplayValue('My ntfy');
  392. expect(screen.getByText(/data \(json, optional\)/i)).toBeInTheDocument();
  393. expect(screen.getByPlaceholderText(/"priority": "high"/)).toBeInTheDocument();
  394. });
  395. it('rejects malformed JSON in the Data field on save', async () => {
  396. const patchSpy = vi.fn();
  397. server.use(
  398. http.patch('*/api/v1/notifications/1', () => {
  399. patchSpy();
  400. return HttpResponse.json({ id: 1 });
  401. }),
  402. );
  403. const onClose = vi.fn();
  404. const user = userEvent.setup();
  405. render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
  406. const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
  407. await user.type(textarea, '{{priority: high}');
  408. await user.click(screen.getByRole('button', { name: /^save$/i }));
  409. expect(await screen.findByText(/must be a valid JSON object/i)).toBeInTheDocument();
  410. expect(patchSpy).not.toHaveBeenCalled();
  411. expect(onClose).not.toHaveBeenCalled();
  412. });
  413. it('round-trips valid Data JSON into config on save', async () => {
  414. let captured: { config: Record<string, unknown> } | null = null;
  415. server.use(
  416. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  417. captured = (await request.json()) as { config: Record<string, unknown> };
  418. return HttpResponse.json({ id: 1 });
  419. }),
  420. );
  421. const onClose = vi.fn();
  422. const user = userEvent.setup();
  423. render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
  424. const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
  425. await user.type(textarea, '{{"ttl": 0}');
  426. await user.click(screen.getByRole('button', { name: /^save$/i }));
  427. await waitFor(() => expect(onClose).toHaveBeenCalled());
  428. expect(captured).not.toBeNull();
  429. expect(captured!.config).toMatchObject({
  430. service: 'notify.mobile_app_myphone',
  431. data: '{"ttl": 0}',
  432. });
  433. });
  434. });
  435. describe('AddNotificationModal — Bark provider (#1495)', () => {
  436. it('offers Bark in the provider select and renders its config fields', async () => {
  437. render(
  438. <AddNotificationModal
  439. provider={buildProvider({ provider_type: 'bark', config: { device_key: 'abc123' } })}
  440. onClose={() => undefined}
  441. />,
  442. );
  443. await screen.findByDisplayValue('My ntfy');
  444. expect(screen.getByRole('option', { name: 'Bark' })).toBeInTheDocument();
  445. expect(screen.getByText(/device key/i)).toBeInTheDocument();
  446. expect(screen.getByPlaceholderText('https://api.day.app')).toBeInTheDocument();
  447. expect(screen.getByText(/interruption level/i)).toBeInTheDocument();
  448. });
  449. it('round-trips Bark options into config on save', async () => {
  450. let captured: { config: Record<string, unknown> } | null = null;
  451. server.use(
  452. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  453. captured = (await request.json()) as { config: Record<string, unknown> };
  454. return HttpResponse.json({ id: 1 });
  455. }),
  456. );
  457. const onClose = vi.fn();
  458. const user = userEvent.setup();
  459. render(
  460. <AddNotificationModal
  461. provider={buildProvider({ provider_type: 'bark', config: { device_key: 'abc123' } })}
  462. onClose={onClose}
  463. />,
  464. );
  465. const groupInput = await screen.findByPlaceholderText('Bambuddy');
  466. await user.type(groupInput, 'Printers');
  467. const levelRow = screen.getByText(/interruption level/i).closest('div')!;
  468. await user.selectOptions(within(levelRow).getByRole('combobox'), 'critical');
  469. await user.click(screen.getByRole('button', { name: /^save$/i }));
  470. await waitFor(() => expect(onClose).toHaveBeenCalled());
  471. expect(captured).not.toBeNull();
  472. expect(captured!.config).toMatchObject({
  473. device_key: 'abc123',
  474. group: 'Printers',
  475. level: 'critical',
  476. });
  477. });
  478. });
  479. describe('AddNotificationModal — Telegram forum topic (#1518)', () => {
  480. const telegramProvider = (config: Record<string, unknown> = { bot_token: 'x', chat_id: '-100123' }) =>
  481. buildProvider({ provider_type: 'telegram', config });
  482. it('offers the Forum Topic ID field as optional for telegram', async () => {
  483. render(<AddNotificationModal provider={telegramProvider()} onClose={() => undefined} />);
  484. const label = await screen.findByText(/forum topic id/i);
  485. // Required fields are marked with a trailing asterisk — this one must not be.
  486. expect(label.textContent).not.toContain('*');
  487. expect(screen.getByText(/leave empty for the general topic/i)).toBeInTheDocument();
  488. });
  489. it('does not offer the field for other providers', async () => {
  490. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  491. await screen.findByDisplayValue('My ntfy');
  492. expect(screen.queryByText(/forum topic id/i)).not.toBeInTheDocument();
  493. });
  494. it('round-trips the topic id into config on save', async () => {
  495. let captured: { config: Record<string, unknown> } | null = null;
  496. server.use(
  497. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  498. captured = (await request.json()) as { config: Record<string, unknown> };
  499. return HttpResponse.json({ id: 1 });
  500. }),
  501. );
  502. const onClose = vi.fn();
  503. const user = userEvent.setup();
  504. render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
  505. await user.type(await screen.findByPlaceholderText('123'), '25');
  506. await user.click(screen.getByRole('button', { name: /^save$/i }));
  507. await waitFor(() => expect(onClose).toHaveBeenCalled());
  508. expect(captured).not.toBeNull();
  509. expect(captured!.config).toMatchObject({ chat_id: '-100123', message_thread_id: '25' });
  510. });
  511. it('keeps the config free of the key when the field is left empty', async () => {
  512. let captured: { config: Record<string, unknown> } | null = null;
  513. server.use(
  514. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  515. captured = (await request.json()) as { config: Record<string, unknown> };
  516. return HttpResponse.json({ id: 1 });
  517. }),
  518. );
  519. const onClose = vi.fn();
  520. const user = userEvent.setup();
  521. render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
  522. await screen.findByPlaceholderText('123');
  523. await user.click(screen.getByRole('button', { name: /^save$/i }));
  524. await waitFor(() => expect(onClose).toHaveBeenCalled());
  525. expect(captured!.config).not.toHaveProperty('message_thread_id');
  526. });
  527. it('blocks save on a non-numeric topic id', async () => {
  528. // Reaches the form via a config written by the API rather than the picker —
  529. // the number input itself already filters most junk out.
  530. let patched = false;
  531. server.use(
  532. http.patch('*/api/v1/notifications/1', async () => {
  533. patched = true;
  534. return HttpResponse.json({ id: 1 });
  535. }),
  536. );
  537. const onClose = vi.fn();
  538. const user = userEvent.setup();
  539. render(
  540. <AddNotificationModal
  541. provider={telegramProvider({ bot_token: 'x', chat_id: '-100123', message_thread_id: 'General' })}
  542. onClose={onClose}
  543. />,
  544. );
  545. await screen.findByText(/forum topic id/i);
  546. await user.click(screen.getByRole('button', { name: /^save$/i }));
  547. expect(await screen.findByText(/forum topic id must be a number/i)).toBeInTheDocument();
  548. expect(patched).toBe(false);
  549. expect(onClose).not.toHaveBeenCalled();
  550. });
  551. });