Files
pipu-music/apps/web/src/App.test.tsx
T
mrleo1nid 1a693848f2
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
Redesign social conversations and shared music
2026-10-23 11:27:47 +03:00

720 lines
47 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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();
});
});