mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
a97979358f
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.
383 lines
12 KiB
TypeScript
383 lines
12 KiB
TypeScript
/**
|
|
* Component: Setup Wizard Page Tests
|
|
* Documentation: documentation/setup-wizard.md
|
|
*/
|
|
|
|
// @vitest-environment jsdom
|
|
|
|
import React from 'react';
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import path from 'path';
|
|
import { resetMockRouter } from '../helpers/mock-next-navigation';
|
|
|
|
const mockSetupModules = () => {
|
|
vi.doMock(path.resolve('src/app/setup/components/WizardLayout.tsx'), () => ({
|
|
WizardLayout: ({
|
|
children,
|
|
currentStep,
|
|
totalSteps,
|
|
}: {
|
|
children: React.ReactNode;
|
|
currentStep: number;
|
|
totalSteps: number;
|
|
}) => (
|
|
<div data-testid="wizard" data-step={currentStep} data-total={totalSteps}>
|
|
{children}
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/WelcomeStep.tsx'), () => ({
|
|
WelcomeStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/BackendSelectionStep.tsx'), () => ({
|
|
BackendSelectionStep: ({
|
|
onNext,
|
|
onChange,
|
|
}: {
|
|
onNext: () => void;
|
|
onChange: (value: 'plex' | 'audiobookshelf') => void;
|
|
}) => (
|
|
<div>
|
|
<button type="button" onClick={() => onChange('plex')}>
|
|
Choose Plex
|
|
</button>
|
|
<button type="button" onClick={() => onChange('audiobookshelf')}>
|
|
Choose ABS
|
|
</button>
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/AdminAccountStep.tsx'), () => ({
|
|
AdminAccountStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/PlexStep.tsx'), () => ({
|
|
PlexStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/AudiobookshelfStep.tsx'), () => ({
|
|
AudiobookshelfStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/AuthMethodStep.tsx'), () => ({
|
|
AuthMethodStep: ({
|
|
onNext,
|
|
onChange,
|
|
}: {
|
|
onNext: () => void;
|
|
onChange: (value: 'oidc' | 'manual' | 'both') => void;
|
|
}) => (
|
|
<div>
|
|
<button type="button" onClick={() => onChange('oidc')}>
|
|
Choose OIDC
|
|
</button>
|
|
<button type="button" onClick={() => onChange('manual')}>
|
|
Choose Manual
|
|
</button>
|
|
<button type="button" onClick={() => onChange('both')}>
|
|
Choose Both
|
|
</button>
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/OIDCConfigStep.tsx'), () => ({
|
|
OIDCConfigStep: ({
|
|
onNext,
|
|
onUpdate,
|
|
}: {
|
|
onNext: () => void;
|
|
onUpdate: (field: string, value: string) => void;
|
|
}) => (
|
|
<div>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
onUpdate('oidcAccessControlMethod', 'allowed_list');
|
|
onUpdate('oidcAllowedEmails', 'user1@example.com, user2@example.com');
|
|
onUpdate('oidcAllowedUsernames', 'john, jane');
|
|
}}
|
|
>
|
|
Set Allowed Lists
|
|
</button>
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
</div>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/RegistrationSettingsStep.tsx'), () => ({
|
|
RegistrationSettingsStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/ProwlarrStep.tsx'), () => ({
|
|
ProwlarrStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/DownloadClientStep.tsx'), () => ({
|
|
DownloadClientStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/PathsStep.tsx'), () => ({
|
|
PathsStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/BookDateStep.tsx'), () => ({
|
|
BookDateStep: ({ onNext }: { onNext: () => void }) => (
|
|
<button type="button" onClick={onNext}>
|
|
Next
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/ReviewStep.tsx'), () => ({
|
|
ReviewStep: ({ onComplete }: { onComplete: () => void }) => (
|
|
<button type="button" onClick={onComplete}>
|
|
Complete
|
|
</button>
|
|
),
|
|
}));
|
|
|
|
vi.doMock(path.resolve('src/app/setup/steps/FinalizeStep.tsx'), () => ({
|
|
FinalizeStep: ({ hasAdminTokens }: { hasAdminTokens: boolean }) => (
|
|
<div data-testid="finalize">{hasAdminTokens ? 'admin' : 'oidc'}</div>
|
|
),
|
|
}));
|
|
};
|
|
|
|
const makeJsonResponse = (body: any, ok: boolean = true) => ({
|
|
ok,
|
|
status: ok ? 200 : 500,
|
|
json: async () => body,
|
|
});
|
|
|
|
describe('SetupWizard', () => {
|
|
beforeEach(() => {
|
|
resetMockRouter();
|
|
localStorage.clear();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('completes setup in Plex mode and stores admin tokens', async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/setup/complete') {
|
|
return makeJsonResponse({
|
|
accessToken: 'access-token',
|
|
refreshToken: 'refresh-token',
|
|
user: { id: 'admin-1', username: 'admin' },
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
vi.resetModules();
|
|
mockSetupModules();
|
|
const { default: SetupWizard } = await import('@/app/setup/page');
|
|
render(<SetupWizard />);
|
|
|
|
for (let i = 0; i < 8; i += 1) {
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
}
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem('accessToken')).toBe('access-token');
|
|
expect(screen.getByTestId('finalize')).toHaveTextContent('admin');
|
|
});
|
|
|
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
|
expect(requestBody.backendMode).toBe('plex');
|
|
expect(requestBody.admin).toBeDefined();
|
|
expect(requestBody.plex).toBeDefined();
|
|
});
|
|
|
|
it('completes setup in OIDC-only mode and clears tokens', async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/setup/complete') {
|
|
return makeJsonResponse({ success: true });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
localStorage.setItem('accessToken', 'stale-token');
|
|
|
|
vi.resetModules();
|
|
mockSetupModules();
|
|
const { default: SetupWizard } = await import('@/app/setup/page');
|
|
render(<SetupWizard />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Choose OIDC' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
|
|
for (let i = 0; i < 5; i += 1) {
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
}
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem('accessToken')).toBeNull();
|
|
expect(screen.getByTestId('finalize')).toHaveTextContent('oidc');
|
|
});
|
|
|
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
|
expect(requestBody.backendMode).toBe('audiobookshelf');
|
|
expect(requestBody.authMethod).toBe('oidc');
|
|
expect(requestBody.audiobookshelf).toBeDefined();
|
|
expect(requestBody.oidc).toBeDefined();
|
|
expect(requestBody.admin).toBeUndefined();
|
|
});
|
|
|
|
it('completes setup in manual auth mode and includes registration settings', async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/setup/complete') {
|
|
return makeJsonResponse({
|
|
accessToken: 'access-token',
|
|
refreshToken: 'refresh-token',
|
|
user: { id: 'admin-1', username: 'admin' },
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
vi.resetModules();
|
|
mockSetupModules();
|
|
const { default: SetupWizard } = await import('@/app/setup/page');
|
|
render(<SetupWizard />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Choose Manual' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
|
|
for (let i = 0; i < 6; i += 1) {
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
}
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
|
|
|
|
await waitFor(() => {
|
|
expect(localStorage.getItem('accessToken')).toBe('access-token');
|
|
expect(screen.getByTestId('finalize')).toHaveTextContent('admin');
|
|
});
|
|
|
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
|
expect(requestBody.backendMode).toBe('audiobookshelf');
|
|
expect(requestBody.authMethod).toBe('manual');
|
|
expect(requestBody.registration).toEqual({
|
|
enabled: true,
|
|
require_admin_approval: true,
|
|
});
|
|
expect(requestBody.admin).toBeDefined();
|
|
expect(requestBody.oidc).toBeUndefined();
|
|
expect(requestBody.bookdate).toBeNull();
|
|
});
|
|
|
|
it('serializes OIDC allowed lists as JSON arrays', async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/setup/complete') {
|
|
return makeJsonResponse({ success: true });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
vi.resetModules();
|
|
mockSetupModules();
|
|
const { default: SetupWizard } = await import('@/app/setup/page');
|
|
render(<SetupWizard />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Choose ABS' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Choose OIDC' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Set Allowed Lists' }));
|
|
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
|
|
|
|
for (let i = 0; i < 4; i += 1) {
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Next' }));
|
|
}
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Complete' }));
|
|
|
|
await waitFor(() => {
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
});
|
|
|
|
const requestBody = JSON.parse(fetchMock.mock.calls[0][1].body as string);
|
|
expect(requestBody.oidc.allowed_emails).toBe(
|
|
JSON.stringify(['user1@example.com', 'user2@example.com'])
|
|
);
|
|
expect(requestBody.oidc.allowed_usernames).toBe(JSON.stringify(['john', 'jane']));
|
|
});
|
|
});
|