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 axe from 'axe-core'; 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'); const { user } = await renderApp([mod], '/', { signedInAs: mod }); await user.click(await screen.findByRole('button', { name: 'Админка' })); 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.each(['root', 'anna'])('lets admins change the username of %s', async (target) => { const root = makeUser('root', 'admin'); const anna = makeUser('anna', 'user'); const { user, api, session } = await renderApp([root, anna], '/admin/users', { signedInAs: root }); await user.click(await screen.findByRole('button', { name: `Действия с ${target}` })); await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' })); const dialog = screen.getByRole('dialog', { name: 'Изменить логин' }); const input = within(dialog).getByLabelText('Логин'); await user.clear(input); await user.type(input, `new.${target}`); await user.click(within(dialog).getByRole('button', { name: 'Сохранить' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect(await screen.findByText(`@new.${target}`)).toBeInTheDocument(); expect(api.users.get(target === 'root' ? root.id : anna.id)?.username).toBe(`new.${target}`); if (target === 'root') expect(session.state).toMatchObject({ status: 'signed_in', user: { username: 'new.root' } }); }); it('keeps the username dialog open when the new username is taken', async () => { const root = makeUser('root', 'admin'); const anna = makeUser('anna', 'user'); const { user, api } = await renderApp([root, anna], '/admin/users', { signedInAs: root }); await user.click(await screen.findByRole('button', { name: 'Действия с anna' })); await user.click(screen.getByRole('menuitem', { name: 'Изменить логин' })); const dialog = screen.getByRole('dialog', { name: 'Изменить логин' }); const input = within(dialog).getByLabelText('Логин'); await user.clear(input); await user.type(input, 'ROOT'); await user.click(within(dialog).getByRole('button', { name: 'Сохранить' })); await waitFor(() => expect(input).toHaveAttribute('aria-invalid', 'true')); expect(api.users.get(anna.id)?.username).toBe('anna'); expect(dialog).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('opens a shared track after login and copies its page URL from the player', async () => { const alice = makeUser('alice', 'user'); const path = '/track/00000000-0000-4000-8000-000000000101'; const { user, router } = await renderApp([alice], path); await screen.findByRole('heading', { name: 'Вход' }); await user.type(screen.getByLabelText('Логин'), alice.username); 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(path); await user.click(screen.getByRole('button', { name: 'Слушать' })); const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); await user.click(within(bar).getByRole('button', { name: 'Поделиться треком' })); expect(await within(bar).findByRole('status')).toHaveTextContent('Ссылка на трек скопирована'); expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}${path}`); }); it('shows a missing shared track without starting playback', async () => { const alice = makeUser('alice', 'user'); await renderApp([alice], '/track/missing', { signedInAs: alice }); expect(await screen.findByRole('alert')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument(); }); 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="listitem"]')!; 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('suggest tracks', () => { it('warns that lossless formats are the ones to send', async () => { const admin = makeUser('admin', 'admin'); await renderApp([admin], '/uploads/new', { signedInAs: admin }); expect(await screen.findByText(/Нужны FLAC, WAV или AIFF/)).toBeInTheDocument(); expect(screen.getByText(/MP3 — только в крайнем случае/)).toBeInTheDocument(); }); }); describe('accessibility', () => { it('home and login have no serious axe violations', async () => { const alice = makeUser('alice', 'user'); await renderApp([alice], '/', { signedInAs: alice }); await screen.findByRole('button', { name: 'Бегаю' }); const home = await axe.run(document.body, { resultTypes: ['violations'] }); expect(home.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]); cleanup(); await renderApp([], '/login'); await screen.findByRole('heading', { name: 'Вход' }); const login = await axe.run(document.body, { resultTypes: ['violations'] }); expect(login.violations.filter((v) => v.impact === 'serious' || v.impact === 'critical')).toEqual([]); }); }); describe('safeRedirect', () => { it.each([ ['/settings', '/settings'], ['//evil.example', '/'], ['https://evil.example', '/'], [undefined, '/'], ])('%s → %s', (input, expected) => { expect(safeRedirect(input)).toBe(expected); }); }); describe('playlists', () => { it('adds the playing track from the player, keeping the selected track when playback advances', async () => { const alice = makeUser('alice', 'user'); const { user, api } = await renderApp([alice], '/album/00000000-0000-4000-8000-0000000000bb', { signedInAs: alice }); await screen.findByRole('heading', { name: 'Группа крови' }); await user.click(screen.getAllByRole('listitem')[0]!); const bar = screen.getByRole('contentinfo', { name: 'Плеер' }); await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' })); await user.click(await screen.findByRole('button', { name: 'Создать плейлист' })); await user.type(screen.getByLabelText('Название'), 'Из плеера'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); await waitFor(() => expect(api.playlists.size).toBe(1)); expect([...api.playlists.values()][0]?.tracks[0]?.title).toBe('Группа крови'); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); await user.click(within(bar).getByRole('button', { name: 'Добавить в плейлист' })); // Simulate automatic advancement while the picker is open. await user.click(within(bar).getByRole('button', { name: 'Следующий' })); await user.click(await screen.findByRole('button', { name: 'Из плеера · 1' })); await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); expect([...api.playlists.values()][0]?.tracks.map(t => t.title)).toEqual(['Группа крови']); }); it('creates, edits, adds an album, reorders, removes and deletes a playlist', async () => { const alice = makeUser('alice', 'user'); const { user, router, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice }); await user.click(await screen.findByRole('button', { name: 'Создать плейлист' })); await user.type(screen.getByLabelText('Название'), 'В дорогу'); await user.type(screen.getByLabelText('Описание'), 'Музыка для дороги'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); await screen.findByRole('heading', { name: 'В дорогу' }); const id = [...api.playlists.keys()][0]!; expect(api.playlists.get(id)?.description).toBe('Музыка для дороги'); await router.navigate({ to: '/album/$id', params: { id: '00000000-0000-4000-8000-0000000000bb' } }); await screen.findByRole('heading', { name: 'Группа крови' }); await user.click(screen.getAllByRole('button', { name: 'Добавить в плейлист' })[0]!); await user.click(await screen.findByRole('button', { name: 'В дорогу · 0' })); await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(2)); await router.navigate({ to: '/playlist/$id', params: { id } }); await user.click(await screen.findByRole('button', { name: 'Управлять треками' })); await user.click(screen.getByRole('button', { name: 'Переместить «Закрой за мной дверь» выше' })); await waitFor(() => expect(api.playlists.get(id)?.tracks[0]?.title).toBe('Закрой за мной дверь')); await waitFor(() => expect(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })).toBeEnabled()); await user.click(screen.getByRole('button', { name: 'Убрать «Группа крови» из плейлиста' })); await waitFor(() => expect(api.playlists.get(id)?.tracks).toHaveLength(1)); await user.click(screen.getByRole('button', { name: 'Изменить плейлист' })); await user.clear(screen.getByLabelText('Название')); await user.type(screen.getByLabelText('Название'), 'Вечер'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); await screen.findByRole('heading', { name: 'Вечер' }); await user.click(screen.getByRole('button', { name: 'Удалить плейлист' })); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Удалить плейлист' })); await screen.findByRole('heading', { name: 'Плейлисты' }); expect(api.playlists.size).toBe(0); }); it('keeps stale editor open on conflict without overwriting newer changes', async () => { const alice = makeUser('alice', 'user'); const { user, api } = await renderApp([alice], '/collection/playlists', { signedInAs: alice }); await user.click(await screen.findByRole('button', { name: 'Создать плейлист' })); await user.type(screen.getByLabelText('Название'), 'Первый'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); await screen.findByRole('heading', { name: 'Первый' }); await user.click(screen.getByRole('button', { name: 'Изменить плейлист' })); const item = [...api.playlists.values()][0]!; api.playlists.set(item.id, { ...item, revision: 2, name: 'Другое устройство' }); await user.type(screen.getByLabelText('Название'), ' изменён'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); expect(await screen.findByRole('alert')).toHaveTextContent('Плейлист изменён на другом устройстве'); expect(screen.getByRole('dialog')).toBeInTheDocument(); expect(api.playlists.get(item.id)?.name).toBe('Другое устройство'); }); });