Files
ReadMeABook/tests/services/audiobookshelf-api.test.ts
T
kikootwo eca24e46a8 Add test mocks and update delete API assertion
Add missing mocks used by updated code paths: mock PreferencesContext in profile page tests and add useReplaceWithTorrent/replaceWithTorrent mock for InteractiveTorrentSearchModal tests. Update Audiobookshelf API test to expect DELETE to include ?hard=1 and Authorization header. Extend the prisma test helper in audiobook-matcher tests with a reportedIssue.findMany mock and ensure it resolves to an empty array for the test.
2026-02-11 17:02:21 -05:00

301 lines
9.4 KiB
TypeScript

/**
* Component: Audiobookshelf API Client Tests
* Documentation: documentation/features/audiobookshelf-integration.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
absRequest,
deleteABSItem,
getABSLibraries,
getABSLibraryItems,
getABSRecentItems,
getABSServerInfo,
searchABSItems,
triggerABSItemMatch,
triggerABSScan,
} from '@/lib/services/audiobookshelf/api';
const configServiceMock = vi.hoisted(() => ({
get: vi.fn(),
getAudibleRegion: vi.fn(),
}));
const fetchMock = vi.hoisted(() => vi.fn());
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configServiceMock,
}));
describe('Audiobookshelf API client', () => {
beforeEach(() => {
vi.clearAllMocks();
configServiceMock.get.mockReset();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
});
it('throws when Audiobookshelf config is missing', async () => {
configServiceMock.get.mockResolvedValue(null);
await expect(absRequest('/status')).rejects.toThrow('Audiobookshelf not configured');
});
it('returns parsed JSON for successful requests', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({ version: '2.0.0', name: 'ABS' }),
});
const info = await getABSServerInfo();
expect(info).toEqual({ version: '2.0.0', name: 'ABS' });
expect(fetchMock).toHaveBeenCalledWith('http://abs/api/status', expect.any(Object));
});
it('throws when ABS responds with an error status', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
fetchMock.mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
});
await expect(absRequest('/status')).rejects.toThrow('ABS API error: 401 Unauthorized');
});
it('maps library responses and search queries', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
fetchMock
.mockResolvedValueOnce({
ok: true,
json: async () => ({ libraries: [{ id: 'lib-1' }] }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ results: [{ id: 'item-1' }] }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ results: [{ id: 'recent-1' }] }),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({ book: [{ id: 'result-1' }] }),
});
expect(await getABSLibraries()).toEqual([{ id: 'lib-1' }]);
expect(await getABSLibraryItems('lib-1')).toEqual([{ id: 'item-1' }]);
expect(await getABSRecentItems('lib-1', 5)).toEqual([{ id: 'recent-1' }]);
expect(await searchABSItems('lib-1', 'hello world')).toEqual([{ id: 'result-1' }]);
expect(fetchMock.mock.calls[1][0]).toBe('http://abs/api/libraries/lib-1/items');
expect(fetchMock.mock.calls[2][0]).toBe('http://abs/api/libraries/lib-1/items?sort=addedAt&desc=1&limit=5');
expect(fetchMock.mock.calls[3][0]).toBe('http://abs/api/libraries/lib-1/search?q=hello%20world');
});
it('returns an empty array when search results are missing', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
});
expect(await searchABSItems('lib-1', 'missing')).toEqual([]);
});
it('triggers library scan using plain text responses', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
fetchMock.mockResolvedValue({
ok: true,
text: async () => 'OK',
});
await triggerABSScan('lib-1');
expect(fetchMock).toHaveBeenCalledWith('http://abs/api/libraries/lib-1/scan', expect.objectContaining({
method: 'POST',
}));
});
it('includes ASIN overrides in metadata match requests with US region', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
configServiceMock.getAudibleRegion.mockResolvedValue('us');
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
});
await triggerABSItemMatch('item-1', 'ASIN123');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({
provider: 'audible', // US uses 'audible'
asin: 'ASIN123',
overrideDefaults: true,
});
});
it('uses region-specific provider for Canada', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
configServiceMock.getAudibleRegion.mockResolvedValue('ca');
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
});
await triggerABSItemMatch('item-1', 'ASIN123');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({
provider: 'audible.ca',
asin: 'ASIN123',
overrideDefaults: true,
});
});
it('uses region-specific provider for UK', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
configServiceMock.getAudibleRegion.mockResolvedValue('uk');
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
});
await triggerABSItemMatch('item-1');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({
provider: 'audible.uk',
});
});
it('uses region-specific provider for Australia', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
configServiceMock.getAudibleRegion.mockResolvedValue('au');
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
});
await triggerABSItemMatch('item-1', 'ASIN456');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({
provider: 'audible.au',
asin: 'ASIN456',
overrideDefaults: true,
});
});
it('uses region-specific provider for India', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
configServiceMock.getAudibleRegion.mockResolvedValue('in');
fetchMock.mockResolvedValue({
ok: true,
json: async () => ({}),
});
await triggerABSItemMatch('item-1', 'ASIN789');
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body).toEqual({
provider: 'audible.in',
asin: 'ASIN789',
overrideDefaults: true,
});
});
it('suppresses errors when metadata match fails', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
configServiceMock.getAudibleRegion.mockResolvedValue('us');
fetchMock.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Boom',
});
await expect(triggerABSItemMatch('item-1', 'ASIN123')).resolves.toBeUndefined();
});
it('deletes a library item successfully', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
fetchMock.mockResolvedValue({
ok: true,
});
await expect(deleteABSItem('item-1')).resolves.toBeUndefined();
expect(fetchMock).toHaveBeenCalledWith('http://abs/api/items/item-1?hard=1', {
method: 'DELETE',
headers: {
'Authorization': 'Bearer token',
},
});
});
it('throws when delete fails', async () => {
configServiceMock.get.mockImplementation(async (key: string) => {
if (key === 'audiobookshelf.server_url') return 'http://abs';
if (key === 'audiobookshelf.api_token') return 'token';
return null;
});
fetchMock.mockResolvedValue({
ok: false,
status: 500,
statusText: 'Boom',
});
await expect(deleteABSItem('item-1')).rejects.toThrow('ABS API error: 500 Boom');
});
});