AddNotificationModal.test.tsx 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090
  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 { NotificationProviderCard } from '../../components/NotificationProviderCard';
  21. import type { NotificationProvider } from '../../api/client';
  22. afterEach(() => {
  23. server.resetHandlers();
  24. vi.restoreAllMocks();
  25. });
  26. function buildProvider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
  27. return {
  28. id: 1,
  29. name: 'My ntfy',
  30. provider_type: 'ntfy',
  31. enabled: true,
  32. config: { server: 'https://ntfy.sh', topic: 'bambuddy' },
  33. on_print_start: false,
  34. on_print_complete: true,
  35. on_print_failed: true,
  36. on_print_stopped: true,
  37. on_print_progress: false,
  38. on_print_missing_spool_assignment: false,
  39. on_printer_offline: false,
  40. on_printer_error: false,
  41. on_ai_failure_detection: false,
  42. on_filament_low: false,
  43. on_maintenance_due: false,
  44. on_ams_humidity_high: false,
  45. on_ams_temperature_high: false,
  46. on_ams_ht_humidity_high: false,
  47. on_ams_ht_temperature_high: false,
  48. on_plate_not_empty: true,
  49. on_plate_clear_required: false,
  50. on_bed_cooled: false,
  51. on_first_layer_complete: false,
  52. on_queue_job_added: false,
  53. on_queue_job_assigned: false,
  54. on_queue_job_started: false,
  55. on_queue_job_waiting: true,
  56. on_queue_job_skipped: true,
  57. on_queue_job_failed: true,
  58. on_queue_completed: false,
  59. on_stock_reorder_alert: false,
  60. on_stock_break_alert: false,
  61. quiet_hours_enabled: false,
  62. quiet_hours_start: null,
  63. quiet_hours_end: null,
  64. daily_digest_enabled: false,
  65. daily_digest_time: null,
  66. printer_id: null,
  67. last_success: null,
  68. last_error: null,
  69. last_error_at: null,
  70. created_at: '2026-04-25T00:00:00Z',
  71. updated_at: '2026-04-25T00:00:00Z',
  72. ...overrides,
  73. };
  74. }
  75. describe('AddNotificationModal — ntfy Priority (#990)', () => {
  76. it('renders the ntfy Priority section listing only enabled events', async () => {
  77. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  78. // Section header present, then scope every label query to it — the same
  79. // labels also appear in the toggle grid above.
  80. const sectionHeader = await screen.findByText(/ntfy priority/i);
  81. const sectionRoot = sectionHeader.closest('div')!;
  82. // Defaults from buildProvider(): complete + failed + stopped enabled;
  83. // start + progress + offline disabled. The priority list mirrors that.
  84. expect(within(sectionRoot).getByText('Complete')).toBeInTheDocument();
  85. expect(within(sectionRoot).getByText('Failed')).toBeInTheDocument();
  86. expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
  87. // Disabled events must not appear in the priority block.
  88. expect(within(sectionRoot).queryByText('Start')).not.toBeInTheDocument();
  89. expect(within(sectionRoot).queryByText('Progress')).not.toBeInTheDocument();
  90. expect(within(sectionRoot).queryByText('Offline')).not.toBeInTheDocument();
  91. });
  92. it('does not render the Priority section for non-ntfy providers', async () => {
  93. render(
  94. <AddNotificationModal
  95. provider={buildProvider({ provider_type: 'telegram', config: { bot_token: 'x', chat_id: 'y' } })}
  96. onClose={() => undefined}
  97. />,
  98. );
  99. // Wait for the modal to settle.
  100. await screen.findByDisplayValue('My ntfy');
  101. expect(screen.queryByText(/ntfy priority/i)).not.toBeInTheDocument();
  102. });
  103. it('persists event_priorities into config on save', async () => {
  104. let captured: unknown = null;
  105. server.use(
  106. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  107. captured = await request.json();
  108. return HttpResponse.json({ id: 1 });
  109. }),
  110. );
  111. const onClose = vi.fn();
  112. const user = userEvent.setup();
  113. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  114. // Pick "Urgent" (5) for the on_print_failed row.
  115. const sectionHeader = await screen.findByText(/ntfy priority/i);
  116. const sectionRoot = sectionHeader.closest('div')!;
  117. const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
  118. const select = within(failedRow).getByRole('combobox');
  119. await user.selectOptions(select, '5');
  120. await user.click(screen.getByRole('button', { name: /^save$/i }));
  121. await waitFor(() => expect(onClose).toHaveBeenCalled());
  122. expect(captured).not.toBeNull();
  123. const payload = captured as { config: Record<string, unknown> };
  124. expect(payload.config).toMatchObject({
  125. server: 'https://ntfy.sh',
  126. topic: 'bambuddy',
  127. event_priorities: { on_print_failed: 5 },
  128. });
  129. });
  130. it('pre-fills priorities from existing provider.config.event_priorities', async () => {
  131. const provider = buildProvider({
  132. config: {
  133. server: 'https://ntfy.sh',
  134. topic: 'bambuddy',
  135. event_priorities: { on_print_failed: 5, on_print_complete: 2 },
  136. },
  137. });
  138. render(<AddNotificationModal provider={provider} onClose={() => undefined} />);
  139. const sectionHeader = await screen.findByText(/ntfy priority/i);
  140. const sectionRoot = sectionHeader.closest('div')!;
  141. const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
  142. expect((within(failedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('5');
  143. const completeRow = within(sectionRoot).getByText('Complete').closest('div')!;
  144. expect((within(completeRow).getByRole('combobox') as HTMLSelectElement).value).toBe('2');
  145. // Stopped is enabled but has no override → defaults to 3.
  146. const stoppedRow = within(sectionRoot).getByText('Stopped').closest('div')!;
  147. expect((within(stoppedRow).getByRole('combobox') as HTMLSelectElement).value).toBe('3');
  148. });
  149. it('drops events from the priority section when their toggle is disabled', async () => {
  150. const user = userEvent.setup();
  151. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  152. const sectionHeader = await screen.findByText(/ntfy priority/i);
  153. const sectionRoot = sectionHeader.closest('div')!;
  154. // Stopped is initially enabled → row visible.
  155. expect(within(sectionRoot).getByText('Stopped')).toBeInTheDocument();
  156. // Find the Stopped toggle in the events grid (a separate area). Its label
  157. // appears in the priority section AND the toggle grid; we need the toggle
  158. // one. The toggle is a sibling of the label inside an event-row div.
  159. const allStoppedNodes = screen.getAllByText('Stopped');
  160. // The first occurrence is in the Print Events grid; the second is in the
  161. // Priority section. Click the toggle next to the first one.
  162. const togglesGridStopped = allStoppedNodes[0];
  163. const toggleRow = togglesGridStopped.closest('div')!;
  164. const toggle = within(toggleRow).getByRole('switch');
  165. await user.click(toggle);
  166. // Row drops out of the priority section.
  167. await waitFor(() => {
  168. const stillSection = screen.getByText(/ntfy priority/i).closest('div')!;
  169. expect(within(stillSection).queryByText('Stopped')).not.toBeInTheDocument();
  170. });
  171. });
  172. it('omits event_priorities for non-ntfy providers on save', async () => {
  173. let captured: unknown = null;
  174. server.use(
  175. http.post('*/api/v1/notifications/', async ({ request }) => {
  176. captured = await request.json();
  177. return HttpResponse.json({ id: 99 });
  178. }),
  179. );
  180. const onClose = vi.fn();
  181. const user = userEvent.setup();
  182. render(<AddNotificationModal onClose={onClose} />);
  183. // Default new-provider type is email. Fill required fields and save.
  184. await user.type(screen.getByPlaceholderText(/My Notifications/i), 'Test');
  185. await user.type(screen.getByPlaceholderText('smtp.gmail.com'), 'smtp.example.com');
  186. const fromInputs = screen.getAllByPlaceholderText('your@email.com');
  187. await user.type(fromInputs[fromInputs.length - 1], 'me@example.com');
  188. await user.type(screen.getByPlaceholderText('recipient@email.com'), 'them@example.com');
  189. await user.click(screen.getByRole('button', { name: /^add$/i }));
  190. await waitFor(() => expect(onClose).toHaveBeenCalled());
  191. const payload = captured as { provider_type: string; config: Record<string, unknown> };
  192. expect(payload.provider_type).toBe('email');
  193. expect(payload.config).not.toHaveProperty('event_priorities');
  194. });
  195. });
  196. describe('AddNotificationModal — Gotify (#2743)', () => {
  197. const gotifyProvider = () =>
  198. buildProvider({
  199. name: 'My Gotify',
  200. provider_type: 'gotify',
  201. config: { server: 'https://gotify.example.com', app_token: 'Atoken' },
  202. });
  203. it('shows the server and token fields, with the token masked', async () => {
  204. render(<AddNotificationModal provider={gotifyProvider()} onClose={() => undefined} />);
  205. expect(await screen.findByDisplayValue('https://gotify.example.com')).toBeInTheDocument();
  206. const token = screen.getByDisplayValue('Atoken');
  207. expect(token).toHaveAttribute('type', 'password');
  208. });
  209. it('renders its own priority section and saves event_priorities', async () => {
  210. let captured: unknown = null;
  211. server.use(
  212. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  213. captured = await request.json();
  214. return HttpResponse.json({ id: 1 });
  215. }),
  216. );
  217. const onClose = vi.fn();
  218. const user = userEvent.setup();
  219. render(<AddNotificationModal provider={gotifyProvider()} onClose={onClose} />);
  220. const sectionHeader = await screen.findByText(/gotify priority/i);
  221. expect(screen.queryByText(/ntfy priority/i)).not.toBeInTheDocument();
  222. const sectionRoot = sectionHeader.closest('div')!;
  223. const failedRow = within(sectionRoot).getByText('Failed').closest('div')!;
  224. await user.selectOptions(within(failedRow).getByRole('combobox'), '4');
  225. await user.click(screen.getByRole('button', { name: /^save$/i }));
  226. await waitFor(() => expect(onClose).toHaveBeenCalled());
  227. const payload = captured as { config: Record<string, unknown> };
  228. expect(payload.config).toMatchObject({
  229. server: 'https://gotify.example.com',
  230. app_token: 'Atoken',
  231. event_priorities: { on_print_failed: 4 },
  232. });
  233. });
  234. });
  235. describe('AddNotificationModal — provider type list', () => {
  236. it('lists the providers alphabetically by their label', async () => {
  237. render(<AddNotificationModal onClose={() => undefined} />);
  238. const select = (await screen.findByDisplayValue('Email')) as HTMLSelectElement;
  239. const labels = Array.from(select.options).map((o) => o.textContent);
  240. expect(labels).toEqual([
  241. 'Bark',
  242. 'CallMeBot/WhatsApp',
  243. 'Discord',
  244. 'Email',
  245. 'Gotify',
  246. 'Home Assistant',
  247. 'Notify!',
  248. 'ntfy',
  249. 'Pushover',
  250. 'Telegram',
  251. 'Webhook',
  252. ]);
  253. });
  254. });
  255. describe('AddNotificationModal — plate clear required (#2525)', () => {
  256. it('renders the toggle off by default', async () => {
  257. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  258. await screen.findByDisplayValue('My ntfy');
  259. const toggle = screen
  260. .getAllByRole('switch')
  261. .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i));
  262. expect(toggle).toBeDefined();
  263. expect(toggle).toHaveAttribute('aria-checked', 'false');
  264. });
  265. it('pre-fills the toggle from the existing provider value', async () => {
  266. render(
  267. <AddNotificationModal
  268. provider={buildProvider({ on_plate_clear_required: true })}
  269. onClose={() => undefined}
  270. />,
  271. );
  272. await screen.findByDisplayValue('My ntfy');
  273. const toggle = screen
  274. .getAllByRole('switch')
  275. .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
  276. expect(toggle).toHaveAttribute('aria-checked', 'true');
  277. });
  278. it('persists on_plate_clear_required on save', async () => {
  279. let captured: unknown = null;
  280. server.use(
  281. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  282. captured = await request.json();
  283. return HttpResponse.json({ id: 1 });
  284. }),
  285. );
  286. const onClose = vi.fn();
  287. const user = userEvent.setup();
  288. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  289. await screen.findByDisplayValue('My ntfy');
  290. const toggle = screen
  291. .getAllByRole('switch')
  292. .find((s) => s.closest('div')?.textContent?.match(/plate clear required/i))!;
  293. await user.click(toggle);
  294. await user.click(screen.getByRole('button', { name: /^save$/i }));
  295. await waitFor(() => expect(onClose).toHaveBeenCalled());
  296. const payload = captured as Record<string, unknown>;
  297. expect(payload.on_plate_clear_required).toBe(true);
  298. });
  299. it('lists the event in the ntfy priority section once enabled', async () => {
  300. render(
  301. <AddNotificationModal
  302. provider={buildProvider({ on_plate_clear_required: true })}
  303. onClose={() => undefined}
  304. />,
  305. );
  306. const sectionHeader = await screen.findByText(/ntfy priority/i);
  307. const sectionRoot = sectionHeader.closest('div')!;
  308. expect(within(sectionRoot).getByText(/plate clear required/i)).toBeInTheDocument();
  309. });
  310. });
  311. describe('AddNotificationModal — stock alert toggles', () => {
  312. it('renders Inventory Alerts section with both stock alert toggles', async () => {
  313. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  314. const section = await screen.findByText(/inventory alerts/i);
  315. const sectionRoot = section.closest('div')!;
  316. expect(section).toBeInTheDocument();
  317. expect(sectionRoot.textContent).toMatch(/reorder alert/i);
  318. expect(sectionRoot.textContent).toMatch(/stock break alert/i);
  319. });
  320. it('pre-fills toggles from existing provider values', async () => {
  321. render(
  322. <AddNotificationModal
  323. provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: false })}
  324. onClose={() => undefined}
  325. />,
  326. );
  327. await screen.findByText(/inventory alerts/i);
  328. // Reorder alert switch should be ON, break alert switch OFF
  329. const switches = screen.getAllByRole('switch');
  330. const reorderSwitch = switches.find((s) => {
  331. const row = s.closest('div');
  332. return row?.textContent?.match(/reorder alert/i);
  333. });
  334. const breakSwitch = switches.find((s) => {
  335. const row = s.closest('div');
  336. return row?.textContent?.match(/stock break alert/i);
  337. });
  338. expect(reorderSwitch).toHaveAttribute('aria-checked', 'true');
  339. expect(breakSwitch).toHaveAttribute('aria-checked', 'false');
  340. });
  341. it('persists on_stock_reorder_alert on save', async () => {
  342. let captured: unknown = null;
  343. server.use(
  344. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  345. captured = await request.json();
  346. return HttpResponse.json({ id: 1 });
  347. }),
  348. );
  349. const onClose = vi.fn();
  350. const user = userEvent.setup();
  351. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  352. await screen.findByText(/inventory alerts/i);
  353. // Enable the reorder alert toggle
  354. const switches = screen.getAllByRole('switch');
  355. const reorderSwitch = switches.find((s) => {
  356. const row = s.closest('div');
  357. return row?.textContent?.match(/reorder alert/i);
  358. })!;
  359. await user.click(reorderSwitch);
  360. await user.click(screen.getByRole('button', { name: /^save$/i }));
  361. await waitFor(() => expect(onClose).toHaveBeenCalled());
  362. const payload = captured as Record<string, unknown>;
  363. expect(payload.on_stock_reorder_alert).toBe(true);
  364. });
  365. it('persists on_stock_break_alert on save', async () => {
  366. let captured: unknown = null;
  367. server.use(
  368. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  369. captured = await request.json();
  370. return HttpResponse.json({ id: 1 });
  371. }),
  372. );
  373. const onClose = vi.fn();
  374. const user = userEvent.setup();
  375. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  376. await screen.findByText(/inventory alerts/i);
  377. const switches = screen.getAllByRole('switch');
  378. const breakSwitch = switches.find((s) => {
  379. const row = s.closest('div');
  380. return row?.textContent?.match(/stock break alert/i);
  381. })!;
  382. await user.click(breakSwitch);
  383. await user.click(screen.getByRole('button', { name: /^save$/i }));
  384. await waitFor(() => expect(onClose).toHaveBeenCalled());
  385. const payload = captured as Record<string, unknown>;
  386. expect(payload.on_stock_break_alert).toBe(true);
  387. });
  388. it('stock alert events appear in ntfy priority section when enabled', async () => {
  389. const user = userEvent.setup();
  390. render(
  391. <AddNotificationModal
  392. provider={buildProvider({ on_stock_reorder_alert: true, on_stock_break_alert: true })}
  393. onClose={() => undefined}
  394. />,
  395. );
  396. const priorityHeader = await screen.findByText(/ntfy priority/i);
  397. const priorityRoot = priorityHeader.closest('div')!;
  398. // Both stock alert events should appear in the priority list since they are enabled
  399. expect(within(priorityRoot).getByText('Reorder Alert')).toBeInTheDocument();
  400. expect(within(priorityRoot).getByText('Stock Break Alert')).toBeInTheDocument();
  401. void user; // referenced to avoid unused-var lint warning
  402. });
  403. });
  404. describe('AddNotificationModal — AI Failure Detection toggle (#1794)', () => {
  405. it('renders the toggle in the Printer Status section', async () => {
  406. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  407. expect(await screen.findByText('AI Failure Detection')).toBeInTheDocument();
  408. });
  409. it('persists on_ai_failure_detection on save (and does NOT touch on_printer_error)', async () => {
  410. let captured: Record<string, unknown> | null = null;
  411. server.use(
  412. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  413. captured = (await request.json()) as Record<string, unknown>;
  414. return HttpResponse.json({ id: 1 });
  415. }),
  416. );
  417. const onClose = vi.fn();
  418. const user = userEvent.setup();
  419. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  420. const label = await screen.findByText('AI Failure Detection');
  421. const row = label.closest('div.flex')!;
  422. const toggle = within(row).getByRole('switch');
  423. await user.click(toggle);
  424. await user.click(screen.getByRole('button', { name: /^save$/i }));
  425. await waitFor(() => expect(onClose).toHaveBeenCalled());
  426. expect(captured).not.toBeNull();
  427. expect(captured!.on_ai_failure_detection).toBe(true);
  428. // Critical regression guard: don't accidentally flip the legacy multiplexed field.
  429. expect(captured!.on_printer_error).toBe(false);
  430. });
  431. it('AI Failure Detection appears in ntfy priority section when enabled', async () => {
  432. render(
  433. <AddNotificationModal
  434. provider={buildProvider({ on_ai_failure_detection: true })}
  435. onClose={() => undefined}
  436. />,
  437. );
  438. const priorityHeader = await screen.findByText(/ntfy priority/i);
  439. const priorityRoot = priorityHeader.closest('div')!;
  440. expect(within(priorityRoot).getByText('AI Failure Detection')).toBeInTheDocument();
  441. });
  442. });
  443. describe('AddNotificationModal — Storage Location Sensor Alert toggle (#2824)', () => {
  444. it('renders its own toggle, separate from the printer Sensor Alert toggle', async () => {
  445. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  446. expect(await screen.findByText('Printer Sensor Alert')).toBeInTheDocument();
  447. expect(await screen.findByText('Storage Location Sensor Alert')).toBeInTheDocument();
  448. });
  449. it('persists on_location_ha_sensor_alert on save (and does NOT touch on_ha_sensor_alert)', async () => {
  450. // The regression this guards: the two events used to share one column,
  451. // so a provider scoped to a single printer's sensor alerts would also
  452. // start receiving storage-location alerts the moment either toggle went on.
  453. let captured: Record<string, unknown> | null = null;
  454. server.use(
  455. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  456. captured = (await request.json()) as Record<string, unknown>;
  457. return HttpResponse.json({ id: 1 });
  458. }),
  459. );
  460. const onClose = vi.fn();
  461. const user = userEvent.setup();
  462. render(<AddNotificationModal provider={buildProvider()} onClose={onClose} />);
  463. const label = await screen.findByText('Storage Location Sensor Alert');
  464. const row = label.closest('div.flex')!;
  465. const toggle = within(row).getByRole('switch');
  466. await user.click(toggle);
  467. await user.click(screen.getByRole('button', { name: /^save$/i }));
  468. await waitFor(() => expect(onClose).toHaveBeenCalled());
  469. expect(captured).not.toBeNull();
  470. expect(captured!.on_location_ha_sensor_alert).toBe(true);
  471. expect(captured!.on_ha_sensor_alert).toBe(false);
  472. });
  473. it('appears in ntfy priority section when enabled', async () => {
  474. render(
  475. <AddNotificationModal
  476. provider={buildProvider({ provider_type: 'ntfy', on_location_ha_sensor_alert: true })}
  477. onClose={() => undefined}
  478. />,
  479. );
  480. const priorityHeader = await screen.findByText(/ntfy priority/i);
  481. const priorityRoot = priorityHeader.closest('div')!;
  482. expect(within(priorityRoot).getByText('Storage Location Sensor Alert')).toBeInTheDocument();
  483. });
  484. });
  485. describe('AddNotificationModal — Home Assistant custom data (#1441)', () => {
  486. const haProvider = () =>
  487. buildProvider({
  488. provider_type: 'homeassistant',
  489. config: { service: 'notify.mobile_app_myphone' },
  490. });
  491. it('renders the Data (JSON) textarea for the homeassistant provider', async () => {
  492. render(<AddNotificationModal provider={haProvider()} onClose={() => undefined} />);
  493. await screen.findByDisplayValue('My ntfy');
  494. expect(screen.getByText(/data \(json, optional\)/i)).toBeInTheDocument();
  495. expect(screen.getByPlaceholderText(/"priority": "high"/)).toBeInTheDocument();
  496. });
  497. it('rejects malformed JSON in the Data field on save', async () => {
  498. const patchSpy = vi.fn();
  499. server.use(
  500. http.patch('*/api/v1/notifications/1', () => {
  501. patchSpy();
  502. return HttpResponse.json({ id: 1 });
  503. }),
  504. );
  505. const onClose = vi.fn();
  506. const user = userEvent.setup();
  507. render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
  508. const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
  509. await user.type(textarea, '{{priority: high}');
  510. await user.click(screen.getByRole('button', { name: /^save$/i }));
  511. expect(await screen.findByText(/must be a valid JSON object/i)).toBeInTheDocument();
  512. expect(patchSpy).not.toHaveBeenCalled();
  513. expect(onClose).not.toHaveBeenCalled();
  514. });
  515. it('round-trips valid Data JSON into config on save', async () => {
  516. let captured: { config: Record<string, unknown> } | null = null;
  517. server.use(
  518. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  519. captured = (await request.json()) as { config: Record<string, unknown> };
  520. return HttpResponse.json({ id: 1 });
  521. }),
  522. );
  523. const onClose = vi.fn();
  524. const user = userEvent.setup();
  525. render(<AddNotificationModal provider={haProvider()} onClose={onClose} />);
  526. const textarea = await screen.findByPlaceholderText(/"priority": "high"/);
  527. await user.type(textarea, '{{"ttl": 0}');
  528. await user.click(screen.getByRole('button', { name: /^save$/i }));
  529. await waitFor(() => expect(onClose).toHaveBeenCalled());
  530. expect(captured).not.toBeNull();
  531. expect(captured!.config).toMatchObject({
  532. service: 'notify.mobile_app_myphone',
  533. data: '{"ttl": 0}',
  534. });
  535. });
  536. });
  537. describe('AddNotificationModal — Bark provider (#1495)', () => {
  538. it('offers Bark in the provider select and renders its config fields', async () => {
  539. render(
  540. <AddNotificationModal
  541. provider={buildProvider({ provider_type: 'bark', config: { device_key: 'abc123' } })}
  542. onClose={() => undefined}
  543. />,
  544. );
  545. await screen.findByDisplayValue('My ntfy');
  546. expect(screen.getByRole('option', { name: 'Bark' })).toBeInTheDocument();
  547. expect(screen.getByText(/device key/i)).toBeInTheDocument();
  548. expect(screen.getByPlaceholderText('https://api.day.app')).toBeInTheDocument();
  549. expect(screen.getByText(/interruption level/i)).toBeInTheDocument();
  550. });
  551. it('round-trips Bark options into config on save', async () => {
  552. let captured: { config: Record<string, unknown> } | null = null;
  553. server.use(
  554. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  555. captured = (await request.json()) as { config: Record<string, unknown> };
  556. return HttpResponse.json({ id: 1 });
  557. }),
  558. );
  559. const onClose = vi.fn();
  560. const user = userEvent.setup();
  561. render(
  562. <AddNotificationModal
  563. provider={buildProvider({ provider_type: 'bark', config: { device_key: 'abc123' } })}
  564. onClose={onClose}
  565. />,
  566. );
  567. const groupInput = await screen.findByPlaceholderText('Bambuddy');
  568. await user.type(groupInput, 'Printers');
  569. const levelRow = screen.getByText(/interruption level/i).closest('div')!;
  570. await user.selectOptions(within(levelRow).getByRole('combobox'), 'critical');
  571. await user.click(screen.getByRole('button', { name: /^save$/i }));
  572. await waitFor(() => expect(onClose).toHaveBeenCalled());
  573. expect(captured).not.toBeNull();
  574. expect(captured!.config).toMatchObject({
  575. device_key: 'abc123',
  576. group: 'Printers',
  577. level: 'critical',
  578. });
  579. });
  580. });
  581. describe('AddNotificationModal — Telegram forum topic (#1518)', () => {
  582. const telegramProvider = (config: Record<string, unknown> = { bot_token: 'x', chat_id: '-100123' }) =>
  583. buildProvider({ provider_type: 'telegram', config });
  584. it('offers the Forum Topic ID field as optional for telegram', async () => {
  585. render(<AddNotificationModal provider={telegramProvider()} onClose={() => undefined} />);
  586. const label = await screen.findByText(/forum topic id/i);
  587. // Required fields are marked with a trailing asterisk — this one must not be.
  588. expect(label.textContent).not.toContain('*');
  589. expect(screen.getByText(/leave empty for the general topic/i)).toBeInTheDocument();
  590. });
  591. it('does not offer the field for other providers', async () => {
  592. render(<AddNotificationModal provider={buildProvider()} onClose={() => undefined} />);
  593. await screen.findByDisplayValue('My ntfy');
  594. expect(screen.queryByText(/forum topic id/i)).not.toBeInTheDocument();
  595. });
  596. it('round-trips the topic id into config on save', async () => {
  597. let captured: { config: Record<string, unknown> } | null = null;
  598. server.use(
  599. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  600. captured = (await request.json()) as { config: Record<string, unknown> };
  601. return HttpResponse.json({ id: 1 });
  602. }),
  603. );
  604. const onClose = vi.fn();
  605. const user = userEvent.setup();
  606. render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
  607. await user.type(await screen.findByPlaceholderText('123'), '25');
  608. await user.click(screen.getByRole('button', { name: /^save$/i }));
  609. await waitFor(() => expect(onClose).toHaveBeenCalled());
  610. expect(captured).not.toBeNull();
  611. expect(captured!.config).toMatchObject({ chat_id: '-100123', message_thread_id: '25' });
  612. });
  613. it('keeps the config free of the key when the field is left empty', async () => {
  614. let captured: { config: Record<string, unknown> } | null = null;
  615. server.use(
  616. http.patch('*/api/v1/notifications/1', async ({ request }) => {
  617. captured = (await request.json()) as { config: Record<string, unknown> };
  618. return HttpResponse.json({ id: 1 });
  619. }),
  620. );
  621. const onClose = vi.fn();
  622. const user = userEvent.setup();
  623. render(<AddNotificationModal provider={telegramProvider()} onClose={onClose} />);
  624. await screen.findByPlaceholderText('123');
  625. await user.click(screen.getByRole('button', { name: /^save$/i }));
  626. await waitFor(() => expect(onClose).toHaveBeenCalled());
  627. expect(captured!.config).not.toHaveProperty('message_thread_id');
  628. });
  629. it('blocks save on a non-numeric topic id', async () => {
  630. // Reaches the form via a config written by the API rather than the picker —
  631. // the number input itself already filters most junk out.
  632. let patched = false;
  633. server.use(
  634. http.patch('*/api/v1/notifications/1', async () => {
  635. patched = true;
  636. return HttpResponse.json({ id: 1 });
  637. }),
  638. );
  639. const onClose = vi.fn();
  640. const user = userEvent.setup();
  641. render(
  642. <AddNotificationModal
  643. provider={telegramProvider({ bot_token: 'x', chat_id: '-100123', message_thread_id: 'General' })}
  644. onClose={onClose}
  645. />,
  646. );
  647. await screen.findByText(/forum topic id/i);
  648. await user.click(screen.getByRole('button', { name: /^save$/i }));
  649. expect(await screen.findByText(/forum topic id must be a number/i)).toBeInTheDocument();
  650. expect(patched).toBe(false);
  651. expect(onClose).not.toHaveBeenCalled();
  652. });
  653. });
  654. describe('AddNotificationModal — Notify!', () => {
  655. const notifyProvider = (config: Record<string, unknown> = {}) => buildProvider({
  656. name: 'My Notify!',
  657. provider_type: 'notify',
  658. config: { device_id: 'IO12345678901234', token: 'notify-secret', ...config },
  659. });
  660. const liveActivitiesSwitch = () => within(
  661. screen.getByRole('group', { name: 'Live Activities (iOS)' }),
  662. ).getByRole('switch');
  663. const widgetsSwitch = () => within(
  664. screen.getByRole('group', { name: 'Lock Screen Widgets (iOS)' }),
  665. ).getByRole('switch');
  666. const photoSwitch = () => screen.getAllByRole('switch').find(
  667. (toggle) => toggle.parentElement?.textContent?.includes('Attach Photo'),
  668. )!;
  669. it('offers Notify! with masked credentials and Live Activities disabled by default', async () => {
  670. const user = userEvent.setup();
  671. render(<AddNotificationModal onClose={() => undefined} />);
  672. await user.selectOptions(await screen.findByDisplayValue('Email'), 'notify');
  673. expect(screen.getByLabelText(/device or group id/i)).toBeInTheDocument();
  674. expect(screen.getByLabelText(/notify! token/i)).toHaveAttribute('type', 'password');
  675. expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
  676. expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'false');
  677. expect(screen.queryByText('Live Activity appearance')).not.toBeInTheDocument();
  678. expect(screen.getByText(/Use an iOS device ID; add a separate provider for group pushes/)).toBeInTheDocument();
  679. expect(screen.getByRole('button', { name: /test configuration/i })).toBeDisabled();
  680. await user.type(screen.getByLabelText(/device or group id/i), 'GRP12345678901234');
  681. expect(screen.getByRole('button', { name: /test configuration/i })).toBeDisabled();
  682. });
  683. it('creates a group push provider with Live Activities explicitly disabled', async () => {
  684. let captured: Record<string, unknown> | null = null;
  685. server.use(http.post('*/api/v1/notifications/', async ({ request }) => {
  686. captured = await request.json() as Record<string, unknown>;
  687. return HttpResponse.json({ id: 2 });
  688. }));
  689. const onClose = vi.fn();
  690. const user = userEvent.setup();
  691. render(<AddNotificationModal onClose={onClose} />);
  692. await user.selectOptions(await screen.findByDisplayValue('Email'), 'notify');
  693. await user.type(screen.getByPlaceholderText(/My Notifications/i), 'Notify! group');
  694. await user.type(screen.getByLabelText(/device or group id/i), 'GRP12345678901234');
  695. await user.type(screen.getByLabelText(/notify! token/i), 'secret');
  696. await user.click(screen.getByRole('button', { name: /^add$/i }));
  697. await waitFor(() => expect(onClose).toHaveBeenCalled());
  698. expect(captured).toMatchObject({
  699. provider_type: 'notify',
  700. config: { device_id: 'GRP12345678901234', token: 'secret', live_activities: false },
  701. });
  702. });
  703. it('saves tile customization with boolean and array types without enabling push events', async () => {
  704. let captured: Record<string, unknown> | null = null;
  705. server.use(http.patch('*/api/v1/notifications/1', async ({ request }) => {
  706. captured = await request.json() as Record<string, unknown>;
  707. return HttpResponse.json({ id: 1 });
  708. }));
  709. const onClose = vi.fn();
  710. const user = userEvent.setup();
  711. render(<AddNotificationModal provider={notifyProvider()} onClose={onClose} />);
  712. await user.click(liveActivitiesSwitch());
  713. expect(screen.getByText(/Event toggles below control push alerts only/)).toBeInTheDocument();
  714. expect(screen.getByText(/Quiet hours block new activities; existing activities still update and end/)).toBeInTheDocument();
  715. await user.click(screen.getByText('Live Activity appearance'));
  716. await user.selectOptions(screen.getByLabelText('Hide file name on tile'), 'true');
  717. await user.selectOptions(screen.getByLabelText('Show print stage'), 'true');
  718. await user.selectOptions(screen.getByLabelText('Progress style'), 'segments');
  719. await user.selectOptions(screen.getByLabelText('Time Sensitive problem alerts'), 'true');
  720. await user.click(screen.getByRole('checkbox', { name: 'ETA' }));
  721. await user.click(screen.getByRole('checkbox', { name: 'Bed' }));
  722. await user.type(screen.getByLabelText('Dashboard URL (optional)'), 'https://bambuddy.example.com');
  723. await user.type(screen.getByLabelText('Tint (optional)'), '#123456');
  724. await user.type(screen.getByLabelText('Symbol (optional)'), 'printer');
  725. await user.click(screen.getByRole('button', { name: /^save$/i }));
  726. await waitFor(() => expect(onClose).toHaveBeenCalled());
  727. expect(captured).toMatchObject({
  728. on_print_start: false,
  729. on_print_progress: false,
  730. config: {
  731. live_activities: true,
  732. live_activity_privacy: true,
  733. live_activity_stage: true,
  734. live_activity_style: 'segments',
  735. live_activity_metrics: ['eta', 'bed'],
  736. live_activity_button_url: 'https://bambuddy.example.com',
  737. live_activity_tint: '#123456',
  738. live_activity_symbol: 'printer',
  739. time_sensitive: true,
  740. },
  741. });
  742. });
  743. it('preserves existing customization while disabling Live Activities', async () => {
  744. let captured: Record<string, unknown> | null = null;
  745. server.use(http.patch('*/api/v1/notifications/1', async ({ request }) => {
  746. captured = await request.json() as Record<string, unknown>;
  747. return HttpResponse.json({ id: 1 });
  748. }));
  749. const onClose = vi.fn();
  750. const user = userEvent.setup();
  751. render(<AddNotificationModal provider={notifyProvider({
  752. live_activities: true,
  753. live_activity_privacy: true,
  754. live_activity_style: 'none',
  755. live_activity_metrics: ['layers', 'nozzle'],
  756. time_sensitive: false,
  757. })} onClose={onClose} />);
  758. expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'true');
  759. await user.click(screen.getByText('Live Activity appearance'));
  760. expect(screen.getByLabelText('Hide file name on tile')).toHaveValue('true');
  761. expect(screen.getByLabelText('Progress style')).toHaveValue('none');
  762. expect(screen.getByRole('checkbox', { name: 'Layer' })).toBeChecked();
  763. expect(screen.getByRole('checkbox', { name: 'Nozzle' })).toBeChecked();
  764. await user.click(liveActivitiesSwitch());
  765. await user.click(screen.getByRole('button', { name: /^save$/i }));
  766. await waitFor(() => expect(onClose).toHaveBeenCalled());
  767. expect(captured).toMatchObject({ config: {
  768. live_activities: false,
  769. live_activity_privacy: true,
  770. live_activity_style: 'none',
  771. live_activity_metrics: ['layers', 'nozzle'],
  772. time_sensitive: false,
  773. } });
  774. });
  775. it.each(['GRP12345678901234', 'wb12345678901234', 'MC12345678901234'])('keeps %s eligible for pushes with Live Activities disabled', async (deviceId) => {
  776. let captured: Record<string, unknown> | null = null;
  777. server.use(http.patch('*/api/v1/notifications/1', async ({ request }) => {
  778. captured = await request.json() as Record<string, unknown>;
  779. return HttpResponse.json({ id: 1 });
  780. }));
  781. const onClose = vi.fn();
  782. const user = userEvent.setup();
  783. render(<AddNotificationModal provider={notifyProvider({ device_id: deviceId, live_activities: true, lock_screen_widgets: true })} onClose={onClose} />);
  784. expect(liveActivitiesSwitch()).toBeDisabled();
  785. expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
  786. expect(widgetsSwitch()).toBeDisabled();
  787. expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'false');
  788. expect(screen.getByText(/Group, browser and macOS IDs use push notifications only/)).toBeInTheDocument();
  789. expect(screen.getByRole('button', { name: /test configuration/i })).toBeEnabled();
  790. expect(screen.queryByText('Live Activity appearance')).not.toBeInTheDocument();
  791. await user.click(screen.getByRole('button', { name: /^save$/i }));
  792. await waitFor(() => expect(onClose).toHaveBeenCalled());
  793. expect(captured).toMatchObject({ config: {
  794. device_id: deviceId,
  795. token: 'notify-secret',
  796. live_activities: false,
  797. lock_screen_widgets: false,
  798. } });
  799. });
  800. it.each(['GRP12345678901234', 'WB12345678901234'])('turns off Live Activities when changing an iOS ID to %s', async (deviceId) => {
  801. let captured: Record<string, unknown> | null = null;
  802. server.use(http.post('*/api/v1/notifications/test-config', async ({ request }) => {
  803. captured = await request.json() as Record<string, unknown>;
  804. return HttpResponse.json({ success: true, message: 'Push delivered' });
  805. }));
  806. const user = userEvent.setup();
  807. render(<AddNotificationModal provider={notifyProvider({ live_activities: true, lock_screen_widgets: true })} onClose={() => undefined} />);
  808. expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'true');
  809. expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'true');
  810. const target = screen.getByLabelText(/device or group id/i);
  811. await user.clear(target);
  812. await user.type(target, deviceId);
  813. expect(liveActivitiesSwitch()).toBeDisabled();
  814. expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
  815. expect(widgetsSwitch()).toBeDisabled();
  816. expect(widgetsSwitch()).toHaveAttribute('aria-checked', 'false');
  817. expect(photoSwitch()).toBeDisabled();
  818. expect(photoSwitch()).toHaveAttribute('aria-checked', 'false');
  819. await user.click(screen.getByRole('button', { name: /test configuration/i }));
  820. expect(await screen.findByText('Push delivered')).toBeInTheDocument();
  821. expect(captured).toMatchObject({ attach_photo: false });
  822. expect(captured).toMatchObject({ config: {
  823. device_id: deviceId,
  824. token: 'notify-secret',
  825. live_activities: false,
  826. lock_screen_widgets: false,
  827. } });
  828. await user.clear(target);
  829. await user.type(target, 'IO12345678901234');
  830. expect(liveActivitiesSwitch()).toBeEnabled();
  831. expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
  832. });
  833. it('sends setup tests through the push test endpoint with typed config', async () => {
  834. let captured: Record<string, unknown> | null = null;
  835. server.use(http.post('*/api/v1/notifications/test-config', async ({ request }) => {
  836. captured = await request.json() as Record<string, unknown>;
  837. return HttpResponse.json({ success: true, message: 'Push delivered' });
  838. }));
  839. const user = userEvent.setup();
  840. render(<AddNotificationModal provider={notifyProvider({ live_activities: true })} onClose={() => undefined} />);
  841. expect(screen.getByText(/Testing sends a push notification/)).toBeInTheDocument();
  842. await user.click(screen.getByRole('button', { name: /test configuration/i }));
  843. expect(await screen.findByText('Push delivered')).toBeInTheDocument();
  844. expect(captured).toMatchObject({
  845. provider_type: 'notify',
  846. config: { device_id: 'IO12345678901234', token: 'notify-secret', live_activities: true, live_activity_metrics: [] },
  847. });
  848. });
  849. it('clears Notify customization when changing provider type', async () => {
  850. const user = userEvent.setup();
  851. render(<AddNotificationModal onClose={() => undefined} />);
  852. const providerSelect = await screen.findByDisplayValue('Email');
  853. await user.selectOptions(providerSelect, 'notify');
  854. await user.click(liveActivitiesSwitch());
  855. await user.click(screen.getByText('Live Activity appearance'));
  856. await user.click(screen.getByRole('checkbox', { name: 'ETA' }));
  857. await user.selectOptions(providerSelect, 'ntfy');
  858. await user.selectOptions(providerSelect, 'notify');
  859. expect(liveActivitiesSwitch()).toHaveAttribute('aria-checked', 'false');
  860. await user.click(liveActivitiesSwitch());
  861. await user.click(screen.getByText('Live Activity appearance'));
  862. expect(screen.getByRole('checkbox', { name: 'ETA' })).not.toBeChecked();
  863. });
  864. it('defaults to the timer with optional metrics unchecked', async () => {
  865. const user = userEvent.setup();
  866. render(<AddNotificationModal provider={notifyProvider({ live_activities: true })} onClose={() => undefined} />);
  867. await user.click(screen.getByText('Live Activity appearance'));
  868. expect(screen.getByText(/The timer is shown by default/)).toBeInTheDocument();
  869. for (const checkbox of screen.getAllByRole('checkbox')) expect(checkbox).not.toBeChecked();
  870. });
  871. it('enables Lock Screen widgets independently of Live Activities and push events', async () => {
  872. let captured: Record<string, unknown> | null = null;
  873. server.use(http.patch('*/api/v1/notifications/1', async ({ request }) => {
  874. captured = await request.json() as Record<string, unknown>;
  875. return HttpResponse.json({ id: 1 });
  876. }));
  877. const onClose = vi.fn();
  878. const user = userEvent.setup();
  879. render(<AddNotificationModal provider={notifyProvider()} onClose={onClose} />);
  880. expect(screen.getByText(/iOS refreshes roughly every 15 minutes or longer/)).toBeInTheDocument();
  881. expect(screen.getByText(/Only active printers outside maintenance mode receive widgets/)).toBeInTheDocument();
  882. expect(screen.getByText(/Notify! allows 10 widgets per device in total/)).toBeInTheDocument();
  883. expect(screen.getByDisplayValue('All printers')).toBeEnabled();
  884. await user.click(widgetsSwitch());
  885. await user.click(screen.getByRole('button', { name: /^save$/i }));
  886. await waitFor(() => expect(onClose).toHaveBeenCalled());
  887. expect(captured).toMatchObject({
  888. on_print_start: false,
  889. on_print_progress: false,
  890. printer_id: null,
  891. config: { token: 'notify-secret', live_activities: false, lock_screen_widgets: true },
  892. });
  893. });
  894. it.each(['GRP12345678901234', 'WB12345678901234'])('saves %s with photo attachments disabled', async (deviceId) => {
  895. let captured: Record<string, unknown> | null = null;
  896. server.use(http.patch('*/api/v1/notifications/1', async ({ request }) => {
  897. captured = await request.json() as Record<string, unknown>;
  898. return HttpResponse.json({ id: 1 });
  899. }));
  900. const onClose = vi.fn();
  901. const user = userEvent.setup();
  902. render(<AddNotificationModal provider={notifyProvider({ device_id: deviceId })} onClose={onClose} />);
  903. expect(photoSwitch()).toBeDisabled();
  904. expect(photoSwitch()).toHaveAttribute('aria-checked', 'false');
  905. expect(screen.getByText(/Browser and group recipients support text notifications only/)).toBeInTheDocument();
  906. await user.click(screen.getByRole('button', { name: /^save$/i }));
  907. await waitFor(() => expect(onClose).toHaveBeenCalled());
  908. expect(captured).toMatchObject({ attach_photo: false, config: { device_id: deviceId, token: 'notify-secret' } });
  909. });
  910. it.each(['GRP12345678901234', 'WB12345678901234'])('disables photo attachments in the %s provider card', async (deviceId) => {
  911. const user = userEvent.setup();
  912. render(<NotificationProviderCard provider={notifyProvider({ device_id: deviceId, live_activities: true, lock_screen_widgets: true })} onEdit={() => undefined} />);
  913. expect(screen.queryByText('Live Activities (iOS)')).not.toBeInTheDocument();
  914. expect(screen.queryByText('Lock Screen Widgets (iOS)')).not.toBeInTheDocument();
  915. await user.click(screen.getByRole('button', { name: /event settings/i }));
  916. expect(photoSwitch()).toBeDisabled();
  917. expect(photoSwitch()).toHaveAttribute('aria-checked', 'false');
  918. });
  919. it('shows a separate badge when Lock Screen widgets are enabled', () => {
  920. render(<NotificationProviderCard provider={notifyProvider({ lock_screen_widgets: true })} onEdit={() => undefined} />);
  921. expect(screen.getByText('Lock Screen Widgets (iOS)')).toBeInTheDocument();
  922. expect(screen.queryByText('Live Activities (iOS)')).not.toBeInTheDocument();
  923. });
  924. it('shows the Live Activities badge on enabled Notify! provider cards', () => {
  925. render(<NotificationProviderCard provider={notifyProvider({ live_activities: true })} onEdit={() => undefined} />);
  926. expect(screen.getByText('Notify!')).toBeInTheDocument();
  927. expect(screen.getByText('Live Activities (iOS)')).toBeInTheDocument();
  928. });
  929. it('does not show the Live Activities badge for push-only providers', () => {
  930. render(<NotificationProviderCard provider={notifyProvider({ live_activities: false })} onEdit={() => undefined} />);
  931. expect(screen.queryByText('Live Activities (iOS)')).not.toBeInTheDocument();
  932. });
  933. });