import { AuthSession } from '@pipu/core'; import { createI18n } from '@pipu/i18n'; import { QueryClient } from '@tanstack/react-query'; import { createMemoryHistory } from '@tanstack/react-router'; import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { I18nextProvider } from 'react-i18next'; import { afterEach, describe, expect, it } from 'vitest'; import { App } from './App'; import { createAppRouter, safeRedirect } from './app/router'; import { fakeApi, makeUser, type FakeUser } from './test/fakeApi'; afterEach(cleanup); async function renderApp(users: FakeUser[], path = '/', opts: { signedInAs?: FakeUser } = {}) { const api = fakeApi(users); const i18n = await createI18n('ru'); const session = new AuthSession({ baseUrl: 'http://localhost', fetch: api.fetch, clientType: 'web' }); if (opts.signedInAs) await session.login(opts.signedInAs.username, opts.signedInAs.password); else await session.restore(); const router = createAppRouter(session, { history: createMemoryHistory({ initialEntries: [path] }) }); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( , ); return { api, router, session, i18n, user: userEvent.setup() }; } describe('sign-in', () => { it('sends a guest to the login page and back after signing in', async () => { const alice = makeUser('alice', 'user', { display_name: 'Алиса' }); const { user, router } = await renderApp([alice], '/settings'); await screen.findByRole('heading', { name: 'Вход' }); expect(router.state.location.pathname).toBe('/login'); await user.type(screen.getByLabelText('Логин'), 'alice'); await user.type(screen.getByLabelText('Пароль'), 'wrong'); await user.click(screen.getByRole('button', { name: 'Войти' })); expect(await screen.findByRole('alert')).toHaveTextContent('Неверный логин или пароль'); await user.type(screen.getByLabelText('Пароль'), alice.password); await user.click(screen.getByRole('button', { name: 'Войти' })); await screen.findByRole('heading', { name: 'Настройки' }); expect(router.state.location.pathname).toBe('/settings'); }); it('shows the bootstrap hint on first start', async () => { await renderApp([makeUser('admin', 'admin', { must_change_credentials: true })], '/login'); expect(await screen.findByText(/Первый запуск/)).toBeInTheDocument(); }); it('forces the bootstrap admin to change credentials', async () => { const admin = makeUser('admin', 'admin', { must_change_credentials: true }); const { user, router, api } = await renderApp([admin], '/admin/users', { signedInAs: admin }); await screen.findByRole('heading', { name: 'Смените логин и пароль' }); expect(router.state.location.pathname).toBe('/setup/admin'); const login = screen.getByLabelText('Новый логин'); expect(login).toHaveValue('admin'); await user.clear(login); await user.type(login, 'owner'); await user.type(screen.getByLabelText('Новый пароль'), 'short'); await user.type(screen.getByLabelText('Повторите пароль'), 'shorter'); expect(screen.getByText('Пароли не совпадают')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Сохранить и продолжить' })).toBeDisabled(); await user.clear(screen.getByLabelText('Повторите пароль')); await user.type(screen.getByLabelText('Повторите пароль'), 'short'); await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' })); expect(await screen.findByText('Пароль должен быть не короче 12 символов')).toBeInTheDocument(); for (const label of ['Новый пароль', 'Повторите пароль']) { await user.clear(screen.getByLabelText(label)); await user.type(screen.getByLabelText(label), 'a-strong-owner-pass'); } await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' })); await screen.findByRole('button', { name: 'Запустить «Мою волну»' }); expect(router.state.location.pathname).toBe('/'); expect(api.calls.find((c) => c.path === '/api/v1/me/bootstrap-credentials' && c.body)?.body).toMatchObject({ new_username: 'owner', }); }); it('forces a temporary password to be replaced', async () => { const bob = makeUser('bob', 'user', { must_change_password: true }); const { user } = await renderApp([bob], '/', { signedInAs: bob }); await screen.findByRole('heading', { name: 'Задайте новый пароль' }); await user.type(screen.getByLabelText('Новый пароль'), 'bobs-own-pass'); await user.type(screen.getByLabelText('Повторите пароль'), 'bobs-own-pass'); await user.click(screen.getByRole('button', { name: 'Сохранить и продолжить' })); await screen.findByRole('button', { name: 'Запустить «Мою волну»' }); }); it('signs out from the profile menu', async () => { const alice = makeUser('alice', 'user'); const { user, router } = await renderApp([alice], '/', { signedInAs: alice }); await user.click(await screen.findByRole('button', { name: 'Меню профиля' })); await user.click(screen.getByRole('menuitem', { name: 'Выйти' })); await screen.findByRole('heading', { name: 'Вход' }); expect(router.state.location.pathname).toBe('/login'); }); }); describe('navigation by role', () => { it('hides the admin section from listeners and blocks the route', async () => { const alice = makeUser('alice', 'user'); const { router } = await renderApp([alice], '/admin/users', { signedInAs: alice }); await screen.findByRole('button', { name: 'Запустить «Мою волну»' }); expect(router.state.location.pathname).toBe('/'); expect(screen.queryByRole('link', { name: 'Пользователи' })).not.toBeInTheDocument(); }); it('shows users but not the audit log to moderators', async () => { const mod = makeUser('mod', 'moderator'); await renderApp([mod], '/', { signedInAs: mod }); expect(await screen.findByRole('link', { name: 'Пользователи' })).toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'Журнал' })).not.toBeInTheDocument(); }); it('shows the audit log to admins', async () => { const root = makeUser('root', 'admin'); await renderApp([root], '/admin/audit', { signedInAs: root }); expect(await screen.findByText('Записей пока нет')).toBeInTheDocument(); }); }); describe('users admin', () => { it('lets an admin create a moderator and shows the temporary password once', async () => { const root = makeUser('root', 'admin'); const anna = makeUser('anna', 'user', { last_login_at: new Date().toISOString() }); const { user } = await renderApp([root, anna], '/admin/users', { signedInAs: root }); const table = await screen.findByRole('table'); await within(table).findByText('@anna'); expect(within(table).getByText('(вы)')).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Создать пользователя' })); const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' }); await user.type(within(dialog).getByLabelText('Логин'), 'ilya'); await user.selectOptions(within(dialog).getByRole('combobox'), 'moderator'); await user.click(within(dialog).getByRole('button', { name: 'Создать' })); const secret = await screen.findByRole('dialog', { name: 'Временный пароль' }); expect(within(secret).getByTestId('temporary-password')).toHaveTextContent('Temp-Pass-123'); await user.click(within(secret).getByRole('button', { name: 'Готово' })); expect(screen.queryByTestId('temporary-password')).not.toBeInTheDocument(); expect(await within(table).findByText('@ilya')).toBeInTheDocument(); }); it('offers moderators only what they may do', async () => { const mod = makeUser('mod', 'moderator'); const anna = makeUser('anna', 'user'); const other = makeUser('other', 'moderator'); const { user } = await renderApp([mod, anna, other], '/admin/users', { signedInAs: mod }); await screen.findByText('@anna'); expect(screen.queryByRole('button', { name: 'Действия с other' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Действия с mod' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Действия с anna' })); const items = screen.getAllByRole('menuitem').map((i) => i.textContent); expect(items).toEqual(['Сбросить пароль', 'Заблокировать']); await user.keyboard('{Escape}'); await user.click(screen.getByRole('button', { name: 'Создать пользователя' })); const dialog = screen.getByRole('dialog', { name: 'Новый пользователь' }); expect(within(dialog).queryByRole('combobox')).not.toBeInTheDocument(); }); it('filters by role', async () => { const root = makeUser('root', 'admin'); const { user, api } = await renderApp([root, makeUser('anna', 'user'), makeUser('mod', 'moderator')], '/admin/users', { signedInAs: root, }); await screen.findByText('@anna'); await user.click(screen.getByRole('button', { name: 'Модератор' })); await waitFor(() => expect(screen.queryByText('@anna')).not.toBeInTheDocument()); expect(screen.getByText('@mod')).toBeInTheDocument(); expect(api.calls.some((c) => c.path === '/api/v1/admin/users' && c.method === 'GET')).toBe(true); }); }); describe('language and settings', () => { it('saves the language in the profile', async () => { const alice = makeUser('alice', 'user'); const { user, api } = await renderApp([alice], '/settings', { signedInAs: alice }); await screen.findByRole('heading', { name: 'Настройки' }); const groups = screen.getAllByRole('group', { name: 'Язык' }); await user.click(within(groups[0]!).getByRole('button', { name: 'EN' })); await screen.findByRole('heading', { name: 'Settings' }); await waitFor(() => expect(api.users.get(alice.id)?.locale).toBe('en')); }); it('applies the theme from the profile', async () => { const alice = makeUser('alice', 'user'); const { user } = await renderApp([alice], '/settings', { signedInAs: alice }); await user.click(await screen.findByRole('button', { name: 'Светлая' })); await waitFor(() => expect(document.documentElement.dataset.theme).toBe('light')); await user.click(screen.getByRole('button', { name: 'Тёмная' })); await waitFor(() => expect(document.documentElement.dataset.theme).toBe('dark')); }); it('switches the language on the login page without a session', async () => { const { user } = await renderApp([], '/login'); await user.click(await screen.findByRole('button', { name: 'EN' })); expect(await screen.findByRole('heading', { name: 'Sign in' })).toBeInTheDocument(); }); }); describe('search box', () => { it('opens the search page on Enter and an item with the arrows', async () => { const alice = makeUser('alice', 'user'); const { user, router } = await renderApp([alice], '/', { signedInAs: alice }); const box = await screen.findByRole('combobox', { name: 'Поиск' }); await user.type(box, 'rbyj'); const list = await screen.findByRole('listbox'); const options = within(list).getAllByRole('option'); expect(options.map((o) => o.textContent)).toEqual(['КиноИсполнитель', 'Группа кровиКино · 1988']); expect(screen.getByText('Показаны результаты для «кино»')).toBeInTheDocument(); await user.keyboard('{Enter}'); await waitFor(() => expect(router.state.location.pathname).toBe('/search')); expect(router.state.location.search).toEqual({ q: 'rbyj' }); await user.clear(box); await user.type(box, 'кино'); await screen.findByRole('listbox'); await user.keyboard('{ArrowDown}{Enter}'); await waitFor(() => expect(router.state.location.pathname).toBe('/artist/00000000-0000-4000-8000-0000000000aa')); expect(await screen.findByRole('heading', { name: 'Кино' })).toBeInTheDocument(); }); it('focuses on the slash key', async () => { const alice = makeUser('alice', 'user'); const { user } = await renderApp([alice], '/', { signedInAs: alice }); const box = await screen.findByRole('combobox', { name: 'Поиск' }); await user.keyboard('/'); expect(box).toHaveFocus(); }); }); describe('player', () => { it('plays an album from a clicked row, likes and pauses with the keyboard', async () => { const alice = makeUser('alice', 'user'); const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice }); const row = (await screen.findByText('Закрой за мной дверь')).closest('[role="row"]')!; await user.click(row); const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); await waitFor(() => expect(within(bar).getByText('Закрой за мной дверь')).toBeInTheDocument()); expect(row).toHaveAttribute('aria-current', 'true'); expect(within(bar).getByRole('button', { name: 'Пауза' })).toBeInTheDocument(); await user.click(within(bar).getByRole('button', { name: 'Нравится' })); await waitFor(() => expect(api.calls).toContainEqual({ method: 'PUT', path: '/api/v1/me/reactions/tracks/00000000-0000-4000-8000-000000000101', body: { reaction: 'like' }, }), ); expect(within(bar).getByRole('button', { name: 'Убрать из лайков' })).toHaveAttribute('aria-pressed', 'true'); // Пробел на кнопке нажимает её саму; горячая клавиша работает, когда фокус не на элементе управления. (document.activeElement as HTMLElement).blur(); await user.keyboard(' '); expect(within(bar).getByRole('button', { name: 'Слушать' })).toBeInTheDocument(); // Уход со страницы досылает события и сохраняет состояние плеера. window.dispatchEvent(new Event('pagehide')); await waitFor(() => { const sent = api.calls.filter((c) => c.path === '/api/v1/events').flatMap((c) => (c.body as { events: { type: string; context_type?: string }[] }).events); expect(sent.find((e) => e.type === 'play_start')).toMatchObject({ context_type: 'album' }); }); expect(api.calls.find((c) => c.method === 'PUT' && c.path === '/api/v1/me/playback-state')?.body).toMatchObject({ track_id: '00000000-0000-4000-8000-000000000101', queue_index: 1, }); }); }); describe('my wave', () => { it('starts the wave and switches the mood on the fly', async () => { const alice = makeUser('alice', 'user'); const { user, api } = await renderApp([alice], '/', { signedInAs: alice }); await user.click(await screen.findByRole('button', { name: 'Бегаю' })); await user.click(screen.getByRole('button', { name: 'Запустить «Мою волну»' })); const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); await waitFor(() => expect(within(bar).getByText('Группа крови')).toBeInTheDocument()); expect(api.calls.find((c) => c.method === 'POST' && c.path === '/api/v1/wave/sessions')?.body).toEqual({ activity: 'running', mood: null, }); expect(screen.getByRole('button', { name: 'Пауза «Моей волны»' })).toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Спокойное' })); await waitFor(() => expect(api.calls.find((c) => c.method === 'PATCH' && c.path.startsWith('/api/v1/wave/sessions/'))?.body).toEqual({ activity: 'running', mood: 'calm', }), ); expect(within(bar).getByText('Группа крови')).toBeInTheDocument(); // музыка не прервалась expect(screen.getByText('Бегаю · Спокойное')).toBeInTheDocument(); }); }); describe('safeRedirect', () => { it.each([ ['/settings', '/settings'], ['//evil.example', '/'], ['https://evil.example', '/'], [undefined, '/'], ])('%s → %s', (input, expected) => { expect(safeRedirect(input)).toBe(expected); }); });