Files
ToBePlayed-Frontend/src/pages/AdminHintsPage.test.jsx
T
lobo 8598024bce feat(hint): targets, automatic hints and formatting
Target fields on the admin hints page. The dashboard shows the four
automatic hints after admin hints when their condition is true, using
data Core already serves. HintText renders **bold** and safe links (app
path or https) as React elements, never HTML, in hints and news.
Auto-close without tick hides for this session, X for 7 days, tick + X
for good.
2026-10-02 14:36:49 +02:00

174 lines
8.1 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 AdminHintsPage from './AdminHintsPage.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 DAY_MS = 24 * 60 * 60 * 1000;
const at = (offsetDays) => new Date(Date.now() + offsetDays * DAY_MS).toISOString();
const ACTIVE = {id: 2, textEn: 'Server update tonight', textDe: null, kind: 'DISMISS', startsAt: at(-1), endsAt: at(1), status: 'ACTIVE',
createdBy: 1, createdAt: at(-1), doneCount: 3};
const ENDED = {...ACTIVE, id: 1, textEn: 'Old news', startsAt: at(-3), endsAt: at(-2), status: 'ENDED', doneCount: 0};
const withRoutes = (routes) => fetchRoutes({
'GET /api/admin/hints': json(200, {results: [ACTIVE, ENDED],
automatic: [{key: 'NO_STEAM', enabled: true}, {key: 'PILE_GREW', enabled: true}]}),
'POST /api/admin/hints': json(201, {id: 3}),
'PUT /api/admin/hints/2/end': json(204),
'PUT /api/admin/hints/auto/PILE_GREW': json(204),
...routes,
});
const calls = (method) => fetch.mock.calls.filter(([, init]) => (init?.method ?? 'GET') === method);
const renderPage = () => render(<MemoryRouter><PreferencesProvider><AdminHintsPage/></PreferencesProvider></MemoryRouter>);
describe('AdminHintsPage', () => {
beforeEach(() => {
localStorage.setItem('tbp_session', '1');
withRoutes({});
});
afterEach(() => {
cleanup();
localStorage.clear();
vi.clearAllMocks();
});
it('lists the hints and offers "End" only on a running one', async () => {
renderPage();
expect(await screen.findByText('Server update tonight')).toBeTruthy();
expect(screen.getByText('ADMIN_HINTS.STATUS_ACTIVE')).toBeTruthy();
expect(screen.getByText('ADMIN_HINTS.STATUS_ENDED')).toBeTruthy();
expect(screen.getAllByText('ADMIN_HINTS.END')).toHaveLength(1);
fireEvent.click(screen.getByText('ADMIN_HINTS.END'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.ENDED'));
expect(calls('PUT')[0][0]).toContain('/api/admin/hints/2/end');
});
it('publishes a trimmed hint and shows a preview', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: ' Link your Steam account '}});
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_DE'), {target: {value: ' '}});
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.KIND_ACCEPT'));
expect(screen.getByText('HINT.OK')).toBeTruthy();
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.CREATED'));
const body = JSON.parse(calls('POST')[0][1].body);
expect(body).toMatchObject({textEn: 'Link your Steam account', textDe: '', kind: 'ACCEPT'});
expect(new Date(body.endsAt) - new Date(body.startsAt)).toBe(7 * DAY_MS);
expect(screen.getByLabelText('ADMIN_HINTS.TEXT_EN').value).toBe('');
});
it('sends the target groups, empty ones as null', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'News'}});
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.TARGET_NO_STEAM'));
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_NEW_DAYS'), {target: {value: '7'}});
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TARGET_ROLE'), {target: {value: 'AUDITOR'}});
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(calls('POST')).toHaveLength(1));
expect(JSON.parse(calls('POST')[0][1].body)).toMatchObject({
targetNoSteam: true, targetRole: 'AUDITOR', targetNewDays: 7, targetEmptyLibrary: false});
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.CREATED'));
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'More news'}});
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(calls('POST')).toHaveLength(2));
expect(JSON.parse(calls('POST')[1][1].body)).toMatchObject({
targetNoSteam: false, targetRole: null, targetNewDays: null, targetEmptyLibrary: false});
});
it('shows the role labels for the role target', async () => {
renderPage();
expect(await screen.findByRole('option', {name: 'ROLES.AUDITOR'})).toBeTruthy();
});
it('turns an automatic hint off', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.click(screen.getByLabelText('ADMIN_HINTS.AUTO_PILE_GREW'));
await waitFor(() => expect(toast.success).toHaveBeenCalledWith('ADMIN_HINTS.AUTO_SAVED'));
const [url, init] = calls('PUT')[0];
expect(url).toContain('/api/admin/hints/auto/PILE_GREW');
expect(JSON.parse(init.body)).toEqual({enabled: false});
});
it('previews bold text', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'Read **this**'}});
expect(document.querySelector('[role="status"] strong').textContent).toBe('this');
});
it('explains an invalid time range', async () => {
withRoutes({'GET /api/admin/hints': json(200, {results: []}), 'POST /api/admin/hints': json(400, {code: 'INVALID_HINT'})});
renderPage();
await screen.findByText('ADMIN_HINTS.EMPTY');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'News'}});
fireEvent.click(screen.getByText('ADMIN_HINTS.CREATE'));
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('ADMIN_HINTS.INVALID'));
});
it('takes the status from the server, not from the browser clock', async () => {
// The server ended the hint by its own clock; by the browser clock the end is still 5 s away.
const endedOnServer = {...ACTIVE, endsAt: new Date(Date.now() + 5_000).toISOString(), status: 'ENDED'};
withRoutes({'GET /api/admin/hints': json(200, {results: [endedOnServer]})});
renderPage();
await screen.findByText('Server update tonight');
expect(screen.queryByText('ADMIN_HINTS.STATUS_ACTIVE')).toBeNull();
expect(screen.queryByText('ADMIN_HINTS.END')).toBeNull();
});
it('previews the English text when the German one is only spaces', async () => {
renderPage();
await screen.findByText('Server update tonight');
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_EN'), {target: {value: 'Link your Steam account'}});
fireEvent.change(screen.getByLabelText('ADMIN_HINTS.TEXT_DE'), {target: {value: ' '}});
// The mocked UI language is German.
expect(document.querySelector('[role="status"] p').textContent).toBe('Link your Steam account');
});
it('shows "no access" to a non-admin', async () => {
withRoutes({'GET /api/admin/hints': json(403, {})});
renderPage();
expect(await screen.findByText('ADMIN_HINTS.NO_ACCESS')).toBeTruthy();
expect(screen.queryByText('ADMIN_HINTS.CREATE')).toBeNull();
});
});
describe('ADMIN_HINTS.DONE_COUNT', () => {
it('uses the singular for one user', async () => {
const {default: i18next} = await import('i18next');
const en = (await import('../../public/i18n/en.json')).default;
const i18n = i18next.createInstance();
await i18n.init({lng: 'en', resources: {en: {translation: en}}});
expect(i18n.t('ADMIN_HINTS.DONE_COUNT', {count: 1})).not.toContain('users');
});
});