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.
646 lines
24 KiB
TypeScript
646 lines
24 KiB
TypeScript
/**
|
|
* Component: Login Page Tests
|
|
* Documentation: documentation/frontend/pages/login.md
|
|
*/
|
|
|
|
// @vitest-environment jsdom
|
|
|
|
import React from 'react';
|
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { resetMockRouter, routerMock, setMockSearchParams } from '../helpers/mock-next-navigation';
|
|
import { resetMockAuthState, setMockAuthState } from '../helpers/mock-auth';
|
|
|
|
const makeJsonResponse = (body: any, ok: boolean = true) => ({
|
|
ok,
|
|
status: ok ? 200 : 500,
|
|
json: async () => body,
|
|
});
|
|
|
|
const baseProviders = {
|
|
backendMode: 'plex',
|
|
providers: ['plex'],
|
|
registrationEnabled: false,
|
|
hasLocalUsers: false,
|
|
oidcProviderName: null,
|
|
localLoginDisabled: false,
|
|
automationEnabled: false,
|
|
};
|
|
|
|
describe('LoginPage', () => {
|
|
beforeEach(() => {
|
|
resetMockRouter();
|
|
resetMockAuthState();
|
|
localStorage.clear();
|
|
document.cookie.split(';').forEach((cookie) => {
|
|
const name = cookie.split('=')[0]?.trim();
|
|
if (name) {
|
|
document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
|
}
|
|
});
|
|
setMockSearchParams('');
|
|
window.innerWidth = 1024;
|
|
vi.resetModules();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it('renders description based on backend mode and automation flag', async () => {
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') {
|
|
return makeJsonResponse({
|
|
...baseProviders,
|
|
backendMode: 'audiobookshelf',
|
|
automationEnabled: true,
|
|
});
|
|
}
|
|
if (url === '/api/audiobooks/covers') {
|
|
return makeJsonResponse({ success: true, covers: [] });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
expect(
|
|
await screen.findByText(
|
|
"Request audiobooks and they'll automatically download and appear in your Audiobookshelf library"
|
|
)
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('redirects to intended page when user is already logged in', async () => {
|
|
setMockAuthState({
|
|
user: { id: 'user-1', plexId: 'plex-1', username: 'user', role: 'user' },
|
|
isLoading: false,
|
|
});
|
|
setMockSearchParams('redirect=/requests');
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(routerMock.push).toHaveBeenCalledWith('/requests');
|
|
});
|
|
});
|
|
|
|
it('handles Plex login with popup flow', async () => {
|
|
const loginMock = vi.fn().mockResolvedValue(undefined);
|
|
setMockAuthState({ login: loginMock, isLoading: false });
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/plex/login') {
|
|
return makeJsonResponse({ pinId: 123, authUrl: 'http://plex/auth' });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const closeMock = vi.fn();
|
|
const openMock = vi.fn().mockReturnValue({ close: closeMock });
|
|
vi.stubGlobal('open', openMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
|
|
fireEvent.click(loginButton);
|
|
|
|
await waitFor(() => {
|
|
expect(loginMock).toHaveBeenCalledWith(123);
|
|
expect(routerMock.push).toHaveBeenCalledWith('/');
|
|
});
|
|
expect(openMock).toHaveBeenCalledWith(
|
|
'http://plex/auth',
|
|
'plex-auth',
|
|
'width=600,height=700,scrollbars=yes,resizable=yes'
|
|
);
|
|
expect(closeMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows an error when Plex login popup is blocked', async () => {
|
|
const loginMock = vi.fn().mockResolvedValue(undefined);
|
|
setMockAuthState({ login: loginMock, isLoading: false });
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/plex/login') {
|
|
return makeJsonResponse({ pinId: 456, authUrl: 'http://plex/auth' });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
vi.stubGlobal('open', vi.fn().mockReturnValue(null));
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
|
|
fireEvent.click(loginButton);
|
|
|
|
expect(await screen.findByText(/Popup was blocked/i)).toBeInTheDocument();
|
|
expect(loginMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('logs in with local credentials and stores tokens', async () => {
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
|
|
const providers = {
|
|
...baseProviders,
|
|
providers: ['local'],
|
|
};
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/local/login') {
|
|
return makeJsonResponse({
|
|
accessToken: 'access-token',
|
|
refreshToken: 'refresh-token',
|
|
user: { id: 'user-1', username: 'local-user', role: 'admin' },
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
const username = await screen.findByLabelText('Username');
|
|
const password = screen.getByLabelText('Password');
|
|
|
|
fireEvent.change(username, { target: { value: 'admin' } });
|
|
fireEvent.change(password, { target: { value: 'secret' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Login' }));
|
|
|
|
await waitFor(() => {
|
|
expect(setAuthDataMock).toHaveBeenCalledWith(
|
|
{ id: 'user-1', username: 'local-user', role: 'admin' },
|
|
'access-token'
|
|
);
|
|
expect(routerMock.push).toHaveBeenCalledWith('/');
|
|
});
|
|
|
|
expect(localStorage.getItem('accessToken')).toBe('access-token');
|
|
expect(localStorage.getItem('refreshToken')).toBe('refresh-token');
|
|
});
|
|
|
|
it('validates registration passwords before sending request', async () => {
|
|
const providers = {
|
|
...baseProviders,
|
|
providers: ['local'],
|
|
registrationEnabled: true,
|
|
};
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
const registerToggle = await screen.findByRole('button', { name: /Don't have an account\? Register/i });
|
|
fireEvent.click(registerToggle);
|
|
|
|
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
|
|
const passwordInputs = screen.getAllByLabelText('Password');
|
|
fireEvent.change(passwordInputs[0], { target: { value: 'password1' } });
|
|
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password2' } });
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
|
|
|
|
expect(await screen.findByText('Passwords do not match')).toBeInTheDocument();
|
|
});
|
|
|
|
it('renders an OIDC login button and redirects to the provider', async () => {
|
|
const providers = {
|
|
...baseProviders,
|
|
providers: ['oidc'],
|
|
oidcProviderName: 'Auth0',
|
|
};
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
expect(await screen.findByRole('button', { name: 'Login with Auth0' })).toBeInTheDocument();
|
|
expect(
|
|
screen.getByText("You'll be redirected to Auth0 to authenticate")
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('logs in via admin credentials when Plex mode exposes admin login', async () => {
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/admin/login') {
|
|
return makeJsonResponse({
|
|
accessToken: 'admin-access',
|
|
refreshToken: 'admin-refresh',
|
|
user: { id: 'admin-1', username: 'admin', role: 'admin' },
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
const toggleButton = await screen.findByRole('button', { name: 'Admin Login' });
|
|
fireEvent.click(toggleButton);
|
|
|
|
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } });
|
|
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' }));
|
|
|
|
await waitFor(() => {
|
|
expect(setAuthDataMock).toHaveBeenCalledWith(
|
|
{ id: 'admin-1', username: 'admin', role: 'admin' },
|
|
'admin-access'
|
|
);
|
|
expect(routerMock.push).toHaveBeenCalledWith('/');
|
|
});
|
|
|
|
expect(localStorage.getItem('accessToken')).toBe('admin-access');
|
|
expect(localStorage.getItem('refreshToken')).toBe('admin-refresh');
|
|
});
|
|
|
|
it('renders book cover images when the covers API returns data', async () => {
|
|
window.innerWidth = 500;
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') {
|
|
return makeJsonResponse({
|
|
success: true,
|
|
covers: [
|
|
{
|
|
asin: 'asin-1',
|
|
title: 'Book One',
|
|
author: 'Author',
|
|
coverUrl: '/cover.jpg',
|
|
},
|
|
],
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
expect(await screen.findByAltText('Book One')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows pending approval alert when admin login returns pending status', async () => {
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/admin/login') {
|
|
return makeJsonResponse({ pendingApproval: true });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
fireEvent.click(await screen.findByRole('button', { name: 'Admin Login' }));
|
|
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'admin' } });
|
|
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'secret' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Login as Admin' }));
|
|
|
|
expect(await screen.findByText('Account Pending Approval')).toBeInTheDocument();
|
|
expect(setAuthDataMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows registration pending alert when registration needs approval', async () => {
|
|
const providers = {
|
|
...baseProviders,
|
|
providers: ['local'],
|
|
registrationEnabled: true,
|
|
};
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/register') {
|
|
return makeJsonResponse({ pendingApproval: true });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: /Don't have an account\? Register/i })
|
|
);
|
|
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
|
|
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } });
|
|
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
|
|
|
|
expect(await screen.findByText('Registration Pending')).toBeInTheDocument();
|
|
expect(setAuthDataMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('auto-logs in after successful registration', async () => {
|
|
const providers = {
|
|
...baseProviders,
|
|
providers: ['local'],
|
|
registrationEnabled: true,
|
|
};
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(providers);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/register') {
|
|
return makeJsonResponse({
|
|
success: true,
|
|
accessToken: 'reg-access',
|
|
refreshToken: 'reg-refresh',
|
|
user: { id: 'user-3', username: 'new-user', role: 'user' },
|
|
});
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
fireEvent.click(
|
|
await screen.findByRole('button', { name: /Don't have an account\? Register/i })
|
|
);
|
|
fireEvent.change(screen.getByLabelText('Username'), { target: { value: 'new-user' } });
|
|
fireEvent.change(screen.getByLabelText('Password'), { target: { value: 'password1' } });
|
|
fireEvent.change(screen.getByLabelText('Confirm Password'), { target: { value: 'password1' } });
|
|
fireEvent.click(screen.getByRole('button', { name: 'Register' }));
|
|
|
|
await waitFor(() => {
|
|
expect(setAuthDataMock).toHaveBeenCalledWith(
|
|
{ id: 'user-3', username: 'new-user', role: 'user' },
|
|
'reg-access'
|
|
);
|
|
expect(routerMock.push).toHaveBeenCalledWith('/');
|
|
});
|
|
|
|
expect(localStorage.getItem('accessToken')).toBe('reg-access');
|
|
expect(localStorage.getItem('refreshToken')).toBe('reg-refresh');
|
|
});
|
|
|
|
it('falls back to Plex mode when providers fetch fails', async () => {
|
|
const errorMock = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') {
|
|
throw new Error('providers down');
|
|
}
|
|
if (url === '/api/audiobooks/covers') {
|
|
throw new Error('covers down');
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
expect(await screen.findByRole('button', { name: 'Login with Plex' })).toBeInTheDocument();
|
|
expect(errorMock).toHaveBeenCalledWith('Failed to fetch auth providers:', expect.any(Error));
|
|
expect(errorMock).toHaveBeenCalledWith('Failed to fetch book covers:', expect.any(Error));
|
|
});
|
|
|
|
it('processes mobile auth data from URL hash', async () => {
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
setMockSearchParams('auth=success&redirect=/requests');
|
|
|
|
const authData = {
|
|
accessToken: 'mobile-access',
|
|
refreshToken: 'mobile-refresh',
|
|
user: { id: 'user-9', username: 'mobile-user', role: 'user' },
|
|
};
|
|
window.location.hash = `#authData=${encodeURIComponent(JSON.stringify(authData))}`;
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(setAuthDataMock).toHaveBeenCalledWith(authData.user, authData.accessToken);
|
|
expect(routerMock.push).toHaveBeenCalledWith('/requests');
|
|
});
|
|
|
|
expect(localStorage.getItem('accessToken')).toBe('mobile-access');
|
|
expect(localStorage.getItem('refreshToken')).toBe('mobile-refresh');
|
|
expect(window.location.hash).toBe('');
|
|
});
|
|
|
|
it('shows error message from query string', async () => {
|
|
setMockSearchParams('error=Access%20Denied');
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
expect(await screen.findByText('Access Denied')).toBeInTheDocument();
|
|
});
|
|
|
|
it('falls back to cookies when mobile auth has no hash', async () => {
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
setMockSearchParams('auth=success&redirect=/requests');
|
|
|
|
const userData = { id: 'user-10', username: 'cookie-user', role: 'user' };
|
|
document.cookie = 'accessToken=cookie-access';
|
|
document.cookie = 'refreshToken=cookie-refresh';
|
|
document.cookie = `userData=${encodeURIComponent(JSON.stringify(userData))}`;
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
await waitFor(() => {
|
|
expect(setAuthDataMock).toHaveBeenCalledWith(userData, 'cookie-access');
|
|
expect(routerMock.push).toHaveBeenCalledWith('/requests');
|
|
});
|
|
|
|
expect(localStorage.getItem('accessToken')).toBe('cookie-access');
|
|
expect(localStorage.getItem('refreshToken')).toBe('cookie-refresh');
|
|
});
|
|
|
|
it('shows an error when cookie auth payload is invalid', async () => {
|
|
const setAuthDataMock = vi.fn();
|
|
setMockAuthState({ setAuthData: setAuthDataMock, isLoading: false });
|
|
setMockSearchParams('auth=success');
|
|
document.cookie = 'accessToken=cookie-access';
|
|
document.cookie = 'userData=not-json';
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
expect(await screen.findByText('Login failed. Please try again.')).toBeInTheDocument();
|
|
expect(setAuthDataMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('shows an error when cookie auth data is missing', async () => {
|
|
setMockSearchParams('auth=success');
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
expect(await screen.findByText('Authentication failed. Please try again.')).toBeInTheDocument();
|
|
});
|
|
|
|
it('redirects to Plex OAuth on mobile without opening a popup', async () => {
|
|
window.innerWidth = 500;
|
|
const loginMock = vi.fn().mockResolvedValue(undefined);
|
|
setMockAuthState({ login: loginMock, isLoading: false });
|
|
|
|
const fetchMock = vi.fn(async (input: RequestInfo) => {
|
|
const url = typeof input === 'string' ? input : input.url;
|
|
if (url === '/api/auth/providers') return makeJsonResponse(baseProviders);
|
|
if (url === '/api/audiobooks/covers') return makeJsonResponse({ success: true, covers: [] });
|
|
if (url === '/api/auth/plex/login') {
|
|
return makeJsonResponse({ pinId: 321, authUrl: 'http://plex/mobile' });
|
|
}
|
|
throw new Error(`Unexpected fetch: ${url}`);
|
|
});
|
|
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
const openMock = vi.fn();
|
|
vi.stubGlobal('open', openMock);
|
|
|
|
const originalLocation = window.location;
|
|
delete (window as any).location;
|
|
(window as any).location = {
|
|
...originalLocation,
|
|
href: 'http://localhost/login',
|
|
hash: '',
|
|
pathname: '/login',
|
|
search: '',
|
|
};
|
|
|
|
const { default: LoginPage } = await import('@/app/login/page');
|
|
render(<LoginPage />);
|
|
|
|
const loginButton = await screen.findByRole('button', { name: 'Login with Plex' });
|
|
fireEvent.click(loginButton);
|
|
|
|
await waitFor(() => {
|
|
expect(openMock).not.toHaveBeenCalled();
|
|
expect(loginMock).not.toHaveBeenCalled();
|
|
expect(window.location.href).toBe('http://plex/mobile');
|
|
});
|
|
|
|
(window as any).location = originalLocation;
|
|
});
|
|
});
|