Public Access
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.
162 lines
7.3 KiB
React
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();
|
|
});
|
|
});
|