Files
ReadMeABook/tests/app/setup.page.test.tsx
kikootwo a97979358f 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.
2026-01-28 11:42:00 -05:00

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']));
});
});