mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
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.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Component: Active Downloads Table Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ActiveDownloadsTable } from '@/app/admin/components/ActiveDownloadsTable';
|
||||
|
||||
describe('ActiveDownloadsTable', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders an empty state when no downloads exist', () => {
|
||||
render(<ActiveDownloadsTable downloads={[]} />);
|
||||
|
||||
expect(screen.getByText('No Active Downloads')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders download details with formatted values', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:00Z'));
|
||||
|
||||
render(
|
||||
<ActiveDownloadsTable
|
||||
downloads={[
|
||||
{
|
||||
requestId: 'req-1',
|
||||
title: 'Active Book',
|
||||
author: 'Author One',
|
||||
progress: 42,
|
||||
speed: 1024 * 1024,
|
||||
eta: 3600,
|
||||
user: 'Zach',
|
||||
startedAt: new Date('2023-12-31T23:00:00Z'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Active Book')).toBeInTheDocument();
|
||||
expect(screen.getByText('Author One')).toBeInTheDocument();
|
||||
expect(screen.getByText('42%')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 MB/s')).toBeInTheDocument();
|
||||
expect(screen.getByText('1h 0m')).toBeInTheDocument();
|
||||
expect(screen.getByText(/ago/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Component: Confirm Dialog Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ConfirmDialog } from '@/app/admin/components/ConfirmDialog';
|
||||
|
||||
describe('ConfirmDialog', () => {
|
||||
it('renders nothing when closed', () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
isOpen={false}
|
||||
title="Delete"
|
||||
message="Confirm?"
|
||||
onConfirm={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('invokes confirm and cancel actions', () => {
|
||||
const onConfirm = vi.fn();
|
||||
const onCancel = vi.fn();
|
||||
|
||||
const { container } = render(
|
||||
<ConfirmDialog
|
||||
isOpen
|
||||
title="Delete"
|
||||
message="Confirm?"
|
||||
onConfirm={onConfirm}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Confirm' }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
|
||||
const backdrop = container.querySelector('[aria-hidden="true"]');
|
||||
expect(backdrop).not.toBeNull();
|
||||
if (backdrop) {
|
||||
fireEvent.click(backdrop);
|
||||
}
|
||||
expect(onCancel).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Component: Metric Card Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MetricCard } from '@/app/admin/components/MetricCard';
|
||||
|
||||
describe('MetricCard', () => {
|
||||
it('renders title, value, and subtitle with variant styles', () => {
|
||||
const { container } = render(
|
||||
<MetricCard
|
||||
title="Errors"
|
||||
value={3}
|
||||
subtitle="Last 24h"
|
||||
variant="error"
|
||||
icon={<span>!</span>}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Errors')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('Last 24h')).toBeInTheDocument();
|
||||
expect(container.firstChild).toHaveClass('bg-red-50');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Component: Recent Requests Table Tests
|
||||
* Documentation: documentation/admin-dashboard.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import path from 'path';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const fetchWithAuthMock = vi.hoisted(() => vi.fn());
|
||||
const mutateMock = vi.hoisted(() => vi.fn());
|
||||
const toastMock = vi.hoisted(() => ({
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('swr', () => ({
|
||||
mutate: mutateMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/api', () => ({
|
||||
fetchWithAuth: fetchWithAuthMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/Toast', () => ({
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
let RecentRequestsTable: typeof import('@/app/admin/components/RecentRequestsTable').RecentRequestsTable;
|
||||
|
||||
describe('RecentRequestsTable', () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
fetchWithAuthMock.mockReset();
|
||||
mutateMock.mockReset();
|
||||
toastMock.success.mockReset();
|
||||
toastMock.error.mockReset();
|
||||
toastMock.warning.mockReset();
|
||||
|
||||
vi.doMock(path.resolve('src/app/admin/components/RequestActionsDropdown.tsx'), () => ({
|
||||
RequestActionsDropdown: ({
|
||||
request,
|
||||
onDelete,
|
||||
onManualSearch,
|
||||
onCancel,
|
||||
onFetchEbook,
|
||||
isLoading,
|
||||
}: {
|
||||
request: { requestId: string; title: string };
|
||||
onDelete: (requestId: string, title: string) => void;
|
||||
onManualSearch: (requestId: string) => void;
|
||||
onCancel: (requestId: string) => void;
|
||||
onFetchEbook?: (requestId: string) => void;
|
||||
isLoading?: boolean;
|
||||
}) => (
|
||||
<div>
|
||||
<button type="button" onClick={() => onDelete(request.requestId, request.title)}>
|
||||
Delete Trigger
|
||||
</button>
|
||||
<button type="button" onClick={() => onManualSearch(request.requestId)}>
|
||||
Manual Search Trigger
|
||||
</button>
|
||||
<button type="button" onClick={() => onCancel(request.requestId)}>
|
||||
Cancel Trigger
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onFetchEbook?.(request.requestId)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Fetch Ebook Trigger
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const module = await import('@/app/admin/components/RecentRequestsTable');
|
||||
RecentRequestsTable = module.RecentRequestsTable;
|
||||
});
|
||||
|
||||
it('shows empty state when there are no requests', () => {
|
||||
render(<RecentRequestsTable requests={[]} />);
|
||||
|
||||
expect(screen.getByText('No Recent Requests')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('deletes a request and refreshes caches', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: true }),
|
||||
});
|
||||
|
||||
render(
|
||||
<RecentRequestsTable
|
||||
requests={[
|
||||
{
|
||||
requestId: 'req-1',
|
||||
title: 'Delete Me',
|
||||
author: 'Author',
|
||||
status: 'pending',
|
||||
user: 'User',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete Trigger' }));
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Delete' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/admin/requests/req-1', {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/requests/recent');
|
||||
expect(mutateMock).toHaveBeenCalledWith('/api/admin/metrics');
|
||||
|
||||
const predicateCall = mutateMock.mock.calls.find(
|
||||
(call) => typeof call[0] === 'function'
|
||||
);
|
||||
expect(predicateCall).toBeTruthy();
|
||||
const predicate = predicateCall?.[0] as (key: unknown) => boolean;
|
||||
expect(predicate('/api/audiobooks?query=test')).toBe(true);
|
||||
expect(predicate('/api/other')).toBe(false);
|
||||
});
|
||||
|
||||
it('warns when ebook fetch fails', async () => {
|
||||
fetchWithAuthMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ success: false, message: 'No ebook available' }),
|
||||
});
|
||||
|
||||
render(
|
||||
<RecentRequestsTable
|
||||
requests={[
|
||||
{
|
||||
requestId: 'req-2',
|
||||
title: 'Needs Ebook',
|
||||
author: 'Author',
|
||||
status: 'downloaded',
|
||||
user: 'User',
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
]}
|
||||
ebookSidecarEnabled
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Fetch Ebook Trigger' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith('/api/requests/req-2/fetch-ebook', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
expect(toastMock.warning).toHaveBeenCalledWith(
|
||||
'E-book fetch failed: No ebook available'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Component: Request Actions Dropdown Tests
|
||||
* Documentation: documentation/admin-features/request-deletion.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { RequestActionsDropdown } from '@/app/admin/components/RequestActionsDropdown';
|
||||
|
||||
vi.mock('@/hooks/useSmartDropdownPosition', () => ({
|
||||
useSmartDropdownPosition: () => ({
|
||||
containerRef: { current: null },
|
||||
dropdownRef: { current: null },
|
||||
positionAbove: false,
|
||||
style: { position: 'fixed', top: 0, left: 0, minWidth: 120 },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/requests/InteractiveTorrentSearchModal', () => ({
|
||||
InteractiveTorrentSearchModal: ({
|
||||
isOpen,
|
||||
audiobook,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
audiobook: { title: string; author: string };
|
||||
}) => (isOpen ? <div>Interactive search for {audiobook.title}</div> : null),
|
||||
}));
|
||||
|
||||
describe('RequestActionsDropdown', () => {
|
||||
it('exposes manual search, interactive search, cancel, and delete actions', async () => {
|
||||
const onManualSearch = vi.fn().mockResolvedValue(undefined);
|
||||
const onCancel = vi.fn().mockResolvedValue(undefined);
|
||||
const onDelete = vi.fn();
|
||||
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
|
||||
render(
|
||||
<RequestActionsDropdown
|
||||
request={{
|
||||
requestId: 'req-1',
|
||||
title: 'Pending Book',
|
||||
author: 'Author',
|
||||
status: 'pending',
|
||||
}}
|
||||
onManualSearch={onManualSearch}
|
||||
onCancel={onCancel}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
|
||||
expect(screen.getByText('Manual Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Interactive Search')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cancel Request')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete Request')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Manual Search'));
|
||||
await waitFor(() => expect(onManualSearch).toHaveBeenCalledWith('req-1'));
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
fireEvent.click(screen.getByText('Interactive Search'));
|
||||
expect(screen.getByText('Interactive search for Pending Book')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
fireEvent.click(screen.getByText('Cancel Request'));
|
||||
await waitFor(() => expect(onCancel).toHaveBeenCalledWith('req-1'));
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
fireEvent.click(screen.getByText('Delete Request'));
|
||||
expect(onDelete).toHaveBeenCalledWith('req-1', 'Pending Book');
|
||||
});
|
||||
|
||||
it('shows view source and ebook fetch when available', async () => {
|
||||
const onFetchEbook = vi.fn().mockResolvedValue(undefined);
|
||||
const onDelete = vi.fn();
|
||||
|
||||
render(
|
||||
<RequestActionsDropdown
|
||||
request={{
|
||||
requestId: 'req-2',
|
||||
title: 'Downloaded Book',
|
||||
author: 'Author',
|
||||
status: 'downloaded',
|
||||
torrentUrl: 'https://example.com/torrent',
|
||||
}}
|
||||
onManualSearch={vi.fn().mockResolvedValue(undefined)}
|
||||
onCancel={vi.fn().mockResolvedValue(undefined)}
|
||||
onDelete={onDelete}
|
||||
onFetchEbook={onFetchEbook}
|
||||
ebookSidecarEnabled
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Actions'));
|
||||
|
||||
expect(screen.getByText('View Source')).toBeInTheDocument();
|
||||
expect(screen.getByText('Try to fetch Ebook')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByText('Try to fetch Ebook'));
|
||||
await waitFor(() => expect(onFetchEbook).toHaveBeenCalledWith('req-2'));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user