ci / api / api (push) Successful in 19m12s
ci / ts / ts (packages + web) (push) Successful in 2m44s
ci / module boundaries / boundaries (push) Successful in 18m23s
ci / contract / contract (сгенерированный код совпадает с openapi.yaml) (push) Successful in 59s
ci / db / db (push) Failing after 18s
ci / mobile / mobile (Metro-бандл Android) (push) Successful in 1m18s
720 lines
47 KiB
TypeScript
720 lines
47 KiB
TypeScript
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(
|
||
<I18nextProvider i18n={i18n}>
|
||
<App session={session} router={router} queryClient={queryClient} />
|
||
</I18nextProvider>,
|
||
);
|
||
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();
|
||
});
|
||
});
|