Files
ToBePlayed-Frontend/src/pages/AdminBroadcastPage.test.jsx
T
lobo fa0857f55d feat(broadcasts): add admin broadcast page and user opt-out switch
Admin page /admin/broadcasts to write, test and send broadcasts in both
languages; confirms the recipient count before send. History shows the
counts. A new switch in settings lets users opt out of platform mails;
account mails always come. Subject and body are plain text only.
2026-10-01 14:12:34 +02:00

162 lines
7.3 KiB
React

// @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(<MemoryRouter><PreferencesProvider><AdminBroadcastPage/></PreferencesProvider></MemoryRouter>);
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();
});
});