// @vitest-environment jsdom import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest'; import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react'; import {MemoryRouter} from 'react-router-dom'; import {toast} from 'sonner'; import AdminBroadcastPage from './AdminBroadcastPage.jsx'; import {PreferencesProvider} from '../context/PreferencesContext.jsx'; import {fetchRoutes, json} from '../test/fetchRoutes.js'; vi.mock('react-i18next', () => import('../test/i18n-mock.js')); vi.mock('sonner', () => ({toast: {success: vi.fn(), error: vi.fn()}})); const ENTRY = {id: 1, subjectEn: 'Maintenance', subjectDe: 'Wartung', bodyEn: 'We pause.', bodyDe: 'Wir pausieren.', createdBy: 1, createdAt: '2026-10-01T10:00:00Z', recipientCount: 5, sentCount: 4, failedCount: 1}; const USERS = {content: [ {id: 7, email: 'ann@example.com', displayName: 'Ann', blocked: false, deletedAt: null}, {id: 8, email: 'bob@example.com', displayName: 'Bob', blocked: true, deletedAt: null}, ]}; const withRoutes = (routes) => fetchRoutes({ 'GET /api/admin/broadcasts': json(200, {results: [ENTRY]}), 'GET /api/admin/broadcasts/recipient-count': json(200, {count: 42}), 'POST /api/admin/broadcasts': json(201, {recipientCount: 42}), 'POST /api/admin/broadcasts/test': json(204), 'GET /api/admin/users': json(200, USERS), ...routes, }); const posts = (suffix) => fetch.mock.calls.filter(([url, init]) => init?.method === 'POST' && url.endsWith(suffix)); const body = (call) => JSON.parse(call[1].body); const renderPage = () => render(); const fill = () => { fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN'), {target: {value: ' Hello '}}); fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_EN'), {target: {value: ' Body en '}}); fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_DE'), {target: {value: ' Hallo '}}); fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_DE'), {target: {value: ' Inhalt '}}); }; const TRIMMED = {subjectEn: 'Hello', subjectDe: 'Hallo', bodyEn: 'Body en', bodyDe: 'Inhalt'}; describe('AdminBroadcastPage', () => { beforeEach(() => { localStorage.setItem('tbp_session', '1'); withRoutes({}); }); afterEach(() => { cleanup(); localStorage.clear(); vi.clearAllMocks(); }); it('lists the history with the counts', async () => { renderPage(); expect(await screen.findByText('Maintenance')).toBeTruthy(); expect(screen.getByText('Wartung')).toBeTruthy(); expect(screen.getByText(/ADMIN_BROADCAST\.COUNTS/)).toBeTruthy(); }); it('sends the trimmed test mail and keeps the form', async () => { renderPage(); await screen.findByText('Maintenance'); const test = screen.getByText('ADMIN_BROADCAST.TEST').closest('button'); expect(test.disabled).toBe(true); fill(); fireEvent.click(test); await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_BROADCAST.TEST_SENT')); expect(body(posts('/test')[0])).toEqual(TRIMMED); expect(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN').value).toBe(' Hello '); }); it('sends to all after the confirmation with the count, then empties the form', async () => { renderPage(); await screen.findByText('Maintenance'); fill(); fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND')); expect(await screen.findByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeTruthy(); expect(posts('/broadcasts')).toEqual([]); fireEvent.click(screen.getByText('ADMIN_BROADCAST.CONFIRM')); await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_BROADCAST.SENT:42')); expect(body(posts('/broadcasts')[0])).toEqual({...TRIMMED, userIds: null}); await waitFor(() => expect(screen.getByLabelText('ADMIN_BROADCAST.SUBJECT_EN').value).toBe('')); expect(screen.queryByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeNull(); }); it('sends to the picked users only and leaves a blocked one out of the list', async () => { renderPage(); await screen.findByText('Maintenance'); fill(); const send = screen.getByText('ADMIN_BROADCAST.SEND').closest('button'); fireEvent.click(screen.getByText('ADMIN_BROADCAST.PICKED')); expect(send.disabled).toBe(true); fireEvent.change(screen.getByRole('textbox', {name: 'ADMIN_BROADCAST.SEARCH'}), {target: {value: 'a'}}); fireEvent.click(screen.getByRole('button', {name: 'ADMIN_BROADCAST.SEARCH'})); const ann = await screen.findByLabelText(/ann@example\.com/); expect(screen.queryByLabelText(/bob@example\.com/)).toBeNull(); fireEvent.click(ann); expect(screen.getByText('ADMIN_BROADCAST.PICKED_COUNT:1')).toBeTruthy(); fireEvent.click(send); await screen.findByText('ADMIN_BROADCAST.CONFIRM_TEXT:42'); expect(fetch.mock.calls.some(([url]) => url.includes('/recipient-count?userIds=7'))).toBe(true); fireEvent.click(screen.getByText('ADMIN_BROADCAST.CONFIRM')); await waitFor(() => expect(posts('/broadcasts')).toHaveLength(1)); expect(body(posts('/broadcasts')[0]).userIds).toEqual([7]); }); it('cannot send when nobody would get the mail', async () => { withRoutes({'GET /api/admin/broadcasts/recipient-count': json(200, {count: 0})}); renderPage(); await screen.findByText('Maintenance'); fill(); fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND')); expect(await screen.findByText('ADMIN_BROADCAST.NO_RECIPIENTS')).toBeTruthy(); expect(screen.getByText('ADMIN_BROADCAST.CONFIRM').closest('button').disabled).toBe(true); }); it('keeps the confirmation closed when it was cancelled before the count arrived', async () => { let answer; withRoutes({'GET /api/admin/broadcasts/recipient-count': () => new Promise((resolve) => { answer = resolve; })}); renderPage(); await screen.findByText('Maintenance'); fill(); fireEvent.click(screen.getByText('ADMIN_BROADCAST.SEND')); fireEvent.click(await screen.findByText('ADMIN_BROADCAST.CANCEL')); await waitFor(() => expect(screen.queryByText('ADMIN_BROADCAST.CANCEL')).toBeNull()); answer(json(200, {count: 42})); await waitFor(() => expect(fetch.mock.calls.some(([url]) => url.includes('/recipient-count'))).toBe(true)); await new Promise((resolve) => setTimeout(resolve, 20)); expect(screen.queryByText('ADMIN_BROADCAST.CONFIRM_TEXT:42')).toBeNull(); }); it('does not offer the send while a field is blank', async () => { renderPage(); await screen.findByText('Maintenance'); fill(); fireEvent.change(screen.getByLabelText('ADMIN_BROADCAST.BODY_DE'), {target: {value: ' '}}); expect(screen.getByText('ADMIN_BROADCAST.SEND').closest('button').disabled).toBe(true); }); it('shows "no access" to a non-admin', async () => { withRoutes({'GET /api/admin/broadcasts': json(403, {})}); renderPage(); expect(await screen.findByText('ADMIN_BROADCAST.NO_ACCESS')).toBeTruthy(); expect(screen.queryByText('ADMIN_BROADCAST.SEND')).toBeNull(); }); });