/** * Component: Audiobookshelf Library Service Tests * Documentation: documentation/features/audiobookshelf-integration.md */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AudiobookshelfLibraryService } from '@/lib/services/library/AudiobookshelfLibraryService'; const apiMock = vi.hoisted(() => ({ getABSServerInfo: vi.fn(), getABSLibraries: vi.fn(), getABSLibraryItems: vi.fn(), getABSRecentItems: vi.fn(), getABSItem: vi.fn(), searchABSItems: vi.fn(), triggerABSScan: vi.fn(), })); const configServiceMock = vi.hoisted(() => ({ getMany: vi.fn(), })); vi.mock('@/lib/services/audiobookshelf/api', () => apiMock); vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); // --- Test data helpers --- /** Creates a mock ABS item with audio files (audiobook) */ function makeAudiobookItem(overrides: Record = {}) { return { id: overrides.id ?? 'item-1', addedAt: overrides.addedAt ?? 1700000000000, updatedAt: overrides.updatedAt ?? 1700000100000, media: { duration: overrides.duration ?? 3600, coverPath: overrides.coverPath ?? '/covers/1.jpg', numAudioFiles: overrides.numAudioFiles ?? 1, numTracks: overrides.numTracks ?? 1, audioFiles: overrides.audioFiles ?? [ { index: 0, ino: 'ino-1', metadata: { filename: 'chapter01.mp3', ext: '.mp3', path: '/books/chapter01.mp3', size: 5000000, mtimeMs: 1700000000000 }, duration: 3600, }, ], metadata: { title: overrides.title ?? 'Audiobook Title', authorName: overrides.authorName ?? 'Author', narratorName: overrides.narratorName ?? 'Narrator', description: overrides.description ?? 'Description', asin: overrides.asin ?? 'B00ASIN001', isbn: overrides.isbn ?? 'ISBN001', publishedYear: overrides.publishedYear ?? '2020', genres: overrides.genres ?? [], explicit: false, }, }, }; } /** Creates a mock ABS item with NO audio files (ebook-only) */ function makeEbookOnlyItem(overrides: Record = {}) { return { id: overrides.id ?? 'ebook-1', addedAt: overrides.addedAt ?? 1700000000000, updatedAt: overrides.updatedAt ?? 1700000100000, media: { duration: 0, coverPath: overrides.coverPath ?? '/covers/ebook.jpg', numAudioFiles: 0, numTracks: 0, audioFiles: [], ebookFile: overrides.ebookFile ?? { ino: 'ino-e1', metadata: { filename: 'book.epub', ext: '.epub' } }, metadata: { title: overrides.title ?? 'Ebook Title', authorName: overrides.authorName ?? 'Ebook Author', narratorName: undefined, description: overrides.description ?? 'Ebook Description', asin: overrides.asin ?? 'B00EBOOK01', isbn: overrides.isbn ?? 'ISBN-EBOOK', publishedYear: overrides.publishedYear ?? '2023', genres: overrides.genres ?? [], explicit: false, }, }, }; } describe('AudiobookshelfLibraryService', () => { beforeEach(() => { vi.clearAllMocks(); }); it('tests connection and returns server info', async () => { apiMock.getABSServerInfo.mockResolvedValue({ name: 'ABS', version: '2.0.0' }); const service = new AudiobookshelfLibraryService(); const result = await service.testConnection(); expect(result.success).toBe(true); expect(result.serverInfo).toEqual({ name: 'ABS', version: '2.0.0', identifier: 'ABS', }); }); it('returns errors when server info fails', async () => { apiMock.getABSServerInfo.mockRejectedValue(new Error('No connection')); const service = new AudiobookshelfLibraryService(); const result = await service.testConnection(); expect(result.success).toBe(false); expect(result.error).toBe('No connection'); }); it('filters audiobook libraries only', async () => { apiMock.getABSLibraries.mockResolvedValue([ { id: 'lib-1', name: 'Books', mediaType: 'book', stats: { totalItems: 10 } }, { id: 'lib-2', name: 'Podcasts', mediaType: 'podcast', stats: { totalItems: 5 } }, ]); const service = new AudiobookshelfLibraryService(); const libs = await service.getLibraries(); expect(libs).toEqual([ { id: 'lib-1', name: 'Books', type: 'book', itemCount: 10 }, ]); }); it('maps library items to generic fields', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ makeAudiobookItem({ id: 'item-1', title: 'Title', authorName: 'Author', narratorName: 'Narrator', description: 'Desc', asin: 'ASIN1', isbn: 'ISBN1', publishedYear: '2020', duration: 3600, coverPath: '/covers/1.jpg', }), ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items[0]).toEqual({ id: 'item-1', externalId: 'item-1', title: 'Title', author: 'Author', narrator: 'Narrator', description: 'Desc', coverUrl: '/api/items/item-1/cover', duration: 3600, asin: 'ASIN1', isbn: 'ISBN1', year: 2020, addedAt: new Date(1700000000000), updatedAt: new Date(1700000100000), }); }); it('returns null when item fetch fails', async () => { apiMock.getABSItem.mockRejectedValue(new Error('missing')); const service = new AudiobookshelfLibraryService(); const result = await service.getItem('item-1'); expect(result).toBeNull(); }); it('searches items and maps results', async () => { apiMock.searchABSItems.mockResolvedValue([ { libraryItem: makeAudiobookItem({ id: 'item-2', title: 'Search Title', authorName: 'Search Author', narratorName: '', description: '', duration: 200, coverPath: undefined, asin: undefined, isbn: undefined, publishedYear: undefined, }), }, ]); const service = new AudiobookshelfLibraryService(); const items = await service.searchItems('lib-1', 'Search'); expect(items[0].title).toBe('Search Title'); expect(items[0].author).toBe('Search Author'); }); it('triggers library scans', async () => { apiMock.triggerABSScan.mockResolvedValue(undefined); const service = new AudiobookshelfLibraryService(); await service.triggerLibraryScan('lib-1'); expect(apiMock.triggerABSScan).toHaveBeenCalledWith('lib-1'); }); it('returns cover caching params for Audiobookshelf backend', async () => { configServiceMock.getMany.mockResolvedValue({ 'audiobookshelf.server_url': 'http://abs:13378', 'audiobookshelf.api_token': 'abs-token-456', }); const service = new AudiobookshelfLibraryService(); const params = await service.getCoverCachingParams(); expect(params).toEqual({ backendBaseUrl: 'http://abs:13378', authToken: 'abs-token-456', backendMode: 'audiobookshelf', }); }); it('throws when getting cover caching params without server URL', async () => { configServiceMock.getMany.mockResolvedValue({ 'audiobookshelf.server_url': null, 'audiobookshelf.api_token': 'token', }); const service = new AudiobookshelfLibraryService(); await expect(service.getCoverCachingParams()).rejects.toThrow('Audiobookshelf server configuration is incomplete'); }); it('throws when getting cover caching params without API token', async () => { configServiceMock.getMany.mockResolvedValue({ 'audiobookshelf.server_url': 'http://abs', 'audiobookshelf.api_token': null, }); const service = new AudiobookshelfLibraryService(); await expect(service.getCoverCachingParams()).rejects.toThrow('Audiobookshelf server configuration is incomplete'); }); // --- Ebook-only filtering tests --- describe('ebook-only item filtering', () => { it('getLibraryItems excludes ebook-only items (no audio files)', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ makeAudiobookItem({ id: 'audio-1', title: 'Audiobook One' }), makeEbookOnlyItem({ id: 'ebook-1', title: 'Ebook One' }), makeAudiobookItem({ id: 'audio-2', title: 'Audiobook Two' }), ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(2); expect(items.map(i => i.id)).toEqual(['audio-1', 'audio-2']); }); it('getLibraryItems returns empty when all items are ebook-only', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ makeEbookOnlyItem({ id: 'ebook-1' }), makeEbookOnlyItem({ id: 'ebook-2' }), ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(0); }); it('getLibraryItems returns all items when none are ebook-only', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ makeAudiobookItem({ id: 'audio-1' }), makeAudiobookItem({ id: 'audio-2' }), makeAudiobookItem({ id: 'audio-3' }), ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(3); }); it('getRecentlyAdded excludes ebook-only items', async () => { apiMock.getABSRecentItems.mockResolvedValue([ makeEbookOnlyItem({ id: 'ebook-recent' }), makeAudiobookItem({ id: 'audio-recent' }), ]); const service = new AudiobookshelfLibraryService(); const items = await service.getRecentlyAdded('lib-1', 10); expect(items).toHaveLength(1); expect(items[0].id).toBe('audio-recent'); }); it('getItem returns null for ebook-only item', async () => { apiMock.getABSItem.mockResolvedValue( makeEbookOnlyItem({ id: 'ebook-1' }) ); const service = new AudiobookshelfLibraryService(); const result = await service.getItem('ebook-1'); expect(result).toBeNull(); }); it('getItem returns audiobook item with audio files', async () => { apiMock.getABSItem.mockResolvedValue( makeAudiobookItem({ id: 'audio-1', title: 'Real Audiobook' }) ); const service = new AudiobookshelfLibraryService(); const result = await service.getItem('audio-1'); expect(result).not.toBeNull(); expect(result!.title).toBe('Real Audiobook'); }); it('searchItems excludes ebook-only results', async () => { apiMock.searchABSItems.mockResolvedValue([ { libraryItem: makeAudiobookItem({ id: 'audio-match', title: 'Audio Match' }) }, { libraryItem: makeEbookOnlyItem({ id: 'ebook-match', title: 'Ebook Match' }) }, ]); const service = new AudiobookshelfLibraryService(); const items = await service.searchItems('lib-1', 'Match'); expect(items).toHaveLength(1); expect(items[0].title).toBe('Audio Match'); }); it('handles items with missing media field gracefully', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ makeAudiobookItem({ id: 'audio-1' }), { id: 'broken-1', addedAt: 1700000000000, updatedAt: 1700000000000 }, // no media field at all ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(1); expect(items[0].id).toBe('audio-1'); }); it('handles items with undefined audioFiles gracefully', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ makeAudiobookItem({ id: 'audio-1' }), { id: 'no-audio-field', addedAt: 1700000000000, updatedAt: 1700000000000, media: { duration: 0, metadata: { title: 'Broken', authorName: 'Author', genres: [], explicit: false }, // audioFiles intentionally absent }, }, ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(1); expect(items[0].id).toBe('audio-1'); }); it('treats item with both audio files and ebook file as audiobook (passes filter)', async () => { // ABS can have items with both audio + ebook (companion ebook) const hybridItem = makeAudiobookItem({ id: 'hybrid-1', title: 'Hybrid Item' }); (hybridItem as any).media.ebookFile = { ino: 'ino-e', metadata: { filename: 'companion.epub' } }; apiMock.getABSLibraryItems.mockResolvedValue([hybridItem]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(1); expect(items[0].title).toBe('Hybrid Item'); }); it('filters using numAudioFiles when audioFiles array is absent (minified list response)', async () => { // The ABS list endpoint returns minified media without the audioFiles array apiMock.getABSLibraryItems.mockResolvedValue([ { id: 'audiobook-minified', addedAt: 1700000000000, updatedAt: 1700000100000, media: { duration: 3600, numAudioFiles: 5, numTracks: 5, coverPath: '/covers/1.jpg', // no audioFiles array — minified response metadata: { title: 'Minified Audiobook', authorName: 'Author', narratorName: 'Narrator', description: 'Desc', asin: 'B00MIN001', publishedYear: '2021', genres: [], explicit: false, }, }, }, { id: 'ebook-minified', addedAt: 1700000000000, updatedAt: 1700000100000, media: { duration: 0, numAudioFiles: 0, numTracks: 0, coverPath: '/covers/ebook.jpg', ebookFormat: 'epub', // no audioFiles array — minified response metadata: { title: 'Minified Ebook', authorName: 'Ebook Author', description: 'Ebook Desc', asin: 'B00EBOOK02', publishedYear: '2023', genres: [], explicit: false, }, }, }, ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(1); expect(items[0].title).toBe('Minified Audiobook'); }); it('falls back to duration check when neither numAudioFiles nor audioFiles exist', async () => { apiMock.getABSLibraryItems.mockResolvedValue([ { id: 'audio-duration-only', addedAt: 1700000000000, updatedAt: 1700000100000, media: { duration: 7200, coverPath: '/covers/1.jpg', metadata: { title: 'Duration Audiobook', authorName: 'Author', genres: [], explicit: false, }, }, }, { id: 'ebook-duration-zero', addedAt: 1700000000000, updatedAt: 1700000100000, media: { duration: 0, coverPath: '/covers/ebook.jpg', metadata: { title: 'Duration Ebook', authorName: 'Author', genres: [], explicit: false, }, }, }, ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); expect(items).toHaveLength(1); expect(items[0].title).toBe('Duration Audiobook'); }); it('assumes audio content when no media fields can determine type', async () => { // Safety: if we truly can't tell, don't filter it out apiMock.getABSLibraryItems.mockResolvedValue([ { id: 'unknown-1', addedAt: 1700000000000, updatedAt: 1700000100000, media: { coverPath: '/covers/1.jpg', metadata: { title: 'Unknown Media', authorName: 'Author', genres: [], explicit: false, }, }, }, ]); const service = new AudiobookshelfLibraryService(); const items = await service.getLibraryItems('lib-1'); // Should NOT be filtered — we can't determine, so assume audio expect(items).toHaveLength(1); expect(items[0].title).toBe('Unknown Media'); }); }); });