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