// @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(); 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'); }); });