import { AuthSession, refreshListening } 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 { albumTracks, fakeApi, makeUser, type FakeUser } from './test/fakeApi'; afterEach(cleanup); describe('social', () => { it('finds music beyond the last text page without acknowledging unread messages', async () => { const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true }); for (let seq = 1; seq <= 61; seq++) api.messages.push({ id: crypto.randomUUID(), seq, sender_id: bob.id, recipient: alice.id, text: seq === 1 ? albumTracks[0]!.title : `Сообщение ${seq}`, attachment_kind: seq === 1 ? 'track' : 'none', ...(seq === 1 ? { attachment_id: albumTracks[0]!.id } : {}), created_at: new Date().toISOString(), read: true }); await queryClient.invalidateQueries({ queryKey: ['social'] }); await user.click(await screen.findByRole('button', { name: 'Музыка из переписки' })); await screen.findByRole('link', { name: 'Открыть трек' }); expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument(); const firstCall = api.calls.length; api.messages.push({ id: crypto.randomUUID(), seq: 62, sender_id: bob.id, recipient: alice.id, text: 'Новая музыка', attachment_kind: 'track', attachment_id: albumTracks[0]!.id, created_at: new Date().toISOString(), read: false }); await queryClient.invalidateQueries({ queryKey: ['social'] }); await screen.findByText('Новая музыка'); expect(api.messages.at(-1)?.read).toBe(false); expect(api.calls.slice(firstCall).some(c => c.path.endsWith('/actions') && (c.body as { action?: string })?.action === 'read_chat')).toBe(false); }); it('keeps the current conversation when another friend sends newer messages and confirms blocking', async () => { const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const carol = makeUser('carol', 'user'); const { api, user, queryClient } = await renderApp([alice, bob, carol], '/friends', { signedInAs: alice }); for (const friend of [bob, carol]) api.friendships.set([alice.id, friend.id].sort().join(':'), { sender: friend.id, accepted: true }); await queryClient.invalidateQueries({ queryKey: ['social'] }); await screen.findByRole('heading', { name: 'bob' }); await user.type(screen.getByRole('textbox', { name: 'Сообщение' }), 'Черновик для bob'); api.messages.push({ id: crypto.randomUUID(), seq: 1, sender_id: carol.id, recipient: alice.id, text: 'Новое сообщение от carol', attachment_kind: 'none', created_at: new Date().toISOString(), read: false }); await queryClient.invalidateQueries({ queryKey: ['social'] }); await screen.findByText('Новое сообщение от carol'); expect(screen.getByRole('heading', { name: 'bob' })).toBeInTheDocument(); expect(screen.getByRole('textbox', { name: 'Сообщение' })).toHaveValue('Черновик для bob'); await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' })); await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' })); const confirm = await screen.findByRole('dialog', { name: 'Заблокировать bob?' }); expect(api.friendships.size).toBe(2); await user.click(within(confirm).getByRole('button', { name: 'Отмена' })); expect(api.friendships.size).toBe(2); await user.click(screen.getByRole('button', { name: 'Действия с пользователем bob' })); await user.click(screen.getByRole('menuitem', { name: 'Заблокировать' })); await user.click(within(await screen.findByRole('dialog', { name: 'Заблокировать bob?' })).getByRole('button', { name: 'Заблокировать' })); await waitFor(() => expect(api.friendships.size).toBe(1)); expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument(); }); it('follows a room with locked playback controls, independent volume and local exit', async () => { const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const { api, session, user } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); const id = crypto.randomUUID(); api.listeningRooms.set(id, { id, owner_id: bob.id, owner_name: 'bob', device_id: crypto.randomUUID(), track_id: albumTracks[0]!.id, position_ms: 5000, playing: false, updated_at: new Date().toISOString(), participants: [] }); api.listeningMembers.set(alice.id, id); api.listeningMembers.set(bob.id, id); refreshListening(session); await screen.findByText('Слушаете вместе с bob'); const player = screen.getByRole('contentinfo', { name: 'Плеер' }); await waitFor(() => expect(within(player).getByRole('button', { name: 'Слушать' })).toBeDisabled()); expect(within(player).getByRole('slider', { name: 'Громкость' })).toBeEnabled(); await user.click(screen.getByRole('button', { name: 'Выйти из прослушивания' })); await waitFor(() => expect(screen.queryByText('Слушаете вместе с bob')).not.toBeInTheDocument()); expect(api.listeningMembers.has(alice.id)).toBe(false); }); it('edits, replies, reacts, searches, deletes and preserves a draft between sections', async () => { const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: true }); await queryClient.invalidateQueries({ queryKey: ['social'] }); await user.click(await screen.findByRole('button', { name: /bob Начать разговор/ })); const composer = () => screen.getByRole('textbox', { name: 'Сообщение' }); await user.type(composer(), 'Первый вариант'); await user.click(screen.getByRole('button', { name: 'Отправить' })); await screen.findByText('Первый вариант'); const actions = async (name: string) => { await user.click((await screen.findAllByRole('button', { name: 'Действия с сообщением' }))[0]!); await user.click(await screen.findByRole('menuitem', { name })); }; await actions('Редактировать'); await user.clear(composer()); await user.type(composer(), 'Исправленный текст'); await user.click(screen.getByRole('button', { name: 'Сохранить' })); await screen.findByText('Исправленный текст', { exact: true }); await actions('❤️'); await screen.findByRole('button', { name: '❤️ 1' }); await actions('Ответить'); await user.type(composer(), 'Ответ на музыку'); await user.click(screen.getByRole('button', { name: 'Отправить' })); await waitFor(() => expect(api.messages).toHaveLength(2)); expect(api.messages[1]?.reply_to).toBe(api.messages[0]?.id); await user.click(screen.getByRole('button', { name: 'Поиск в переписке' })); await user.type(screen.getByRole('searchbox', { name: 'Поиск в переписке' }), 'Исправленный'); await waitFor(() => expect(screen.queryByText('Ответ на музыку', { exact: true })).not.toBeInTheDocument()); await actions('Удалить для всех'); await waitFor(() => expect(api.messages[0]?.deleted).toBe(true)); await user.clear(screen.getByRole('searchbox', { name: 'Поиск в переписке' })); await user.type(composer(), 'Мой черновик'); await user.click(screen.getByRole('button', { name: 'Друзья' })); expect(screen.queryByRole('textbox', { name: 'Сообщение' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Написать' })); expect(composer()).toHaveValue('Мой черновик'); }); it('saves privacy and independent request admission preferences', async () => { const alice = makeUser('alice', 'user'); const { api, user } = await renderApp([alice], '/friends', { signedInAs: alice }); await user.click(await screen.findByRole('button', { name: 'Мой статус скрыт' })); const privacy = await screen.findByRole('group', { name: 'Показывать мой статус' }); expect(within(privacy).getByRole('button', { name: 'Никому' })).toHaveAttribute('aria-pressed', 'true'); await user.click(within(privacy).getByRole('button', { name: 'Друзьям' })); await waitFor(() => expect(api.activityVisibility.get(alice.id)).toBe('friends')); await user.click(screen.getByText('Слушать вместе', { exact: true })); const requests = await screen.findByRole('group', { name: 'Принимать запросы и приглашения от' }); const auto = screen.getByRole('group', { name: 'Автоматически принимать от' }); await user.click(within(requests).getByRole('button', { name: 'Друзьям' })); await waitFor(() => expect(api.listeningPreferences.get(alice.id)?.requests).toBe('friends')); await waitFor(() => expect(within(auto).getByRole('button', { name: 'Всем' })).toBeEnabled()); await user.click(within(auto).getByRole('button', { name: 'Всем' })); await waitFor(() => expect(api.listeningPreferences.get(alice.id)).toMatchObject({ requests: 'friends', auto_accept: 'everyone' })); }); it('shows persistent notifications and acknowledges only the chosen event', async () => { const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user'); const { api, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); await screen.findByRole('heading', { name: 'Друзья и общение' }); api.notifications.push({ id: crypto.randomUUID(), recipient: alice.id, actor_id: bob.id, actor_name: 'bob', kind: 'friend_request', created_at: new Date().toISOString(), read: false }); await queryClient.invalidateQueries({ queryKey: ['social'] }); await user.click(await screen.findByRole('button', { name: 'Уведомления 1' })); await screen.findByText('Хочет добавить вас в друзья'); await user.click(screen.getByRole('button', { name: 'Прочитано' })); await waitFor(() => expect(screen.queryByRole('button', { name: 'Прочитано' })).not.toBeInTheDocument()); expect(api.notifications[0]?.read).toBe(true); expect(api.calls.filter(c => c.path.endsWith('/actions')).at(-1)?.body).toMatchObject({ action: 'read_notification', target_id: api.notifications[0]!.id }); }); it('searches, requests, cancels, accepts and exchanges messages and music', async () => { const alice = makeUser('alice', 'user'); const bob = makeUser('bob', 'user', { display_name: 'Борис' }); const { api, router, user, queryClient } = await renderApp([alice, bob], '/friends', { signedInAs: alice }); await user.click((await screen.findAllByRole('button', { name: 'Найти друзей' }))[0]!); await user.type(await screen.findByLabelText('Найти по имени или логину'), 'bob'); await user.click(await screen.findByRole('button', { name: 'В друзья' })); await screen.findByRole('button', { name: 'Отменить заявку' }); await user.click(screen.getByRole('button', { name: 'Отменить заявку' })); await screen.findByRole('button', { name: 'В друзья' }); api.friendships.set([alice.id, bob.id].sort().join(':'), { sender: bob.id, accepted: false }); await queryClient.invalidateQueries({ queryKey: ['social'] }); await user.click(await screen.findByRole('button', { name: 'Принять' })); await user.click(await screen.findByRole('button', { name: 'Написать' })); await user.type(await screen.findByLabelText('Сообщение', { exact: true }), 'Привет, послушай это'); await user.click(screen.getByRole('button', { name: 'Отправить' })); expect(await screen.findByText('Привет, послушай это')).toBeInTheDocument(); expect(api.messages).toHaveLength(1); await router.navigate({ to: '/track/$id', params: { id: albumTracks[0]!.id } }); await user.click(await screen.findByRole('button', { name: 'Слушать' })); const player = screen.getByRole('contentinfo', { name: 'Плеер' }); await user.click(within(player).getByRole('button', { name: 'Поделиться треком' })); await user.click(await screen.findByRole('button', { name: 'Борис · Отправить' })); await screen.findByRole('button', { name: 'Борис · Отправлено' }); expect(api.messages.at(-1)).toMatchObject({ attachment_kind: 'track', attachment_id: albumTracks[0]!.id, recipient: bob.id }); expect(screen.getByRole('button', { name: 'Борис · Отправлено' })).toBeDisabled(); }); }); 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, queryClient, 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: 'Поделиться треком' })); await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' })); await screen.findByRole('button', { name: 'Ссылка скопирована' }); 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('Другое устройство'); }); }); describe('recommendation preferences', () => { it('saves demographics, edits the age group and asks again after an admin reset', async () => { const admin = makeUser('owner', 'admin'); const { user, api, router, session } = await renderApp([admin], '/settings', { signedInAs: admin }); await user.click(await screen.findByRole('button', { name: 'Настроить рекомендации' })); await user.click(await screen.findByRole('button', { name: 'Мужской' })); await user.click(screen.getByRole('button', { name: '13–19' })); await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!); await screen.findByRole('button', { name: 'Настроить рекомендации' }); expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toMatchObject({ gender: 'male', age_group: '13_19' }); await user.click(screen.getByRole('button', { name: 'Настроить рекомендации' })); expect(await screen.findByRole('button', { name: '13–19' })).toHaveAttribute('aria-pressed', 'true'); await user.click(screen.getByRole('button', { name: '20–29' })); await user.click(screen.getAllByRole('button', { name: 'Сохранить' }).find((b) => !b.hasAttribute('disabled'))!); await screen.findByRole('button', { name: 'Настроить рекомендации' }); await session.client.POST('/api/v1/admin/users/{id}/reset-recommendations', { params: { path: { id: admin.id } } }); await router.navigate({ to: '/' }); await screen.findByRole('heading', { name: 'Что вы любите слушать?' }); expect(screen.getByRole('button', { name: 'Мужской' })).toHaveAttribute('aria-pressed', 'false'); expect(screen.getByRole('button', { name: '20–29' })).toHaveAttribute('aria-pressed', 'false'); await user.click(screen.getByRole('button', { name: 'Пропустить' })); await screen.findByRole('button', { name: 'Запустить «Мою волну»' }); expect(api.calls.filter((c) => c.path === '/api/v1/me/onboarding').at(-1)?.body).toEqual({ artist_ids: [], genres: [], gender: null, age_group: null }); }); }); describe('playlist sharing', () => { it('copies a stable read-only link and revokes it without exposing editing controls', async () => { const alice = makeUser('alice', 'user'); const { user, router, session, api } = await renderApp([alice], '/', { signedInAs: alice }); const created = await session.client.POST('/api/v1/me/playlists', { body: { name: 'Общий микс', description: 'Для друзей', track_ids: [albumTracks[0]!.id] } }); const playlist = created.data!; await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } }); await screen.findByRole('heading', { name: 'Общий микс' }); await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' })); await user.click(await screen.findByRole('button', { name: 'Скопировать ссылку' })); await screen.findByRole('button', { name: 'Ссылка скопирована' }); await user.click(within(screen.getByRole('dialog')).getByRole('button', { name: 'Закрыть' })); const shareId = api.playlists.get(playlist.id)!.share_id!; expect(await navigator.clipboard.readText()).toBe(`${window.location.origin}/shared/playlist/${shareId}`); await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' })); await waitFor(() => expect(screen.getByRole('button', { name: 'Поделиться плейлистом' })).toBeEnabled()); expect(api.calls.filter(c => c.method === 'POST' && c.path.endsWith('/share'))).toHaveLength(2); expect(api.playlists.get(playlist.id)?.share_id).toBe(shareId); await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } }); await screen.findByRole('heading', { name: 'Общий микс' }); expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Управлять треками' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Удалить плейлист' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument(); await user.click(screen.getByRole('button', { name: 'Слушать всё' })); await screen.findByRole('contentinfo', { name: 'Плеер' }); await router.navigate({ to: '/playlist/$id', params: { id: playlist.id } }); await user.click(await screen.findByRole('button', { name: 'Отключить доступ по ссылке' })); await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeNull()); await user.click(screen.getByRole('button', { name: 'Поделиться плейлистом' })); await waitFor(() => expect(api.playlists.get(playlist.id)?.share_id).toBeTruthy()); expect(api.playlists.get(playlist.id)?.share_id).not.toBe(shareId); await router.navigate({ to: '/shared/playlist/$shareId', params: { shareId } }); await screen.findByRole('alert'); expect(screen.queryByRole('heading', { name: 'Общий микс' })).not.toBeInTheDocument(); }); it('returns to the shared playlist after login without starting playback', async () => { const bob = makeUser('bob', 'user'); const shareId = '00000000-0000-4000-9001-000000000001'; const { api, user, router } = await renderApp([bob], `/shared/playlist/${shareId}`); api.playlists.set('00000000-0000-4000-9000-000000000001', { id: '00000000-0000-4000-9000-000000000001', name: 'От друга', description: '', revision: 1, tracks: [albumTracks[0]!], track_count: 1, duration_ms: 200000, created_at: '2026-01-01T00:00:00Z', updated_at: '2026-01-01T00:00:00Z', share_id: shareId }); await screen.findByRole('heading', { name: 'Вход' }); await user.type(screen.getByLabelText('Логин'), bob.username); await user.type(screen.getByLabelText('Пароль'), bob.password); await user.click(screen.getByRole('button', { name: 'Войти' })); await screen.findByRole('heading', { name: 'От друга' }); expect(router.state.location.pathname).toBe(`/shared/playlist/${shareId}`); expect(screen.queryByRole('button', { name: 'Пауза' })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: 'Изменить плейлист' })).not.toBeInTheDocument(); }); });