Files
ReadMeABook/tests/components/layout/Header.test.tsx
T
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

358 lines
11 KiB
TypeScript

/**
* Component: Header Component Tests
* Documentation: documentation/frontend/components.md
*/
// @vitest-environment jsdom
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { renderWithProviders } from '../../helpers/render';
describe('Header', () => {
let Header: typeof import('@/components/layout/Header').Header;
beforeAll(async () => {
({ Header } = await import('@/components/layout/Header'));
});
beforeEach(() => {
localStorage.clear();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders login button and opens Plex auth window', async () => {
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
if (input === '/api/version') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
}
return Promise.resolve({
json: vi.fn().mockResolvedValue({ success: true, authUrl: 'https://plex.example/login' }),
});
});
const openMock = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, { auth: { user: null, isLoading: false } });
const loginButton = screen.getByRole('button', { name: /login with plex/i });
await userEvent.click(loginButton);
await waitFor(() => {
expect(fetchMock).toHaveBeenCalledWith('/api/auth/plex/login', { method: 'POST' });
});
expect(openMock).toHaveBeenCalledWith(
'https://plex.example/login',
'plex-auth',
'width=600,height=700'
);
});
it('renders admin navigation and user menu actions for local users', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'admin-1',
plexId: 'plex-1',
username: 'admin',
role: 'admin',
authProvider: 'local',
},
isLoading: false,
},
});
expect(screen.getByRole('link', { name: 'Admin' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'My Requests' })).toBeInTheDocument();
const userButton = screen.getByText('admin').closest('button');
expect(userButton).not.toBeNull();
await userEvent.click(userButton as HTMLButtonElement);
await waitFor(() => {
expect(screen.getByText('Change Password')).toBeInTheDocument();
});
expect(screen.getByText('Logout')).toBeInTheDocument();
});
it('shows BookDate link and avatar when BookDate is enabled', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
if (input === '/api/version') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
}
if (input === '/api/bookdate/config') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({
config: { isVerified: true, isEnabled: true },
}),
});
}
return Promise.resolve({
json: vi.fn().mockResolvedValue({}),
});
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-1',
plexId: 'plex-1',
username: 'reader',
role: 'user',
avatarUrl: '/avatar.png',
},
isLoading: false,
},
});
await waitFor(() => {
expect(screen.getByRole('link', { name: 'BookDate' })).toBeInTheDocument();
});
expect(screen.getByAltText('reader')).toBeInTheDocument();
});
it('logs out from the user menu and shows initials fallback', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
const logoutMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-2',
plexId: 'plex-2',
username: 'alice',
role: 'user',
authProvider: 'plex',
},
logout: logoutMock,
isLoading: false,
},
});
expect(screen.getByText(/^A$/)).toBeInTheDocument();
const userButton = screen.getByText('alice').closest('button');
expect(userButton).not.toBeNull();
await userEvent.click(userButton as HTMLButtonElement);
await waitFor(() => {
expect(screen.getByText('Logout')).toBeInTheDocument();
});
await userEvent.click(screen.getByText('Logout'));
expect(logoutMock).toHaveBeenCalledTimes(1);
});
it('toggles the mobile menu and closes after navigation', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, { auth: { user: null, isLoading: false } });
const initialHomeLinks = screen.getAllByRole('link', { name: 'Home' }).length;
await userEvent.click(screen.getByRole('button', { name: 'Toggle menu' }));
const openHomeLinks = screen.getAllByRole('link', { name: 'Home' });
expect(openHomeLinks).toHaveLength(initialHomeLinks + 1);
await userEvent.click(openHomeLinks[openHomeLinks.length - 1]);
expect(screen.getAllByRole('link', { name: 'Home' })).toHaveLength(initialHomeLinks);
});
it('hides BookDate when config check fails', async () => {
localStorage.setItem('accessToken', 'token');
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
if (input === '/api/version') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
}
if (input === '/api/bookdate/config') {
return Promise.reject(new Error('boom'));
}
return Promise.resolve({
json: vi.fn().mockResolvedValue({}),
});
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-3',
plexId: 'plex-3',
username: 'reader',
role: 'user',
},
isLoading: false,
},
});
await waitFor(() => {
expect(errorMock).toHaveBeenCalledWith('Failed to check BookDate config:', expect.any(Error));
});
expect(screen.queryByRole('link', { name: 'BookDate' })).not.toBeInTheDocument();
});
it('opens change password modal and closes it', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-4',
plexId: 'plex-4',
username: 'local-admin',
role: 'admin',
authProvider: 'local',
},
isLoading: false,
},
});
const userButton = screen.getByText('local-admin').closest('button');
expect(userButton).not.toBeNull();
await userEvent.click(userButton as HTMLButtonElement);
const changePasswordButton = await screen.findByText('Change Password');
await userEvent.click(changePasswordButton);
expect(await screen.findByRole('heading', { name: 'Change Password' })).toBeInTheDocument();
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
await waitFor(() => {
expect(screen.queryByRole('heading', { name: 'Change Password' })).not.toBeInTheDocument();
});
});
it('closes the user menu when profile is clicked', async () => {
const fetchMock = vi.fn().mockResolvedValue({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-5',
plexId: 'plex-5',
username: 'reader',
role: 'user',
authProvider: 'local',
},
isLoading: false,
},
});
const userButton = screen.getByText('reader').closest('button');
expect(userButton).not.toBeNull();
await userEvent.click(userButton as HTMLButtonElement);
const profileLink = await screen.findByRole('link', { name: 'Profile' });
await userEvent.click(profileLink);
await waitFor(() => {
expect(screen.queryByText('Logout')).not.toBeInTheDocument();
});
});
it('logs errors when Plex login fails', async () => {
const fetchMock = vi.fn().mockRejectedValue(new Error('login failed'));
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
const openMock = vi.spyOn(window, 'open').mockImplementation(() => null);
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, { auth: { user: null, isLoading: false } });
await userEvent.click(screen.getByRole('button', { name: /login with plex/i }));
await waitFor(() => {
expect(errorMock).toHaveBeenCalledWith('Login failed:', expect.any(Error));
});
expect(openMock).not.toHaveBeenCalled();
});
it('closes the mobile menu when BookDate is selected', async () => {
localStorage.setItem('accessToken', 'token');
const fetchMock = vi.fn().mockImplementation((input: RequestInfo) => {
if (input === '/api/version') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({ version: 'v.test' }),
});
}
if (input === '/api/bookdate/config') {
return Promise.resolve({
json: vi.fn().mockResolvedValue({
config: { isVerified: true, isEnabled: true },
}),
});
}
return Promise.resolve({
json: vi.fn().mockResolvedValue({}),
});
});
vi.stubGlobal('fetch', fetchMock);
renderWithProviders(<Header />, {
auth: {
user: {
id: 'user-6',
plexId: 'plex-6',
username: 'reader',
role: 'user',
},
isLoading: false,
},
});
const initialBookDateCount = (await screen.findAllByRole('link', { name: 'BookDate' })).length;
await userEvent.click(screen.getByRole('button', { name: 'Toggle menu' }));
const bookDateLinks = await screen.findAllByRole('link', { name: 'BookDate' });
expect(bookDateLinks).toHaveLength(initialBookDateCount + 1);
await userEvent.click(bookDateLinks[bookDateLinks.length - 1]);
await waitFor(async () => {
const remainingLinks = await screen.findAllByRole('link', { name: 'BookDate' });
expect(remainingLinks).toHaveLength(initialBookDateCount);
});
});
});