mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 12:50:09 +00:00
Implement file hash-based library matching and remove fuzzy ASIN matching
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
This commit is contained in:
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Component: Auth Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const makeResponse = (body: any, ok = true) => ({
|
||||
ok,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('useAuthSettings', () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onSuccess.mockReset();
|
||||
onError.mockReset();
|
||||
});
|
||||
|
||||
it('fetches pending users successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({ users: [{ id: 'u1', plexUsername: 'Pending' }] })
|
||||
);
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPendingUsers();
|
||||
});
|
||||
|
||||
expect(result.current.pendingUsers).toHaveLength(1);
|
||||
expect(result.current.loadingPendingUsers).toBe(false);
|
||||
});
|
||||
|
||||
it('tests OIDC configuration successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: true }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.oidcTestResult?.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith('OIDC configuration is valid. You can now save.');
|
||||
});
|
||||
|
||||
it('surfaces OIDC validation errors', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Bad issuer' }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testOIDCConnection('issuer', 'client', 'secret');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.oidcTestResult?.message).toBe('Bad issuer');
|
||||
expect(onError).toHaveBeenCalledWith('Bad issuer');
|
||||
});
|
||||
|
||||
it('approves a pending user and refreshes the list', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ success: true, message: 'Approved' }))
|
||||
.mockResolvedValueOnce(makeResponse({ users: [] }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.approveUser('u1', true);
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('Approved');
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/users/u1/approve', expect.any(Object));
|
||||
expect(result.current.pendingUsers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('surfaces approval failures', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ success: false, error: 'Nope' }));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.approveUser('u2', false);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Nope');
|
||||
});
|
||||
|
||||
it('handles pending user fetch errors gracefully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({}, false));
|
||||
|
||||
const { useAuthSettings } = await import('@/app/admin/settings/tabs/AuthTab/useAuthSettings');
|
||||
const result = renderHook(() => useAuthSettings({ onSuccess, onError }));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchPendingUsers();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loadingPendingUsers).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Component: BookDate Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const makeResponse = (body: any, ok = true) => ({
|
||||
ok,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('useBookDateSettings', () => {
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('loads BookDate config on mount', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({
|
||||
config: {
|
||||
provider: 'claude',
|
||||
model: 'claude-3',
|
||||
baseUrl: 'http://custom',
|
||||
isEnabled: false,
|
||||
isVerified: true,
|
||||
},
|
||||
}));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(result.current.provider).toBe('claude'));
|
||||
|
||||
expect(result.current.model).toBe('claude-3');
|
||||
expect(result.current.baseUrl).toBe('http://custom');
|
||||
expect(result.current.enabled).toBe(false);
|
||||
expect(result.current.configured).toBe(true);
|
||||
});
|
||||
|
||||
it('validates missing API key for non-custom providers', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(onSuccess, onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter an API key');
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('validates missing base URL for custom providers', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setProvider('custom');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
|
||||
});
|
||||
|
||||
it('tests connection with saved key and auto-selects the first model', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
config: {
|
||||
provider: 'openai',
|
||||
model: '',
|
||||
baseUrl: '',
|
||||
isEnabled: true,
|
||||
isVerified: true,
|
||||
},
|
||||
}))
|
||||
.mockResolvedValueOnce(makeResponse({
|
||||
models: [{ id: 'gpt-4' }, { id: 'gpt-3.5' }],
|
||||
}));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(result.current.configured).toBe(true));
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(onSuccess, onError);
|
||||
});
|
||||
|
||||
const requestBody = JSON.parse((fetchWithAuthMock.mock.calls[1][1] as RequestInit).body as string);
|
||||
expect(requestBody.useSavedKey).toBe(true);
|
||||
expect(requestBody.provider).toBe('openai');
|
||||
expect(result.current.models).toHaveLength(2);
|
||||
expect(result.current.model).toBe('gpt-4');
|
||||
expect(onSuccess).toHaveBeenCalledWith('Connection successful! Please select a model.');
|
||||
});
|
||||
|
||||
it('surfaces connection test errors', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({ error: 'Bad key' }, false));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setApiKey('key');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testConnection(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Bad key');
|
||||
});
|
||||
|
||||
it('validates missing model before saving', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please select a model');
|
||||
});
|
||||
|
||||
it('validates custom base URL before saving', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setProvider('custom');
|
||||
result.current.setModel('custom-model');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter a base URL for custom provider');
|
||||
});
|
||||
|
||||
it('validates API key for initial setup before saving', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setModel('gpt-4');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Please enter an API key for initial setup');
|
||||
});
|
||||
|
||||
it('saves configuration and clears API key', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({}));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setModel('gpt-4');
|
||||
result.current.setApiKey('secret');
|
||||
result.current.setEnabled(false);
|
||||
});
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(onSuccess, vi.fn());
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('BookDate configuration saved successfully!');
|
||||
expect(result.current.configured).toBe(true);
|
||||
expect(result.current.apiKey).toBe('');
|
||||
});
|
||||
|
||||
it('surfaces save errors', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({ error: 'Save failed' }, false));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
result.current.setModel('gpt-4');
|
||||
result.current.setApiKey('secret');
|
||||
});
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveConfig(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Save failed');
|
||||
});
|
||||
|
||||
it('skips clearing swipes when confirmation is canceled', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ config: {} }));
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(false));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearSwipes(vi.fn(), vi.fn());
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('clears swipes after confirmation', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({}));
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onSuccess = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearSwipes(onSuccess, vi.fn());
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('Swipe history cleared successfully!');
|
||||
});
|
||||
|
||||
it('reports errors when clearing swipes fails', async () => {
|
||||
fetchWithAuthMock
|
||||
.mockResolvedValueOnce(makeResponse({ config: {} }))
|
||||
.mockResolvedValueOnce(makeResponse({ error: 'Clear failed' }, false));
|
||||
vi.stubGlobal('confirm', vi.fn().mockReturnValue(true));
|
||||
|
||||
const { useBookDateSettings } = await import('@/app/admin/settings/tabs/BookDateTab/useBookDateSettings');
|
||||
const result = renderHook(() => useBookDateSettings());
|
||||
|
||||
await waitFor(() => expect(fetchWithAuthMock).toHaveBeenCalledTimes(1));
|
||||
|
||||
const onError = vi.fn();
|
||||
|
||||
await act(async () => {
|
||||
await result.current.clearSwipes(vi.fn(), onError);
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Clear failed');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Component: Download Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const downloadClient = {
|
||||
type: 'qbittorrent',
|
||||
url: 'http://host',
|
||||
username: 'user',
|
||||
password: 'pass',
|
||||
disableSSLVerify: false,
|
||||
remotePathMappingEnabled: false,
|
||||
remotePath: '',
|
||||
localPath: '',
|
||||
};
|
||||
|
||||
describe('useDownloadSettings', () => {
|
||||
const onChange = vi.fn();
|
||||
const onValidationChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onChange.mockReset();
|
||||
onValidationChange.mockReset();
|
||||
});
|
||||
|
||||
it('updates fields and resets validation', async () => {
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.updateField('url', 'http://new');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...downloadClient, url: 'http://new' });
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('resets credentials when changing download client type', async () => {
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange('sabnzbd');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...downloadClient,
|
||||
type: 'sabnzbd',
|
||||
username: '',
|
||||
password: '',
|
||||
});
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('tests the download client connection successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, version: '1.2.3' }),
|
||||
});
|
||||
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.testConnection();
|
||||
expect(response?.success).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.testResult?.message).toContain('qbittorrent');
|
||||
});
|
||||
expect(onValidationChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('handles download client test failures', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: false, error: 'Bad credentials' }),
|
||||
});
|
||||
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.testConnection();
|
||||
expect(response?.success).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.testResult?.message).toBe('Bad credentials');
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('handles download client test exceptions', async () => {
|
||||
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const { useDownloadSettings } = await import('@/app/admin/settings/tabs/DownloadTab/useDownloadSettings');
|
||||
const result = renderHook(() => useDownloadSettings({ downloadClient, onChange, onValidationChange }));
|
||||
|
||||
await act(async () => {
|
||||
const response = await result.current.testConnection();
|
||||
expect(response?.success).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.testResult?.message).toBe('network down');
|
||||
expect(onValidationChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Component: Ebook Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const baseEbook = {
|
||||
enabled: true,
|
||||
preferredFormat: 'epub',
|
||||
baseUrl: 'https://annas-archive.li',
|
||||
flaresolverrUrl: 'http://flare',
|
||||
};
|
||||
|
||||
describe('useEbookSettings', () => {
|
||||
const onChange = vi.fn();
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const markAsSaved = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onChange.mockReset();
|
||||
onSuccess.mockReset();
|
||||
onError.mockReset();
|
||||
markAsSaved.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('updates ebook settings and clears flaresolverr test results when URL changes', async () => {
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.updateEbook('flaresolverrUrl', 'http://new');
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...baseEbook, flaresolverrUrl: 'http://new' });
|
||||
expect(result.current.flaresolverrTestResult).toBeNull();
|
||||
});
|
||||
|
||||
it('returns an error when testing FlareSolverr without a URL', async () => {
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: { ...baseEbook, flaresolverrUrl: '' }, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testFlaresolverrConnection();
|
||||
});
|
||||
|
||||
expect(result.current.flaresolverrTestResult?.success).toBe(false);
|
||||
expect(result.current.flaresolverrTestResult?.message).toContain('Please enter a FlareSolverr URL');
|
||||
});
|
||||
|
||||
it('tests FlareSolverr connection successfully', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, message: 'OK' }),
|
||||
});
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testFlaresolverrConnection();
|
||||
});
|
||||
|
||||
expect(result.current.flaresolverrTestResult?.success).toBe(true);
|
||||
});
|
||||
|
||||
it('handles FlareSolverr test failures', async () => {
|
||||
fetchWithAuthMock.mockRejectedValueOnce(new Error('flare down'));
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.testFlaresolverrConnection();
|
||||
});
|
||||
|
||||
expect(result.current.flaresolverrTestResult?.message).toBe('flare down');
|
||||
});
|
||||
|
||||
it('saves ebook settings and clears success banner after delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
fetchWithAuthMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) });
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings();
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('E-book sidecar settings saved successfully!');
|
||||
expect(markAsSaved).toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
|
||||
expect(onSuccess).toHaveBeenCalledWith('');
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('surfaces save errors', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce({ ok: false, json: async () => ({}) });
|
||||
|
||||
const { useEbookSettings } = await import('@/app/admin/settings/tabs/EbookTab/useEbookSettings');
|
||||
const result = renderHook(() =>
|
||||
useEbookSettings({ ebook: baseEbook, onChange, onSuccess, onError, markAsSaved })
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.saveSettings();
|
||||
});
|
||||
|
||||
expect(onError).toHaveBeenCalledWith('Failed to save e-book settings');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Component: Library Settings Hook Tests
|
||||
* Documentation: documentation/settings-pages.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const makeResponse = (body: any) => ({
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
describe('useLibrarySettings', () => {
|
||||
const onSuccess = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const onValidationChange = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithAuthMock.mockReset();
|
||||
onSuccess.mockReset();
|
||||
onError.mockReset();
|
||||
onValidationChange.mockReset();
|
||||
});
|
||||
|
||||
it('tests Plex connection successfully and stores libraries', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: true,
|
||||
serverName: 'Plex Server',
|
||||
libraries: [{ id: 'lib-1', title: 'Main' }],
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testPlexConnection('http://plex', 'token');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.plexLibraries).toHaveLength(1);
|
||||
expect(result.current.plexTestResult?.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith('Connected to Plex Server. You can now save.');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('plex', true);
|
||||
});
|
||||
|
||||
it('surfaces Plex connection errors', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: false,
|
||||
error: 'Bad token',
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testPlexConnection('http://plex', 'token');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.plexTestResult?.message).toBe('Bad token');
|
||||
expect(onError).toHaveBeenCalledWith('Bad token');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
|
||||
});
|
||||
|
||||
it('tests Audiobookshelf connection successfully and stores libraries', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: true,
|
||||
libraries: [{ id: 'abs-1', name: 'ABS Main' }],
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testABSConnection('http://abs', 'token');
|
||||
expect(ok).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.absLibraries).toHaveLength(1);
|
||||
expect(result.current.absTestResult?.success).toBe(true);
|
||||
expect(onSuccess).toHaveBeenCalledWith('Connected to Audiobookshelf. You can now save.');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', true);
|
||||
});
|
||||
|
||||
it('surfaces Audiobookshelf connection failures', async () => {
|
||||
fetchWithAuthMock.mockResolvedValueOnce(
|
||||
makeResponse({
|
||||
success: false,
|
||||
error: 'ABS down',
|
||||
})
|
||||
);
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testABSConnection('http://abs', 'token');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.absTestResult?.message).toBe('ABS down');
|
||||
expect(onError).toHaveBeenCalledWith('ABS down');
|
||||
expect(onValidationChange).toHaveBeenCalledWith('audiobookshelf', false);
|
||||
});
|
||||
|
||||
it('handles exceptions while testing connections', async () => {
|
||||
fetchWithAuthMock.mockRejectedValueOnce(new Error('network down'));
|
||||
|
||||
const { useLibrarySettings } = await import('@/app/admin/settings/tabs/LibraryTab/useLibrarySettings');
|
||||
const result = renderHook(() => useLibrarySettings(onSuccess, onError, onValidationChange));
|
||||
|
||||
await act(async () => {
|
||||
const ok = await result.current.testPlexConnection('http://plex', 'token');
|
||||
expect(ok).toBe(false);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.plexTestResult?.message).toBe('network down');
|
||||
});
|
||||
expect(onValidationChange).toHaveBeenCalledWith('plex', false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user