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