Files
ReadMeABook/tests/app/setup/steps/PathsStep.test.tsx
T
kikootwo f23afc1ba2 Add Plex format coercion (.mp4 → .m4b)
Implement Plex-compatible file-extension coercion to avoid Plex silently ignoring .mp4 (and single-file .m4a) audiobooks (issue #166). Adds a DB migration and configuration key (plex_format_coercion_enabled, default true), exposes a toggle in the setup wizard and Admin Paths settings, and persists/reads the setting in the admin/setup APIs.

Introduces src/lib/utils/format-coercion.ts (coerceToPlexCompatible) and related constants in src/lib/constants/audio-formats.ts (PLEX_COMPATIBLE_EXTENSIONS, COERCION_RENAME_MAP, DRM_EXTENSIONS, TRANSCODE_REQUIRED_EXTENSIONS). The organize-files processor now runs coercion after organizing/tagging and before generating the filesHash and triggering scans; coercion is idempotent, never overwrites existing targets, logs warnings on DRM/transcode/permission errors, and is non-fatal.

Adds unit tests for the coercion util and updates processor & setup UI tests. Updates documentation (TABLEOFCONTENTS, file-organization, fixes/file-hash-matching, settings-pages) describing behavior, config, and constraints.
2026-05-15 19:33:59 -04:00

120 lines
3.3 KiB
TypeScript

/**
* Component: Setup Paths Step Tests
* Documentation: documentation/setup-wizard.md
*/
// @vitest-environment jsdom
import React, { useState } from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PathsStep } from '@/app/setup/steps/PathsStep';
const PathsHarness = ({
onNext,
onBack,
initialState,
}: {
onNext: () => void;
onBack: () => void;
initialState?: Partial<React.ComponentProps<typeof PathsStep>>;
}) => {
const [state, setState] = useState({
downloadDir: '/downloads',
mediaDir: '/media/audiobooks',
metadataTaggingEnabled: true,
plexFormatCoercionEnabled: true,
chapterMergingEnabled: false,
...initialState,
});
return (
<PathsStep
{...state}
onUpdate={(field, value) => setState((prev) => ({ ...prev, [field]: value }))}
onNext={onNext}
onBack={onBack}
/>
);
};
describe('PathsStep', () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it('validates paths and allows navigation on success', async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
success: true,
message: 'Directories are ready',
downloadDirValid: true,
mediaDirValid: true,
}),
});
vi.stubGlobal('fetch', fetchMock);
const onNext = vi.fn();
render(<PathsHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Validate Paths' }));
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/setup/test-paths', expect.any(Object));
});
expect(screen.getByText('Directories are ready')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(onNext).toHaveBeenCalled();
});
it('requires validation before proceeding', async () => {
const onNext = vi.fn();
render(<PathsHarness onNext={onNext} onBack={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
expect(screen.getByText('Please validate the paths before proceeding')).toBeInTheDocument();
expect(onNext).not.toHaveBeenCalled();
});
it('toggles metadata and chapter merge settings', async () => {
render(
<PathsHarness
onNext={vi.fn()}
onBack={vi.fn()}
initialState={{
metadataTaggingEnabled: false,
plexFormatCoercionEnabled: false,
chapterMergingEnabled: false,
}}
/>
);
const metadataToggle = screen.getByLabelText('Auto-tag audio files with metadata');
const chapterToggle = screen.getByLabelText('Auto-merge chapters to M4B');
fireEvent.click(metadataToggle);
fireEvent.click(chapterToggle);
expect(metadataToggle).toBeChecked();
expect(chapterToggle).toBeChecked();
});
it('renders plex format coercion toggle with default checked and toggles state', async () => {
render(<PathsHarness onNext={vi.fn()} onBack={vi.fn()} />);
const coercionToggle = screen.getByLabelText('Coerce file formats for Plex compatibility');
expect(coercionToggle).toBeChecked();
fireEvent.click(coercionToggle);
expect(coercionToggle).not.toBeChecked();
fireEvent.click(coercionToggle);
expect(coercionToggle).toBeChecked();
});
});