mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00:09 +00:00
4b90b35748
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.
330 lines
11 KiB
TypeScript
330 lines
11 KiB
TypeScript
/**
|
|
* Component: Admin Users Page Tests
|
|
* Documentation: documentation/admin-dashboard.md
|
|
*/
|
|
|
|
// @vitest-environment jsdom
|
|
|
|
import React from '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';
|
|
|
|
const fetchJSONMock = vi.hoisted(() => vi.fn());
|
|
const authenticatedFetcherMock = vi.hoisted(() => vi.fn());
|
|
|
|
const toastMock = vi.hoisted(() => ({
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
info: vi.fn(),
|
|
warning: vi.fn(),
|
|
}));
|
|
|
|
const swrState = new Map<string, { data?: any; error?: any; mutate: ReturnType<typeof vi.fn> }>();
|
|
|
|
vi.mock('swr', () => ({
|
|
default: (key: string) => {
|
|
return swrState.get(key) || { data: undefined, error: undefined, mutate: vi.fn() };
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/utils/api', () => ({
|
|
authenticatedFetcher: authenticatedFetcherMock,
|
|
fetchJSON: fetchJSONMock,
|
|
}));
|
|
|
|
vi.mock('@/components/ui/Toast', () => ({
|
|
ToastProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
|
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();
|
|
fetchJSONMock.mockReset();
|
|
toastMock.success.mockReset();
|
|
toastMock.error.mockReset();
|
|
});
|
|
|
|
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(() => {
|
|
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/settings/auto-approve', {
|
|
method: 'PATCH',
|
|
body: JSON.stringify({ autoApproveRequests: true }),
|
|
});
|
|
expect(mutateAutoApprove).toHaveBeenCalled();
|
|
expect(mutateUsers).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('opens global settings modal and toggles interactive search', async () => {
|
|
const { mutateInteractiveSearch, mutateUsers } = setupSWR({ interactiveSearch: true });
|
|
|
|
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,
|
|
});
|
|
|
|
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
|
|
|
render(<AdminUsersPage />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Edit Role' }));
|
|
fireEvent.click(screen.getByRole('radio', { name: /Admin/i }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }));
|
|
|
|
await waitFor(() => {
|
|
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/u2', {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ role: 'admin' }),
|
|
});
|
|
expect(mutateUsers).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
it('approves a pending user and refreshes lists', async () => {
|
|
const { mutateUsers, mutatePending } = setupSWR({
|
|
users: [],
|
|
pendingUsers: [{ id: 'p1', plexUsername: 'Pending', plexEmail: null, authProvider: 'local', createdAt: new Date().toISOString() }],
|
|
autoApprove: true,
|
|
});
|
|
|
|
fetchJSONMock.mockResolvedValueOnce({ success: true });
|
|
|
|
render(<AdminUsersPage />);
|
|
|
|
const approveButtons = await screen.findAllByRole('button', { name: 'Approve' });
|
|
fireEvent.click(approveButtons[0]);
|
|
|
|
const confirmButtons = await screen.findAllByRole('button', { name: 'Approve' });
|
|
fireEvent.click(confirmButtons[1]);
|
|
|
|
await waitFor(() => {
|
|
expect(fetchJSONMock).toHaveBeenCalledWith('/api/admin/users/p1/approve', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ approve: true }),
|
|
});
|
|
expect(mutatePending).toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|