mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 21:00: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:
@@ -6,7 +6,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { act, render, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const useAuthMock = vi.hoisted(() => vi.fn());
|
||||
@@ -37,6 +37,16 @@ const renderHookValue = <T,>(hook: () => T) => {
|
||||
return value!;
|
||||
};
|
||||
|
||||
const renderHook = <T,>(hook: () => T) => {
|
||||
const result = { current: undefined as T };
|
||||
function Probe() {
|
||||
result.current = hook();
|
||||
return null;
|
||||
}
|
||||
render(<Probe />);
|
||||
return result;
|
||||
};
|
||||
|
||||
const makeResponse = (body: any, ok = true) => ({
|
||||
ok,
|
||||
json: async () => body,
|
||||
@@ -66,6 +76,21 @@ describe('useRequests hooks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('skips request list endpoints when unauthenticated', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: null });
|
||||
useSWRMock.mockReturnValue({ data: null, error: null, isLoading: false });
|
||||
|
||||
const { useRequests } = await import('@/lib/hooks/useRequests');
|
||||
|
||||
renderHookValue(() => useRequests());
|
||||
|
||||
expect(useSWRMock).toHaveBeenCalledWith(
|
||||
null,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({ refreshInterval: 5000 })
|
||||
);
|
||||
});
|
||||
|
||||
it('builds request detail endpoints when authenticated', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
useSWRMock.mockReturnValue({ data: { request: { id: 'req-1' } }, error: null, isLoading: false });
|
||||
@@ -100,6 +125,37 @@ describe('useRequests hooks', () => {
|
||||
expect(mutateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds skipAutoSearch query params when creating requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-10' } }));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.createRequest(
|
||||
{ asin: 'a10', title: 'Book', author: 'Author' } as any,
|
||||
{ skipAutoSearch: true }
|
||||
);
|
||||
});
|
||||
|
||||
expect(fetchWithAuthMock).toHaveBeenCalledWith(
|
||||
'/api/requests?skipAutoSearch=true',
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when creating a request without authentication', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: null });
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await expect(
|
||||
result.current.createRequest({ asin: 'a1', title: 'Book', author: 'Author' } as any)
|
||||
).rejects.toThrow('Not authenticated');
|
||||
});
|
||||
|
||||
it('surfaces specific create request errors', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false));
|
||||
@@ -114,6 +170,42 @@ describe('useRequests hooks', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces being processed errors when creating requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'BeingProcessed' }, false));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.createRequest({ asin: 'a2', title: 'Book', author: 'Author' } as any)
|
||||
).rejects.toThrow('being processed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toContain('being processed');
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces API error messages when creating requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Backend refused' }, false));
|
||||
|
||||
const { useCreateRequest } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useCreateRequest());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.createRequest({ asin: 'a3', title: 'Book', author: 'Author' } as any)
|
||||
).rejects.toThrow('Backend refused');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Backend refused');
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels requests via the API', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-2' } }));
|
||||
@@ -148,6 +240,22 @@ describe('useRequests hooks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('captures API errors when triggering manual search', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Manual search failed' }, false));
|
||||
|
||||
const { useManualSearch } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useManualSearch());
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.triggerManualSearch('req-3')).rejects.toThrow('Manual search failed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Manual search failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('searches torrents interactively for a request', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ results: [{ guid: 't1' }] }));
|
||||
@@ -166,6 +274,22 @@ describe('useRequests hooks', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('reports interactive search errors', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ message: 'Search failed' }, false));
|
||||
|
||||
const { useInteractiveSearch } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useInteractiveSearch());
|
||||
|
||||
await act(async () => {
|
||||
await expect(result.current.searchTorrents('req-4')).rejects.toThrow('Search failed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toBe('Search failed');
|
||||
});
|
||||
});
|
||||
|
||||
it('selects torrents for existing requests', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ request: { id: 'req-5' } }));
|
||||
@@ -217,4 +341,46 @@ describe('useRequests hooks', () => {
|
||||
expect.objectContaining({ method: 'POST' })
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces being processed errors when requesting with torrents', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'BeingProcessed' }, false));
|
||||
|
||||
const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useRequestWithTorrent());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.requestWithTorrent(
|
||||
{ asin: 'a4', title: 'Book', author: 'Author' } as any,
|
||||
{ title: 'Torrent' }
|
||||
)
|
||||
).rejects.toThrow('being processed');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toContain('being processed');
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces already available errors when requesting with torrents', async () => {
|
||||
useAuthMock.mockReturnValue({ accessToken: 'token' });
|
||||
fetchWithAuthMock.mockResolvedValueOnce(makeResponse({ error: 'AlreadyAvailable' }, false));
|
||||
|
||||
const { useRequestWithTorrent } = await import('@/lib/hooks/useRequests');
|
||||
const result = renderHook(() => useRequestWithTorrent());
|
||||
|
||||
await act(async () => {
|
||||
await expect(
|
||||
result.current.requestWithTorrent(
|
||||
{ asin: 'a5', title: 'Book', author: 'Author' } as any,
|
||||
{ title: 'Torrent' }
|
||||
)
|
||||
).rejects.toThrow('already in your Plex library');
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.error).toContain('already in your Plex library');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user