feat(dashboard): show admin hints and add the hints admin page
Build, Test & Push Frontend / quality-check (push) Successful in 18s
Build, Test & Push Frontend / docker (push) Successful in 25s

This commit is contained in:
lobo committed 2026-09-28 10:07:49 +02:00
1 parent 6601277961
commit 01ae372b2d
12 files changed
+700 -6

No files matched your search

+123
View File
@@ -0,0 +1,123 @@
// @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';
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 answer = (status, body) => ({ok: status < 300, status, headers: new Headers(), json: async () => body});
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');
globalThis.fetch = vi.fn(async (url, init) => {
if (init?.method === 'POST') return answer(201, {id: 3});
if (init?.method === 'PUT') return answer(204);
return answer(200, {results: [ACTIVE, ENDED]});
});
});
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('explains an invalid time range', async () => {
fetch.mockImplementation(async (url, init) =>
(init?.method === 'POST' ? answer(400, {code: 'INVALID_HINT'}) : answer(200, {results: []})));
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'};
fetch.mockImplementation(async () => answer(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 () => {
fetch.mockImplementation(async () => answer(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');
});
});