soulsync/webui/src/routes/import/-route.test.tsx
Antti Kettunen 9ba54bd82d
feat(form): add compact option groups
- add a size prop to OptionButtonGroup with a denser sm layout\n- use the compact filter group on the auto-import panel\n- keep the new size variants covered in form and route tests
2026-05-24 21:17:21 +03:00

319 lines
10 KiB
TypeScript

import { createMemoryHistory } from '@tanstack/react-router';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ShellBridge, ShellPageId } from '@/platform/shell/bridge';
import { createAppQueryClient } from '@/app/query-client';
import { AppRouterProvider, createAppRouter } from '@/app/router';
import type { ImportStagingFile } from './-import.types';
import { resetImportWorkflowStore } from './-import.store';
function createResponse(body: unknown, status = 200) {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
function createShellBridge(overrides: Partial<ShellBridge> = {}): ShellBridge {
return {
getCurrentProfileContext: vi.fn(() => ({ profileId: 2, isAdmin: true })),
isPageAllowed: vi.fn(() => true),
getProfileHomePage: vi.fn<() => ShellPageId>(() => 'discover'),
resolveLegacyPath: vi.fn<(pathname: string) => ShellPageId | null>(() => 'search'),
setActivePageChrome: vi.fn(),
activateLegacyPath: vi.fn(),
showReactHost: vi.fn(),
navigateToArtistDetail: vi.fn(),
playLibraryTrack: vi.fn(),
startStream: vi.fn(),
showLoadingOverlay: vi.fn(),
hideLoadingOverlay: vi.fn(),
...overrides,
};
}
function renderImportRoute(initialEntries = ['/import']) {
const queryClient = createAppQueryClient();
const history = createMemoryHistory({ initialEntries });
const router = createAppRouter({ history, queryClient });
return {
history,
router,
...render(<AppRouterProvider router={router} queryClient={queryClient} />),
};
}
function getFetchUrls() {
return vi
.mocked(fetch)
.mock.calls.map(([input]) => (input instanceof Request ? input.url : String(input)));
}
describe('import route', () => {
let albumMatchBodies: Record<string, unknown>[];
let stagingFilesPayload: ImportStagingFile[];
beforeEach(() => {
albumMatchBodies = [];
stagingFilesPayload = [
{
filename: '01-track.flac',
rel_path: 'Album/01-track.flac',
full_path: '/music/Staging/Album/01-track.flac',
title: 'Track One',
artist: 'Artist A',
album: 'Album A',
extension: '.flac',
},
{
filename: '02-track.flac',
rel_path: 'Album/02-track.flac',
full_path: '/music/Staging/Album/02-track.flac',
title: 'Track Two',
artist: 'Artist A',
album: 'Album A',
extension: '.flac',
},
];
resetImportWorkflowStore();
window.SoulSyncWebShellBridge = createShellBridge();
window.showToast = vi.fn();
window.showConfirmDialog = vi.fn(async () => true);
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
if (url.includes('/api/import/staging/files')) {
return createResponse({
success: true,
staging_path: '/music/Staging',
files: stagingFilesPayload,
});
}
if (url.includes('/api/import/staging/groups')) {
return createResponse({
success: true,
groups: [
{
album: 'Album A',
artist: 'Artist A',
file_count: 2,
file_paths: ['/music/Staging/Album/01-track.flac'],
},
],
});
}
if (url.includes('/api/import/staging/suggestions')) {
return createResponse({
success: true,
ready: true,
suggestions: [
{
id: 'album-1',
name: 'Album A',
artist: 'Artist A',
source: 'deezer',
total_tracks: 1,
release_date: '2026-01-01',
},
],
});
}
if (url.includes('/api/import/search/albums')) {
return createResponse({
success: true,
albums: [
{
id: 'album-1',
name: 'Album A',
artist: 'Artist A',
source: 'deezer',
total_tracks: 1,
release_date: '2026-01-01',
},
],
});
}
if (url.includes('/api/import/album/match')) {
const body =
input instanceof Request
? ((await input.clone().json()) as Record<string, unknown>)
: (JSON.parse(typeof init?.body === 'string' ? init.body : '{}') as Record<
string,
unknown
>);
albumMatchBodies.push(body);
return createResponse({
success: true,
received: body,
album: {
id: 'album-1',
name: 'Album A',
artist: 'Artist A',
source: 'deezer',
total_tracks: 1,
release_date: '2026-01-01',
},
matches: [
{
track: { name: 'Track One', track_number: 1 },
staging_file: {
filename: '01-track.flac',
full_path: '/music/Staging/Album/01-track.flac',
},
confidence: 0.95,
},
],
});
}
if (url.includes('/api/auto-import/status')) {
return createResponse({
success: true,
running: true,
current_status: 'idle',
active_imports: [],
});
}
if (url.includes('/api/auto-import/settings')) {
return createResponse({
success: true,
scan_interval: 60,
confidence_threshold: 0.9,
});
}
if (url.includes('/api/auto-import/results')) {
return createResponse({
success: true,
results: [
{
id: 4,
status: 'pending_review',
folder_hash: 'hash-1',
folder_name: 'Album A',
album_name: 'Album A',
artist_name: 'Artist A',
confidence: 0.82,
total_files: 2,
},
],
});
}
return createResponse({ success: true });
}) as unknown as typeof fetch,
);
});
it('renders the import page through the app router', async () => {
const { history } = renderImportRoute();
await waitFor(() => expect(screen.getByTestId('import-page')).toBeInTheDocument());
expect(await screen.findByText('Import Music')).toBeInTheDocument();
expect(screen.getByText('Import: /music/Staging')).toBeInTheDocument();
await waitFor(() => expect(history.location.pathname).toBe('/import/album'));
await waitFor(() =>
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(true),
);
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
true,
);
expect(window.SoulSyncWebShellBridge?.showReactHost).toHaveBeenCalledWith('import');
expect(window.SoulSyncWebShellBridge?.setActivePageChrome).toHaveBeenCalledWith('import');
});
it('stores the active tab in nested route paths', async () => {
const { history } = renderImportRoute();
fireEvent.click(await screen.findByRole('link', { name: 'Singles' }));
await waitFor(() => expect(history.location.pathname).toBe('/import/singles'));
expect(screen.getByRole('button', { name: /Process Selected\s*0/ })).toBeInTheDocument();
});
it('keeps client workflow drafts across page remounts', async () => {
const view = renderImportRoute();
const searchInput = await screen.findByPlaceholderText('Search for an album...');
fireEvent.change(searchInput, { target: { value: 'half matched album' } });
view.unmount();
renderImportRoute();
expect(await screen.findByDisplayValue('half matched album')).toBeInTheDocument();
});
it('keeps singles selection tied to file identity across refreshes', async () => {
renderImportRoute(['/import/singles']);
const secondTrack = await screen.findByLabelText('Select 02-track.flac');
fireEvent.click(secondTrack);
stagingFilesPayload = [
{
filename: '00-intro.flac',
rel_path: 'Album/00-intro.flac',
full_path: '/music/Staging/Album/00-intro.flac',
title: 'Intro',
artist: 'Artist A',
album: 'Album A',
extension: '.flac',
},
...stagingFilesPayload,
];
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }));
await waitFor(() =>
expect(screen.getByRole('checkbox', { name: 'Select 02-track.flac' })).toBeChecked(),
);
expect(screen.getByRole('checkbox', { name: 'Select 01-track.flac' })).not.toBeChecked();
expect(screen.getByRole('button', { name: /Process Selected\s*1/ })).toBeInTheDocument();
});
it('preserves album source details when matching an album', async () => {
renderImportRoute();
const albumButtons = await screen.findAllByRole('button', { name: /Album A/ });
fireEvent.click(albumButtons[albumButtons.length - 1]);
await waitFor(() => expect(screen.getByText('Track Matching')).toBeInTheDocument());
expect(albumMatchBodies.at(-1)).toMatchObject({
source: 'deezer',
album_name: 'Album A',
album_artist: 'Artist A',
});
});
it('renders auto-import results from route search state', async () => {
renderImportRoute(['/import/auto?autoFilter=pending']);
expect(await screen.findByRole('button', { name: /^Needs Review\s*1$/ })).toBeInTheDocument();
expect(screen.getAllByText('Album A').length).toBeGreaterThan(0);
expect(screen.getByText('Watching')).toHaveAttribute('data-tone', 'success');
const compactFilterGroup = document.querySelector('#auto-import-results [data-size="sm"]');
expect(compactFilterGroup).toBeInTheDocument();
const intervalSelect = document.getElementById('auto-import-interval');
if (!(intervalSelect instanceof HTMLElement)) {
throw new Error('auto-import interval select missing');
}
expect(intervalSelect).toHaveAttribute('data-size', 'sm');
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/groups'))).toBe(false);
expect(getFetchUrls().some((url) => url.includes('/api/import/staging/suggestions'))).toBe(
false,
);
});
});