Announcements.test.tsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238
  1. /**
  2. * Announcements from the Bambuddy maintainers: the sidebar entry above System,
  3. * the slide-over list, and the banner for unread important/critical messages.
  4. */
  5. import { describe, it, expect, beforeEach, vi } from 'vitest';
  6. import { screen, waitFor, fireEvent, within } from '@testing-library/react';
  7. import { http, HttpResponse } from 'msw';
  8. import { render } from '../utils';
  9. import { server } from '../mocks/server';
  10. import { AnnouncementsPanel } from '../../components/AnnouncementsPanel';
  11. import { AnnouncementBanner } from '../../components/AnnouncementBanner';
  12. import { Layout } from '../../components/Layout';
  13. import type { Announcement } from '../../api/client';
  14. function announcement(overrides: Partial<Announcement> = {}): Announcement {
  15. return {
  16. id: 'a1',
  17. level: 'info',
  18. texts: { en: { title: 'Hello installs', body: 'Line one\nLine two' } },
  19. link_url: null,
  20. published_at: '2026-10-01T12:00:00Z',
  21. expires_at: null,
  22. archived: false,
  23. read: false,
  24. ...overrides,
  25. };
  26. }
  27. describe('AnnouncementsPanel', () => {
  28. it('lists titles collapsed, marks unread ones New, and marks read only on opening one', () => {
  29. const markRead = vi.fn();
  30. render(
  31. <AnnouncementsPanel
  32. open
  33. onClose={() => {}}
  34. markRead={markRead}
  35. announcements={[
  36. announcement({ texts: { en: { title: 'Fresh', body: 'Fresh body' } } }),
  37. announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'Old body' } } }),
  38. ]}
  39. />
  40. );
  41. const items = screen.getAllByRole('listitem');
  42. expect(within(items[0]).getByText('New')).toBeInTheDocument();
  43. expect(within(items[1]).queryByText('New')).not.toBeInTheDocument();
  44. // Collapsed: titles only, nothing read yet.
  45. expect(screen.queryByText('Fresh body')).not.toBeInTheDocument();
  46. expect(markRead).not.toHaveBeenCalled();
  47. fireEvent.click(screen.getByRole('button', { name: /Fresh/ }));
  48. expect(screen.getByText('Fresh body')).toBeInTheDocument();
  49. expect(markRead).toHaveBeenCalledWith('a1');
  50. // Opening one already read doesn't mark it again.
  51. fireEvent.click(screen.getByRole('button', { name: /Old news/ }));
  52. expect(screen.getByText('Old body')).toBeInTheDocument();
  53. expect(markRead).toHaveBeenCalledTimes(1);
  54. // And it closes again.
  55. fireEvent.click(screen.getByRole('button', { name: /Fresh/ }));
  56. expect(screen.queryByText('Fresh body')).not.toBeInTheDocument();
  57. });
  58. it('opens with the message the banner pointed at expanded', () => {
  59. const markRead = vi.fn();
  60. render(
  61. <AnnouncementsPanel
  62. open
  63. focusId="b"
  64. onClose={() => {}}
  65. markRead={markRead}
  66. announcements={[
  67. announcement({ id: 'a', texts: { en: { title: 'A', body: 'A body' } } }),
  68. announcement({ id: 'b', texts: { en: { title: 'B', body: 'B body' } } }),
  69. ]}
  70. />
  71. );
  72. expect(screen.getByText('B body')).toBeInTheDocument();
  73. expect(screen.queryByText('A body')).not.toBeInTheDocument();
  74. expect(markRead).toHaveBeenCalledWith('b');
  75. });
  76. it('renders the body as plain text, never as HTML', () => {
  77. render(
  78. <AnnouncementsPanel
  79. open
  80. onClose={() => {}}
  81. markRead={() => {}}
  82. announcements={[announcement({ texts: { en: { title: 'T', body: '<img src=x onerror=alert(1)>' } } })]}
  83. />
  84. );
  85. fireEvent.click(screen.getByText('T'));
  86. expect(screen.getByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
  87. expect(document.querySelector('img[src="x"]')).toBeNull();
  88. });
  89. it('links only to allowed hosts', () => {
  90. render(
  91. <AnnouncementsPanel
  92. open
  93. onClose={() => {}}
  94. markRead={() => {}}
  95. announcements={[
  96. announcement({ id: 'ok', link_url: 'https://wiki.bambuddy.cool/x/', texts: { en: { title: 'A', body: 'b', link_label: 'Details' } } }),
  97. announcement({ id: 'bad', link_url: 'https://evil.example/x', texts: { en: { title: 'B', body: 'b', link_label: 'Phish' } } }),
  98. ]}
  99. />
  100. );
  101. fireEvent.click(screen.getByText('A'));
  102. fireEvent.click(screen.getByText('B'));
  103. const link = screen.getByRole('link', { name: /Details/ });
  104. expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/');
  105. expect(link).toHaveAttribute('rel', 'noopener noreferrer');
  106. expect(screen.queryByRole('link', { name: /Phish/ })).not.toBeInTheDocument();
  107. });
  108. it('closes on Escape', () => {
  109. const onClose = vi.fn();
  110. render(<AnnouncementsPanel open onClose={onClose} markRead={() => {}} announcements={[announcement()]} />);
  111. fireEvent.keyDown(window, { key: 'Escape' });
  112. expect(onClose).toHaveBeenCalled();
  113. });
  114. });
  115. describe('AnnouncementsPanel history', () => {
  116. it('lists expired messages under a collapsed Earlier section and never marks them', () => {
  117. const markRead = vi.fn();
  118. render(
  119. <AnnouncementsPanel
  120. open
  121. onClose={() => {}}
  122. markRead={markRead}
  123. announcements={[
  124. announcement({ id: 'old', archived: true, texts: { en: { title: 'Old news', body: 'x' } } }),
  125. ]}
  126. />
  127. );
  128. expect(screen.getByText('No announcements right now.')).toBeInTheDocument();
  129. expect(screen.queryByText('Old news')).not.toBeInTheDocument();
  130. fireEvent.click(screen.getByRole('button', { name: 'Earlier (1)' }));
  131. expect(screen.getByText('Old news')).toBeInTheDocument();
  132. fireEvent.click(screen.getByRole('button', { name: /Old news/ }));
  133. expect(screen.queryByText('New')).not.toBeInTheDocument();
  134. expect(markRead).not.toHaveBeenCalled();
  135. });
  136. });
  137. describe('AnnouncementBanner', () => {
  138. it('shows the first item, offers the rest, and Got it marks it read', () => {
  139. const markRead = vi.fn();
  140. const onOpen = vi.fn();
  141. render(
  142. <AnnouncementBanner
  143. items={[
  144. announcement({ id: 'crit', level: 'critical', texts: { en: { title: 'Update now', body: 'b' } } }),
  145. announcement({ id: 'imp', level: 'important' }),
  146. ]}
  147. onOpen={onOpen}
  148. markRead={markRead}
  149. />
  150. );
  151. expect(screen.getByRole('alert')).toHaveTextContent('Update now');
  152. fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' }));
  153. expect(onOpen).toHaveBeenCalledWith('crit');
  154. fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
  155. expect(markRead).toHaveBeenCalledWith('crit');
  156. });
  157. it('renders nothing without items', () => {
  158. render(<AnnouncementBanner items={[]} onOpen={() => {}} markRead={() => {}} />);
  159. expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  160. expect(screen.queryByRole('status')).not.toBeInTheDocument();
  161. });
  162. });
  163. describe('Layout with announcements', () => {
  164. beforeEach(() => {
  165. server.use(
  166. http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
  167. http.get('/api/v1/settings/ui-flags', () =>
  168. HttpResponse.json({ check_updates: false, billing_enabled: false, user_notifications_enabled: true, currency: 'EUR' })
  169. )
  170. );
  171. });
  172. it('shows no entry and no banner when there is nothing', async () => {
  173. render(<Layout />);
  174. await waitFor(() => expect(screen.getAllByTitle(/System/).length).toBeGreaterThan(0));
  175. expect(screen.queryByText('Announcements')).not.toBeInTheDocument();
  176. });
  177. it('keeps the entry for history alone, with no count and no banner', async () => {
  178. server.use(
  179. http.get('/api/v1/announcements', () =>
  180. HttpResponse.json([announcement({ id: 'old', level: 'critical', archived: true })])
  181. )
  182. );
  183. render(<Layout />);
  184. const entry = await screen.findByRole('button', { name: /announcements/i });
  185. expect(within(entry).queryByText('1')).not.toBeInTheDocument();
  186. expect(screen.queryByRole('alert')).not.toBeInTheDocument();
  187. });
  188. it('shows the entry with an unread count, the banner for important, and opens the list', async () => {
  189. let reads: string[] = [];
  190. server.use(
  191. http.get('/api/v1/announcements', () =>
  192. HttpResponse.json([
  193. announcement({ id: 'imp', level: 'important', texts: { en: { title: 'Breaking change in 2.0', body: 'b' } } }),
  194. announcement({ id: 'inf', level: 'info', texts: { en: { title: 'Testers wanted', body: 'b' } } }),
  195. ])
  196. ),
  197. http.post('/api/v1/announcements/:id/read', ({ params }) => {
  198. reads = [...reads, String(params.id)];
  199. return new HttpResponse(null, { status: 204 });
  200. })
  201. );
  202. render(<Layout />);
  203. const entry = await screen.findByRole('button', { name: /announcements/i });
  204. expect(within(entry).getByText('2')).toBeInTheDocument();
  205. // Only the important one earns a banner.
  206. expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0');
  207. expect(screen.queryByText('Testers wanted')).not.toBeInTheDocument();
  208. fireEvent.click(entry);
  209. const dialog = await screen.findByRole('dialog');
  210. expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument();
  211. // Opening the panel reads nothing; opening a message reads that one.
  212. expect(reads).toEqual([]);
  213. fireEvent.click(within(dialog).getByRole('button', { name: /Breaking change in 2.0/ }));
  214. await waitFor(() => expect(reads).toEqual(['imp']));
  215. // Read now: its banner is gone, the info one is still unread.
  216. await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
  217. expect(within(dialog).getAllByText('New')).toHaveLength(1);
  218. });
  219. });