mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Add Transmission/NZBGet and per-client paths and much more
Extend multi-download-client support to include Transmission and NZBGet and introduce per-client custom download paths. Adds protocol mapping and new client types, Transmission/NZBGet integration services, API CRUD and validation changes, UI components/modal updates and live path previews, and manager routing by protocol. Includes DB migrations (download_path on download_history, interactive_search_access on users), schema updates, and related processor/service fixes and tests to ensure backward compatibility and proper path resolution.
This commit is contained in:
@@ -106,6 +106,7 @@ const settingsFixture = {
|
||||
downloadDir: '',
|
||||
mediaDir: '',
|
||||
audiobookPathTemplate: '',
|
||||
ebookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import AdminUsersPage from '@/app/admin/users/page';
|
||||
|
||||
@@ -38,6 +38,60 @@ vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
const makeUser = (overrides: Record<string, any> = {}) => ({
|
||||
id: 'u1',
|
||||
plexUsername: 'TestUser',
|
||||
plexId: 'plex-1',
|
||||
role: 'user',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexEmail: 'test@example.com',
|
||||
avatarUrl: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
lastLoginAt: null,
|
||||
autoApproveRequests: false,
|
||||
interactiveSearchAccess: null,
|
||||
_count: { requests: 0 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
/** Sets up all required SWR state for the page, with optional overrides. */
|
||||
function setupSWR(opts: {
|
||||
users?: any[];
|
||||
pendingUsers?: any[];
|
||||
autoApprove?: boolean;
|
||||
interactiveSearch?: boolean;
|
||||
mutateUsers?: ReturnType<typeof vi.fn>;
|
||||
mutatePending?: ReturnType<typeof vi.fn>;
|
||||
mutateAutoApprove?: ReturnType<typeof vi.fn>;
|
||||
mutateInteractiveSearch?: ReturnType<typeof vi.fn>;
|
||||
} = {}) {
|
||||
const mutateUsers = opts.mutateUsers ?? vi.fn();
|
||||
const mutatePending = opts.mutatePending ?? vi.fn();
|
||||
const mutateAutoApprove = opts.mutateAutoApprove ?? vi.fn();
|
||||
const mutateInteractiveSearch = opts.mutateInteractiveSearch ?? vi.fn();
|
||||
|
||||
swrState.set('/api/admin/users', {
|
||||
data: { users: opts.users ?? [makeUser()] },
|
||||
mutate: mutateUsers,
|
||||
});
|
||||
swrState.set('/api/admin/users/pending', {
|
||||
data: { users: opts.pendingUsers ?? [] },
|
||||
mutate: mutatePending,
|
||||
});
|
||||
swrState.set('/api/admin/settings/auto-approve', {
|
||||
data: { autoApproveRequests: opts.autoApprove ?? false },
|
||||
mutate: mutateAutoApprove,
|
||||
});
|
||||
swrState.set('/api/admin/settings/interactive-search', {
|
||||
data: { interactiveSearchAccess: opts.interactiveSearch ?? true },
|
||||
mutate: mutateInteractiveSearch,
|
||||
});
|
||||
|
||||
return { mutateUsers, mutatePending, mutateAutoApprove, mutateInteractiveSearch };
|
||||
}
|
||||
|
||||
describe('AdminUsersPage', () => {
|
||||
beforeEach(() => {
|
||||
swrState.clear();
|
||||
@@ -46,22 +100,17 @@ describe('AdminUsersPage', () => {
|
||||
toastMock.error.mockReset();
|
||||
});
|
||||
|
||||
it('toggles global auto-approve and persists setting', async () => {
|
||||
const mutateUsers = vi.fn();
|
||||
const mutatePending = vi.fn();
|
||||
const mutateGlobal = vi.fn();
|
||||
|
||||
swrState.set('/api/admin/users', {
|
||||
data: { users: [{ id: 'u1', plexUsername: 'User', plexId: 'plex-1', role: 'user', isSetupAdmin: false, authProvider: 'local', plexEmail: null, avatarUrl: null, createdAt: '', updatedAt: '', lastLoginAt: null, autoApproveRequests: false, _count: { requests: 0 } }] },
|
||||
mutate: mutateUsers,
|
||||
});
|
||||
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: mutatePending });
|
||||
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: false }, mutate: mutateGlobal });
|
||||
it('opens global settings modal and toggles auto-approve', async () => {
|
||||
const { mutateAutoApprove, mutateUsers } = setupSWR({ autoApprove: false });
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
// Open the Global Settings modal
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Global.*Permissions/i }));
|
||||
|
||||
// Click the toggle label inside the modal
|
||||
fireEvent.click(await screen.findByText('Auto-Approve All Requests'));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -69,38 +118,171 @@ describe('AdminUsersPage', () => {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ autoApproveRequests: true }),
|
||||
});
|
||||
expect(mutateGlobal).toHaveBeenCalled();
|
||||
expect(mutateAutoApprove).toHaveBeenCalled();
|
||||
expect(mutateUsers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('edits a user role and saves changes', async () => {
|
||||
const mutateUsers = vi.fn();
|
||||
it('opens global settings modal and toggles interactive search', async () => {
|
||||
const { mutateInteractiveSearch, mutateUsers } = setupSWR({ interactiveSearch: true });
|
||||
|
||||
swrState.set('/api/admin/users', {
|
||||
data: {
|
||||
users: [
|
||||
{
|
||||
id: 'u2',
|
||||
plexUsername: 'LocalUser',
|
||||
plexId: 'local-1',
|
||||
role: 'user',
|
||||
isSetupAdmin: false,
|
||||
authProvider: 'local',
|
||||
plexEmail: 'local@example.com',
|
||||
avatarUrl: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
lastLoginAt: null,
|
||||
autoApproveRequests: false,
|
||||
_count: { requests: 2 },
|
||||
},
|
||||
],
|
||||
},
|
||||
mutate: mutateUsers,
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
// Open the Global Settings modal
|
||||
fireEvent.click(await screen.findByRole('button', { name: /Global.*Permissions/i }));
|
||||
|
||||
// Click the interactive search toggle label inside the modal
|
||||
fireEvent.click(await screen.findByText('Interactive Search Access'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/settings/interactive-search', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ interactiveSearchAccess: false }),
|
||||
});
|
||||
expect(mutateInteractiveSearch).toHaveBeenCalled();
|
||||
expect(mutateUsers).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct permission badges in the users table', async () => {
|
||||
setupSWR({
|
||||
users: [
|
||||
makeUser({ id: 'u-admin', plexUsername: 'AdminUser', role: 'admin' }),
|
||||
makeUser({ id: 'u-manual', plexUsername: 'ManualUser', role: 'user', autoApproveRequests: false }),
|
||||
makeUser({ id: 'u-approved', plexUsername: 'ApprovedUser', role: 'user', autoApproveRequests: true }),
|
||||
],
|
||||
autoApprove: false,
|
||||
});
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
expect(await screen.findByText('Full Access')).toBeDefined();
|
||||
expect(screen.getByText('Manual')).toBeDefined();
|
||||
expect(screen.getByText('Auto-Approve')).toBeDefined();
|
||||
});
|
||||
|
||||
it('shows Global Default badge when global auto-approve is on', async () => {
|
||||
setupSWR({
|
||||
users: [makeUser({ id: 'u-user', plexUsername: 'RegularUser', role: 'user', autoApproveRequests: false })],
|
||||
autoApprove: true,
|
||||
});
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
expect(await screen.findByText('Global Default')).toBeDefined();
|
||||
});
|
||||
|
||||
it('opens user permissions modal and shows admin lock state for both permissions', async () => {
|
||||
setupSWR({
|
||||
users: [makeUser({ id: 'u-admin', plexUsername: 'AdminUser', role: 'admin', plexEmail: 'admin@test.com' })],
|
||||
autoApprove: false,
|
||||
interactiveSearch: false,
|
||||
});
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
// Click the permissions badge to open modal
|
||||
fireEvent.click(await screen.findByText('Full Access'));
|
||||
|
||||
// Modal should show user info and the locked state for both permissions
|
||||
expect(await screen.findByText('User Permissions')).toBeDefined();
|
||||
expect(screen.getAllByText('AdminUser').length).toBeGreaterThanOrEqual(2); // table + modal
|
||||
expect(screen.getByText('Admin requests are always auto-approved')).toBeDefined();
|
||||
expect(screen.getByText('Admins always have interactive search access')).toBeDefined();
|
||||
});
|
||||
|
||||
it('opens user permissions modal and toggles auto-approve for regular user', async () => {
|
||||
const { mutateUsers } = setupSWR({
|
||||
users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false })],
|
||||
autoApprove: false,
|
||||
interactiveSearch: false,
|
||||
});
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
// Click the Manual badge to open permissions modal
|
||||
fireEvent.click(await screen.findByText('Manual'));
|
||||
|
||||
// Find and click the auto-approve toggle switch inside the modal
|
||||
const toggle = await screen.findByRole('switch', { name: 'Auto-Approve Requests' });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u-reg', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role: 'user', autoApproveRequests: true }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('opens user permissions modal and toggles interactive search for regular user', async () => {
|
||||
setupSWR({
|
||||
users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false, interactiveSearchAccess: false })],
|
||||
autoApprove: false,
|
||||
interactiveSearch: false,
|
||||
});
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
// Click the Manual badge to open permissions modal
|
||||
fireEvent.click(await screen.findByText('Manual'));
|
||||
|
||||
// Find and click the interactive search toggle switch inside the modal
|
||||
const toggle = await screen.findByRole('switch', { name: 'Interactive Search Access' });
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u-reg', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ role: 'user', interactiveSearchAccess: true }),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('shows global override message in permissions modal when global is on', async () => {
|
||||
setupSWR({
|
||||
users: [makeUser({ id: 'u-reg', plexUsername: 'RegularUser', autoApproveRequests: false })],
|
||||
autoApprove: true,
|
||||
interactiveSearch: true,
|
||||
});
|
||||
|
||||
render(<AdminUsersPage />);
|
||||
|
||||
// Click the Global Default badge
|
||||
fireEvent.click(await screen.findByText('Global Default'));
|
||||
|
||||
// Modal should show the global override message for both
|
||||
expect(await screen.findByText('Controlled by global auto-approve setting')).toBeDefined();
|
||||
expect(screen.getByText('Controlled by global interactive search setting')).toBeDefined();
|
||||
|
||||
// Both toggles should be disabled
|
||||
const autoApproveToggle = screen.getByRole('switch', { name: 'Auto-Approve Requests' });
|
||||
expect(autoApproveToggle).toHaveProperty('disabled', true);
|
||||
|
||||
const searchToggle = screen.getByRole('switch', { name: 'Interactive Search Access' });
|
||||
expect(searchToggle).toHaveProperty('disabled', true);
|
||||
});
|
||||
|
||||
it('edits a user role and saves changes', async () => {
|
||||
const { mutateUsers } = setupSWR({
|
||||
users: [
|
||||
makeUser({
|
||||
id: 'u2',
|
||||
plexUsername: 'LocalUser',
|
||||
plexId: 'local-1',
|
||||
plexEmail: 'local@example.com',
|
||||
autoApproveRequests: false,
|
||||
_count: { requests: 2 },
|
||||
}),
|
||||
],
|
||||
autoApprove: true,
|
||||
});
|
||||
swrState.set('/api/admin/users/pending', { data: { users: [] }, mutate: vi.fn() });
|
||||
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
@@ -120,17 +302,11 @@ describe('AdminUsersPage', () => {
|
||||
});
|
||||
|
||||
it('approves a pending user and refreshes lists', async () => {
|
||||
const mutateUsers = vi.fn();
|
||||
const mutatePending = vi.fn();
|
||||
|
||||
swrState.set('/api/admin/users', { data: { users: [] }, mutate: mutateUsers });
|
||||
swrState.set('/api/admin/users/pending', {
|
||||
data: {
|
||||
users: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }],
|
||||
},
|
||||
mutate: mutatePending,
|
||||
const { mutateUsers, mutatePending } = setupSWR({
|
||||
users: [],
|
||||
pendingUsers: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }],
|
||||
autoApprove: true,
|
||||
});
|
||||
swrState.set('/api/admin/settings/auto-approve', { data: { autoApproveRequests: true }, mutate: vi.fn() });
|
||||
|
||||
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ const baseSettings = {
|
||||
downloadDir: '',
|
||||
mediaDir: '',
|
||||
audiobookPathTemplate: '',
|
||||
ebookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
|
||||
@@ -54,6 +54,7 @@ const baseSettings = {
|
||||
downloadDir: '/downloads',
|
||||
mediaDir: '/media',
|
||||
audiobookPathTemplate: '',
|
||||
ebookPathTemplate: '',
|
||||
metadataTaggingEnabled: true,
|
||||
chapterMergingEnabled: false,
|
||||
},
|
||||
|
||||
@@ -33,6 +33,10 @@ vi.mock('@/components/requests/RequestCard', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/contexts/PreferencesContext', () => ({
|
||||
usePreferences: () => ({ squareCovers: false, setSquareCovers: vi.fn(), cardSize: 5, setCardSize: vi.fn() }),
|
||||
}));
|
||||
|
||||
describe('RequestsPage', () => {
|
||||
beforeEach(() => {
|
||||
resetMockAuthState();
|
||||
|
||||
@@ -13,7 +13,7 @@ import { DownloadClientStep } from '@/app/setup/steps/DownloadClientStep';
|
||||
|
||||
interface DownloadClient {
|
||||
id: string;
|
||||
type: 'qbittorrent' | 'sabnzbd';
|
||||
type: 'qbittorrent' | 'sabnzbd' | 'transmission';
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
@@ -469,34 +469,39 @@ describe('DownloadClientStep', () => {
|
||||
});
|
||||
|
||||
describe('Client Type Restrictions', () => {
|
||||
it('shows "Already configured" when qBittorrent is already added', () => {
|
||||
it('shows "Protocol already configured" when a torrent client is already added', () => {
|
||||
const mockClient = createMockClient({ type: 'qbittorrent' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "Already configured" text should appear for qBittorrent
|
||||
expect(screen.getByText('Already configured')).toBeInTheDocument();
|
||||
// "Protocol already configured" text should appear for torrent clients
|
||||
const configuredMessages = screen.getAllByText('Protocol already configured');
|
||||
expect(configuredMessages.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Add qBittorrent button should not exist
|
||||
// Add qBittorrent and Add Transmission buttons should not exist (torrent protocol taken)
|
||||
expect(screen.queryByRole('button', { name: /Add qBittorrent/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Add Transmission/i })).not.toBeInTheDocument();
|
||||
|
||||
// SABnzbd should still have Add button
|
||||
// SABnzbd should still have Add button (different protocol)
|
||||
expect(screen.getByRole('button', { name: /Add SABnzbd/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows "Already configured" when SABnzbd is already added', () => {
|
||||
it('shows "Protocol already configured" when SABnzbd is already added', () => {
|
||||
const mockClient = createMockClient({ type: 'sabnzbd', name: 'My SABnzbd' });
|
||||
|
||||
render(<DownloadClientHarness onNext={vi.fn()} onBack={vi.fn()} initialClients={[mockClient]} />);
|
||||
|
||||
// "Already configured" text should appear for SABnzbd
|
||||
expect(screen.getByText('Already configured')).toBeInTheDocument();
|
||||
// "Protocol already configured" text should appear for both usenet client cards (SABnzbd + NZBGet)
|
||||
const configuredMessages = screen.getAllByText('Protocol already configured');
|
||||
expect(configuredMessages.length).toBe(2);
|
||||
|
||||
// Add SABnzbd button should not exist
|
||||
// Add SABnzbd and NZBGet buttons should not exist (usenet protocol taken)
|
||||
expect(screen.queryByRole('button', { name: /Add SABnzbd/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Add NZBGet/i })).not.toBeInTheDocument();
|
||||
|
||||
// qBittorrent should still have Add button
|
||||
// Torrent clients should still have Add buttons
|
||||
expect(screen.getByRole('button', { name: /Add qBittorrent/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Add Transmission/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -635,9 +640,9 @@ describe('DownloadClientStep', () => {
|
||||
expect(editButtons).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Both "Already configured" messages should appear
|
||||
const alreadyConfiguredMessages = screen.getAllByText('Already configured');
|
||||
expect(alreadyConfiguredMessages).toHaveLength(2);
|
||||
// Both "Protocol already configured" messages should appear (torrent + usenet)
|
||||
const alreadyConfiguredMessages = screen.getAllByText('Protocol already configured');
|
||||
expect(alreadyConfiguredMessages.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user