mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Add series fields to audiobooks and update related logic
Introduces 'series' and 'seriesPart' fields to the Audiobook model and database schema. Updates API routes, file organization, and path template utilities to support series metadata. Enhances chapter merging logic, improves notification backend testing, and expands test coverage for admin and API routes.
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Component: Audiobook Card Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const createRequestMock = vi.hoisted(() => vi.fn());
|
||||
const authState = {
|
||||
user: null as null | { id: string; username: string },
|
||||
};
|
||||
|
||||
vi.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => authState,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/audiobooks/AudiobookDetailsModal', () => ({
|
||||
AudiobookDetailsModal: ({ isOpen }: { isOpen: boolean }) => (
|
||||
<div data-testid="details-modal" data-open={String(isOpen)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img {...props} />,
|
||||
}));
|
||||
|
||||
const baseAudiobook = {
|
||||
asin: 'asin-1',
|
||||
title: 'Test Book',
|
||||
author: 'Author',
|
||||
};
|
||||
|
||||
describe('AudiobookCard', () => {
|
||||
beforeEach(() => {
|
||||
authState.user = null;
|
||||
createRequestMock.mockReset();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('disables requests when no user is logged in', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} />);
|
||||
|
||||
const requestButton = screen.getByRole('button', { name: 'Login to Request' });
|
||||
expect(requestButton).toBeDisabled();
|
||||
expect(createRequestMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates a request and shows a success toast', async () => {
|
||||
authState.user = { id: 'user-1', username: 'user' };
|
||||
createRequestMock.mockResolvedValueOnce(undefined);
|
||||
|
||||
const onRequestSuccess = vi.fn();
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} onRequestSuccess={onRequestSuccess} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request' }));
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
await requestPromise;
|
||||
});
|
||||
|
||||
expect(createRequestMock).toHaveBeenCalledWith(baseAudiobook);
|
||||
expect(onRequestSuccess).toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByText(/Request created successfully/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(3000);
|
||||
});
|
||||
expect(screen.queryByText(/Request created successfully/)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows in-library state when available', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={{ ...baseAudiobook, isAvailable: true }} />);
|
||||
|
||||
expect(screen.getByText('In Your Library')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the details modal when the title is clicked', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} />);
|
||||
|
||||
expect(screen.getByTestId('details-modal')).toHaveAttribute('data-open', 'false');
|
||||
|
||||
fireEvent.click(screen.getByText('Test Book'));
|
||||
|
||||
expect(screen.getByTestId('details-modal')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('shows processing state for downloaded requests', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(
|
||||
<AudiobookCard
|
||||
audiobook={{ ...baseAudiobook, isRequested: true, requestStatus: 'downloaded' }}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Processing...' });
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows pending approval status with requester name', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(
|
||||
<AudiobookCard
|
||||
audiobook={{
|
||||
...baseAudiobook,
|
||||
isRequested: true,
|
||||
requestStatus: 'awaiting_approval',
|
||||
requestedByUsername: 'alice',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: /Pending Approval \(alice\)/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a denied request state', async () => {
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(
|
||||
<AudiobookCard
|
||||
audiobook={{ ...baseAudiobook, isRequested: true, requestStatus: 'denied' }}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Request Denied' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows an error when a request fails', async () => {
|
||||
authState.user = { id: 'user-1', username: 'user' };
|
||||
createRequestMock.mockRejectedValueOnce(new Error('Request failed'));
|
||||
|
||||
const { AudiobookCard } = await import('@/components/audiobooks/AudiobookCard');
|
||||
|
||||
render(<AudiobookCard audiobook={baseAudiobook} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request' }));
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestPromise;
|
||||
} catch {
|
||||
// Expected for this test.
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('Request failed')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(screen.queryByText('Request failed')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Component: Audiobook Details Modal Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useAuthMock = vi.hoisted(() => vi.fn());
|
||||
const useAudiobookDetailsMock = vi.hoisted(() => vi.fn());
|
||||
const createRequestMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => useAuthMock(),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useAudiobooks', () => ({
|
||||
useAudiobookDetails: (asin: string | null) => useAudiobookDetailsMock(asin),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/useRequests', () => ({
|
||||
useCreateRequest: () => ({ createRequest: createRequestMock, isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
InteractiveTorrentSearchModal: ({ isOpen }: { isOpen: boolean }) => (
|
||||
<div data-testid="interactive-modal" data-open={String(isOpen)} />
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: any) => <img {...props} />,
|
||||
}));
|
||||
|
||||
const audiobookDetails = {
|
||||
asin: 'ASIN123',
|
||||
title: 'Detail Book',
|
||||
author: 'Detail Author',
|
||||
description: 'Summary',
|
||||
rating: 4.2,
|
||||
durationMinutes: 320,
|
||||
releaseDate: '2023-01-01',
|
||||
genres: ['Fantasy'],
|
||||
};
|
||||
|
||||
describe('AudiobookDetailsModal', () => {
|
||||
beforeEach(() => {
|
||||
useAuthMock.mockReturnValue({ user: { id: 'user-1', username: 'user' } });
|
||||
useAudiobookDetailsMock.mockReturnValue({
|
||||
audiobook: audiobookDetails,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
createRequestMock.mockReset();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders audiobook details and closes when requested', async () => {
|
||||
const onClose = vi.fn();
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Detail Book')).toBeInTheDocument();
|
||||
expect(document.body.style.overflow).toBe('hidden');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Close modal' }));
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates requests and auto-closes after success', async () => {
|
||||
vi.useFakeTimers();
|
||||
createRequestMock.mockResolvedValueOnce(undefined);
|
||||
const onClose = vi.fn();
|
||||
const onRequestSuccess = vi.fn();
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={onClose}
|
||||
onRequestSuccess={onRequestSuccess}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
const requestButton = screen.getByRole('button', { name: 'Request Audiobook' });
|
||||
fireEvent.click(requestButton);
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
await requestPromise;
|
||||
});
|
||||
|
||||
expect(onRequestSuccess).toHaveBeenCalled();
|
||||
expect(screen.getByText(/Request created successfully/)).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('copies the ASIN to the clipboard', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
const asinButton = screen.getByText('ASIN123');
|
||||
await act(async () => {
|
||||
fireEvent.click(asinButton.closest('button') as HTMLButtonElement);
|
||||
});
|
||||
|
||||
expect(navigator.clipboard.writeText).toHaveBeenCalledWith('ASIN123');
|
||||
});
|
||||
|
||||
it('shows an error state when details fail to load', async () => {
|
||||
useAudiobookDetailsMock.mockReturnValue({
|
||||
audiobook: null,
|
||||
isLoading: false,
|
||||
error: 'boom',
|
||||
});
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Failed to load audiobook details')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows availability state and hides interactive search when available', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
isAvailable={true}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Available in Your Library')).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Interactive Search')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows pending approval status with requester name', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
isRequested={true}
|
||||
requestStatus="awaiting_approval"
|
||||
requestedByUsername="alice"
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole('button', { name: /Pending Approval \(alice\)/ })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows a denied request state', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
isRequested={true}
|
||||
requestStatus="denied"
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByRole('button', { name: 'Request Denied' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows Not Found when rating is missing', async () => {
|
||||
useAudiobookDetailsMock.mockReturnValue({
|
||||
audiobook: { ...audiobookDetails, rating: 0 },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
expect(screen.getByText('Not Found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens interactive search when requested', async () => {
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
|
||||
expect(screen.queryByTestId('interactive-modal')).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Interactive Search'));
|
||||
|
||||
expect(screen.getByTestId('interactive-modal')).toHaveAttribute('data-open', 'true');
|
||||
});
|
||||
|
||||
it('shows request error and clears it after timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
createRequestMock.mockRejectedValueOnce(new Error('Request failed'));
|
||||
const { AudiobookDetailsModal } = await import('@/components/audiobooks/AudiobookDetailsModal');
|
||||
|
||||
render(
|
||||
<AudiobookDetailsModal
|
||||
asin="ASIN123"
|
||||
isOpen={true}
|
||||
onClose={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
await act(async () => {});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Request Audiobook' }));
|
||||
|
||||
const requestPromise = createRequestMock.mock.results[0]?.value;
|
||||
await act(async () => {
|
||||
try {
|
||||
await requestPromise;
|
||||
} catch {
|
||||
// Expected for this test.
|
||||
}
|
||||
});
|
||||
|
||||
expect(screen.getByText('Request failed')).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Request failed')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Component: Audiobook Grid Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import path from 'path';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockAudiobookCard = () => {
|
||||
vi.doMock(path.resolve('src/components/audiobooks/AudiobookCard.tsx'), () => ({
|
||||
AudiobookCard: ({ audiobook }: { audiobook: any }) => (
|
||||
<div data-testid="audiobook-card">{audiobook.asin}</div>
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
describe('AudiobookGrid', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mockAudiobookCard();
|
||||
});
|
||||
|
||||
it('renders skeleton cards when loading', async () => {
|
||||
const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid');
|
||||
|
||||
const { container } = render(<AudiobookGrid audiobooks={[]} isLoading={true} />);
|
||||
|
||||
expect(container.querySelectorAll('.animate-pulse')).toHaveLength(8);
|
||||
});
|
||||
|
||||
it('shows the empty message when there are no results', async () => {
|
||||
const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid');
|
||||
|
||||
render(<AudiobookGrid audiobooks={[]} isLoading={false} emptyMessage="Nothing found" />);
|
||||
|
||||
expect(screen.getByText('Nothing found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('applies grid classes based on card size', async () => {
|
||||
const { AudiobookGrid } = await import('@/components/audiobooks/AudiobookGrid');
|
||||
|
||||
const { container } = render(
|
||||
<AudiobookGrid
|
||||
audiobooks={[{ asin: 'a1', title: 'Book', author: 'Author' }]}
|
||||
cardSize={9}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.querySelector('div')?.className).toContain('grid-cols-1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user