diff --git a/tests/integrations/audible.service.test.ts b/tests/integrations/audible.service.test.ts index 59e8792..9186a47 100644 --- a/tests/integrations/audible.service.test.ts +++ b/tests/integrations/audible.service.test.ts @@ -7,12 +7,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { AudibleService } from '@/lib/integrations/audible.service'; import { AUDIBLE_REGIONS, DEFAULT_AUDIBLE_REGION } from '@/lib/types/audible'; -const clientMock = vi.hoisted(() => ({ - get: vi.fn(), -})); +// --------------------------------------------------------------------------- +// Hoisted mocks +// --------------------------------------------------------------------------- + +// Two separate client mocks so we can distinguish htmlClient vs apiClient calls. +const htmlClientMock = vi.hoisted(() => ({ get: vi.fn() })); +const apiClientMock = vi.hoisted(() => ({ get: vi.fn() })); const axiosMock = vi.hoisted(() => ({ - create: vi.fn(() => clientMock), + // First call → htmlClient, second call → apiClient (matches initialize() order). + create: vi.fn(), get: vi.fn(), })); @@ -20,10 +25,6 @@ const configServiceMock = vi.hoisted(() => ({ getAudibleRegion: vi.fn(), })); -const fsCoreMock = vi.hoisted(() => ({ - writeFileSync: vi.fn(), -})); - vi.mock('axios', () => ({ default: axiosMock, ...axiosMock, @@ -33,462 +34,1069 @@ vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); -vi.mock('fs', () => fsCoreMock); +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +interface ProductOverrides { + asin?: string; + title?: string; + authors?: Array<{ asin?: string; name: string }>; + narrators?: Array<{ name: string }>; + publisher_summary?: string; + merchandising_summary?: string; + product_images?: Record; + runtime_length_min?: number; + release_date?: string; + language?: string; + rating?: { overall_distribution?: { display_stars?: number } }; + category_ladders?: Array<{ ladder: Array<{ name: string }> }>; + series?: Array<{ asin?: string; title?: string; sequence?: string }>; +} + +function makeProduct(overrides: ProductOverrides = {}): ProductOverrides { + return { + asin: 'B000000001', + title: 'Test Book', + authors: [{ asin: 'A000000001', name: 'Test Author' }], + narrators: [{ name: 'Test Narrator' }], + publisher_summary: 'A plain description.', + product_images: { '500': 'https://images.example.com/cover.jpg' }, + runtime_length_min: 300, + release_date: '2024-01-01', + language: 'english', + rating: { overall_distribution: { display_stars: 4.5 } }, + ...overrides, + }; +} + +function makeProductsResponse(products: ProductOverrides[], totalResults = products.length) { + return { products, total_results: totalResults }; +} + +// Produces the value that client.get() should resolve to (the axios response object). +// fetchWithRetry captures this as `response`, then callers do `response.data` to +// unwrap the API envelope. So the mock must be shaped as: { data: }. +function apiResponse(envelope: object) { + return { data: envelope }; +} + +// --------------------------------------------------------------------------- +// Test setup +// --------------------------------------------------------------------------- describe('AudibleService', () => { beforeEach(() => { vi.clearAllMocks(); - clientMock.get.mockReset(); + htmlClientMock.get.mockReset(); + apiClientMock.get.mockReset(); axiosMock.get.mockReset(); configServiceMock.getAudibleRegion.mockReset(); - }); - const buildListHtml = (count: number, startIndex: number = 0) => - Array.from({ length: count }, (_, i) => { - const asin = `B${String(i + 1 + startIndex).padStart(9, '0')}`; - return ` -
-
  • -

    Title ${i + 1}

    - By: Author ${i + 1} - Narrated by: Narrator ${i + 1} - - 4.${i} out of 5 stars -
    - `; - }).join(''); - - it('parses search results from HTML', async () => { - const html = ` -
    -
  • -

    The Test Book

    - Author Name - Narrated by: Narrator Name - - Length: 5 hrs and 30 mins - 4.5 out of 5 stars -
    -
    1-20 of 55 results
    - `; + // Default: first create() → htmlClient, second → apiClient. + axiosMock.create + .mockReturnValueOnce(htmlClientMock) + .mockReturnValueOnce(apiClientMock); configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get.mockResolvedValueOnce({ data: html }); - - const service = new AudibleService(); - const result = await service.search('test', 1); - - expect(result.results).toHaveLength(1); - expect(result.results[0].asin).toBe('B000123456'); - expect(result.results[0].title).toBe('The Test Book'); - expect(result.results[0].author).toBe('Author Name'); - expect(result.results[0].narrator).toBe('Narrator Name'); - expect(result.results[0].durationMinutes).toBe(330); - expect(result.results[0].rating).toBe(4.5); - expect(result.results[0].coverArtUrl).toContain('_SL500_'); - expect(result.totalResults).toBe(55); - expect(result.hasMore).toBe(true); }); - it('reinitializes when the configured region changes', async () => { - const html = `
    0 results
    `; - configServiceMock.getAudibleRegion - .mockResolvedValueOnce('us') - .mockResolvedValueOnce('uk') - .mockResolvedValueOnce('uk'); - clientMock.get.mockResolvedValue({ data: html }); + // ------------------------------------------------------------------------- + // Initialization + // ------------------------------------------------------------------------- - const service = new AudibleService(); - await service.search('test', 1); - await service.search('test', 1); + describe('initialization', () => { + it('calls axios.create twice on first search (htmlClient + apiClient)', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); - expect(axiosMock.create).toHaveBeenCalledTimes(2); - expect(axiosMock.create.mock.calls[1][0].baseURL).toBe(AUDIBLE_REGIONS.uk.baseUrl); - }); + const service = new AudibleService(); + await service.search('test', 1); - it('reinitializes when forced manually', async () => { - const html = `
    0 results
    `; - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get.mockResolvedValue({ data: html }); - - const service = new AudibleService(); - await service.search('test', 1); - service.forceReinitialize(); - await service.search('test', 1); - - expect(axiosMock.create).toHaveBeenCalledTimes(2); - }); - - it('falls back to default region when initialization fails', async () => { - const html = `
    0 results
    `; - configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail')); - clientMock.get.mockResolvedValue({ data: html }); - - const service = new AudibleService(); - const result = await service.search('fallback', 1); - - expect(result.totalResults).toBe(0); - expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].baseUrl); - }); - - it('paginates new releases and respects delays between pages', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get - .mockResolvedValueOnce({ data: buildListHtml(50, 0) }) - .mockResolvedValueOnce({ data: buildListHtml(25, 50) }); - - const service = new AudibleService(); - const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); - const results = await service.getNewReleases(75); - - expect(results).toHaveLength(75); - expect(delaySpy).toHaveBeenCalledTimes(1); - }); - - it('parses popular audiobooks and stops early when fewer results are found', async () => { - const html = ` -
    -
  • -

    Popular One

    - By: Author One - Narrated by: Narrator One - - 4.2 out of 5 stars -
    - `; - - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get.mockResolvedValueOnce({ data: html }); - - const service = new AudibleService(); - const results = await service.getPopularAudiobooks(1); - - expect(results).toHaveLength(1); - expect(results[0].asin).toBe('B000111111'); - expect(results[0].title).toBe('Popular One'); - }); - - it('skips duplicate ASINs when parsing new releases', async () => { - const html = ` -
    -
  • -

    Title One

    -
    -
    -
  • -

    Title Two

    -
    - `; - - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get.mockResolvedValueOnce({ data: html }); - - const service = new AudibleService(); - const results = await service.getNewReleases(20); - - expect(results).toHaveLength(1); - expect(results[0].title).toBe('Title One'); - }); - - it('returns empty search results on failures', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - // Use 404 error which is not retryable - const error: any = new Error('Not Found'); - error.response = { status: 404 }; - clientMock.get.mockRejectedValue(error); - - const service = new AudibleService(); - const result = await service.search('oops', 1); - - expect(result.results).toEqual([]); - expect(result.hasMore).toBe(false); - }); - - it('returns audiobooks from Audnexus when available', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockResolvedValueOnce({ - data: { - title: 'Audnexus Book', - authors: [{ name: 'Author A' }], - narrators: [{ name: 'Narrator A' }], - description: 'Desc', - image: 'https://images.example.com/cover._SL200_.jpg', - runtimeLengthMin: '300', - genres: ['Fiction'], - rating: '4.7', - }, + expect(axiosMock.create).toHaveBeenCalledTimes(2); }); - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000AAAAAA'); + it('creates htmlClient with the region baseUrl', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); - expect(details?.title).toBe('Audnexus Book'); - expect(details?.author).toBe('Author A'); - expect(details?.durationMinutes).toBe(300); - expect(details?.coverArtUrl).toContain('_SL500_'); - }); + const service = new AudibleService(); + await service.search('test', 1); - it('scrapes details from HTML when Audnexus fails', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockRejectedValueOnce({ response: { status: 500 }, message: 'boom' }); - - const html = ` - -
    -

    HTML Title

    -
  • By: HTML Author
  • -
  • Narrated by: HTML Narrator
  • -
  • Length: 2 hrs and 5 mins
  • -
  • Release date: Jan 2, 2022
  • - 4.8 out of 5 stars - -
    - This is a long description for testing the Audible HTML parsing logic. -
    - Fiction -
    - `; - - clientMock.get.mockResolvedValueOnce({ data: html }); - - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000CCCCCC'); - - expect(details?.title).toBe('HTML Title'); - expect(details?.author).toBe('HTML Author'); - expect(details?.narrator).toBe('HTML Narrator'); - expect(details?.durationMinutes).toBe(125); - expect(details?.rating).toBe(4.8); - expect(details?.releaseDate).toBe('Jan 2, 2022'); - expect(details?.coverArtUrl).toContain('_SL500_'); - expect(details?.genres).toContain('Fiction'); - }); - - it('falls back to Audible scraping when Audnexus returns 404', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); - - const html = ` - - `; - - clientMock.get.mockResolvedValueOnce({ data: html }); - - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000BBBBBB'); - - expect(details?.title).toBe('Fallback Book'); - expect(details?.author).toBe('Fallback Author'); - expect(details?.durationMinutes).toBe(510); - }); - - it('returns runtime from Audnexus data', async () => { - axiosMock.get.mockResolvedValue({ data: { runtimeLengthMin: '480' } }); - - const service = new AudibleService(); - const runtime = await service.getRuntime('B000123456'); - - expect(runtime).toBe(480); - }); - - it('returns null runtime when Audnexus returns 404', async () => { - axiosMock.get.mockRejectedValue({ response: { status: 404 }, message: 'Not found' }); - - const service = new AudibleService(); - const runtime = await service.getRuntime('B000404404'); - - expect(runtime).toBeNull(); - }); - - it('returns null runtime when Audnexus errors unexpectedly', async () => { - axiosMock.get.mockRejectedValue({ response: { status: 500 }, message: 'Boom' }); - - const service = new AudibleService(); - const runtime = await service.getRuntime('B000500500'); - - expect(runtime).toBeNull(); - }); - - it('parses runtime strings into minutes', () => { - const service = new AudibleService(); - const parseRuntime = (service as any).parseRuntime.bind(service); - - expect(parseRuntime('Length: 1 hr and 5 mins')).toBe(65); - expect(parseRuntime('Length: 45 mins')).toBe(45); - expect(parseRuntime('')).toBeUndefined(); - }); - - it('does not reinitialize when the region is unchanged', async () => { - const html = `
    0 results
    `; - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get.mockResolvedValue({ data: html }); - - const service = new AudibleService(); - await service.search('test', 1); - await service.search('test', 1); - - expect(axiosMock.create).toHaveBeenCalledTimes(1); - }); - - it('paginates popular audiobooks across pages', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - clientMock.get - .mockResolvedValueOnce({ data: buildListHtml(50, 0) }) - .mockResolvedValueOnce({ data: buildListHtml(25, 50) }); - - const service = new AudibleService(); - const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); - const results = await service.getPopularAudiobooks(75); - - expect(results).toHaveLength(75); - expect(delaySpy).toHaveBeenCalledTimes(1); - }); - - it('returns empty popular audiobooks on errors', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - // Use 404 error which is not retryable - const error: any = new Error('Not Found'); - error.response = { status: 404 }; - clientMock.get.mockRejectedValue(error); - - const service = new AudibleService(); - const results = await service.getPopularAudiobooks(5); - - expect(results).toEqual([]); - }); - - it('returns empty new releases on errors', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - // Use 404 error which is not retryable - const error: any = new Error('Not Found'); - error.response = { status: 404 }; - clientMock.get.mockRejectedValue(error); - - const service = new AudibleService(); - const results = await service.getNewReleases(5); - - expect(results).toEqual([]); - }); - - it('returns null when getAudiobookDetails throws', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - - const service = new AudibleService(); - vi.spyOn(service as any, 'fetchFromAudnexus').mockResolvedValue(null); - vi.spyOn(service as any, 'scrapeAudibleDetails').mockRejectedValue(new Error('boom')); - - const result = await service.getAudiobookDetails('B000TEST'); - - expect(result).toBeNull(); - }); - - it('writes debug HTML in development mode', async () => { - const originalEnv = process.env.NODE_ENV; - process.env.NODE_ENV = 'development'; - - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); - clientMock.get.mockResolvedValueOnce({ - data: '

    Dev Book

    ', + expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(AUDIBLE_REGIONS.us.baseUrl); }); - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000DEV'); + it('creates apiClient with the region apiBaseUrl', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); - expect(details?.title).toBe('Dev Book'); + const service = new AudibleService(); + await service.search('test', 1); - process.env.NODE_ENV = originalEnv; + expect(axiosMock.create.mock.calls[1][0].baseURL).toBe(AUDIBLE_REGIONS.us.apiBaseUrl); + }); + + it('does not reinitialize when the region is unchanged between calls', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.search('test', 1); + await service.search('test', 1); + + // Still only 2 creates total (not 4). + expect(axiosMock.create).toHaveBeenCalledTimes(2); + }); + + it('reinitializes when the configured region changes between calls', async () => { + configServiceMock.getAudibleRegion + .mockResolvedValueOnce('us') + .mockResolvedValueOnce('uk') + .mockResolvedValueOnce('uk'); + + // Prepare creates for both init cycles. + axiosMock.create.mockReset(); + axiosMock.create + .mockReturnValueOnce(htmlClientMock) // first init: htmlClient + .mockReturnValueOnce(apiClientMock) // first init: apiClient + .mockReturnValueOnce(htmlClientMock) // second init: htmlClient + .mockReturnValueOnce(apiClientMock); // second init: apiClient + + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.search('test', 1); + await service.search('test', 1); + + expect(axiosMock.create).toHaveBeenCalledTimes(4); + expect(axiosMock.create.mock.calls[2][0].baseURL).toBe(AUDIBLE_REGIONS.uk.baseUrl); + }); + + it('reinitializes after forceReinitialize() is called', async () => { + axiosMock.create.mockReset(); + axiosMock.create + .mockReturnValueOnce(htmlClientMock) + .mockReturnValueOnce(apiClientMock) + .mockReturnValueOnce(htmlClientMock) + .mockReturnValueOnce(apiClientMock); + + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.search('test', 1); + service.forceReinitialize(); + await service.search('test', 1); + + expect(axiosMock.create).toHaveBeenCalledTimes(4); + }); + + it('falls back to the default US region when config service throws', async () => { + configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail')); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.search('fallback', 1); + + expect(axiosMock.create.mock.calls[0][0].baseURL).toBe( + AUDIBLE_REGIONS[DEFAULT_AUDIBLE_REGION].baseUrl, + ); + }); + + it('creates both clients even when config service throws', async () => { + configServiceMock.getAudibleRegion.mockRejectedValue(new Error('config fail')); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.search('fallback', 1); + + expect(axiosMock.create).toHaveBeenCalledTimes(2); + }); }); - it('parses JSON-LD author and narrator arrays', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + // ------------------------------------------------------------------------- + // search() + // ------------------------------------------------------------------------- - const html = ` - - `; + describe('search()', () => { + it('sends correct endpoint, keywords, num_results, and response_groups to apiClient', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); - clientMock.get.mockResolvedValueOnce({ data: html }); + const service = new AudibleService(); + await service.search('fantasy', 1); - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000ARRAY'); + expect(apiClientMock.get).toHaveBeenCalledWith( + '/1.0/catalog/products', + expect.objectContaining({ + params: expect.objectContaining({ + keywords: 'fantasy', + num_results: 50, + response_groups: expect.stringContaining('contributors'), + }), + }), + ); + }); - expect(details?.author).toBe('Author One, Author Two'); - expect(details?.narrator).toBe('Narrator One, Narrator Two'); + it('subtracts 1 from public page=1 before calling the API (page offset regression)', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + + await service.search('test', 1); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0); + }); + + it('subtracts 1 from public page=2 before calling the API', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + + await service.search('test', 2); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(1); + }); + + it('subtracts 1 from public page=3 before calling the API', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + + await service.search('test', 3); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(2); + }); + + it('returns query, results, totalResults, page, and hasMore fields', async () => { + const products = [makeProduct()]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 1))); + + const service = new AudibleService(); + const result = await service.search('test', 1); + + expect(result).toMatchObject({ + query: 'test', + page: 1, + totalResults: 1, + hasMore: false, + }); + expect(result.results).toHaveLength(1); + }); + + it('sets hasMore=true when totalResults exceeds page * pageSize', async () => { + const products = Array.from({ length: 50 }, (_, i) => + makeProduct({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }), + ); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 150))); + + const service = new AudibleService(); + const result = await service.search('test', 1); + + expect(result.hasMore).toBe(true); + }); + + it('sets hasMore=false when all results fit on the current page', async () => { + const products = [makeProduct()]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 1))); + + const service = new AudibleService(); + const result = await service.search('test', 1); + + expect(result.hasMore).toBe(false); + }); + + it('returns empty results on error without throwing', async () => { + const error: Error & { response?: { status: number } } = new Error('Not Found'); + error.response = { status: 404 }; + apiClientMock.get.mockRejectedValue(error); + + const service = new AudibleService(); + const result = await service.search('oops', 1); + + expect(result.results).toEqual([]); + expect(result.hasMore).toBe(false); + expect(result.totalResults).toBe(0); + }); + + it('uses apiClient (not htmlClient) for catalog requests', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.search('test', 1); + + expect(apiClientMock.get).toHaveBeenCalled(); + expect(htmlClientMock.get).not.toHaveBeenCalled(); + }); }); - it('falls back to author and narrator links when labels are missing', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + // ------------------------------------------------------------------------- + // mapCatalogProduct correctness (tested via search()) + // ------------------------------------------------------------------------- - const html = ` - - `; + describe('mapCatalogProduct field mapping', () => { + it('maps asin and title from catalog product', async () => { + const products = [makeProduct({ asin: 'B000AAABBB', title: 'My Great Book' })]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); - clientMock.get.mockResolvedValueOnce({ data: html }); + const service = new AudibleService(); + const { results } = await service.search('test', 1); - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000LINKS'); + expect(results[0].asin).toBe('B000AAABBB'); + expect(results[0].title).toBe('My Great Book'); + }); - expect(details?.author).toBe('Author One'); - expect(details?.narrator).toBe('Narrator One'); + it('joins multiple author names with a comma and maps first author asin', async () => { + const products = [ + makeProduct({ + authors: [ + { asin: 'A111', name: 'First Author' }, + { asin: 'A222', name: 'Second Author' }, + ], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].author).toBe('First Author, Second Author'); + expect(results[0].authorAsin).toBe('A111'); + }); + + it('joins multiple narrator names with a comma', async () => { + const products = [ + makeProduct({ + narrators: [{ name: 'Narrator A' }, { name: 'Narrator B' }], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].narrator).toBe('Narrator A, Narrator B'); + }); + + it('sets narrator to undefined when narrators array is absent', async () => { + const { narrators: _n, ...base } = makeProduct(); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base]))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].narrator).toBeUndefined(); + }); + + it('strips HTML tags and entities from publisher_summary to produce plain text description', async () => { + const products = [ + makeProduct({ + // Use a space before
    so whitespace is preserved after tag removal. + publisher_summary: + '

    A & B book with smart text.
    More here.

    ', + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].description).toBe('A & B book with smart text. More here.'); + }); + + it('falls back to merchandising_summary when publisher_summary is absent', async () => { + const { publisher_summary: _p, ...base } = makeProduct(); + const products = [{ ...base, merchandising_summary: 'Merchandising text.' }]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].description).toBe('Merchandising text.'); + }); + + it('sets description to undefined when both summary fields are absent', async () => { + const { publisher_summary: _p, ...base } = makeProduct(); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base]))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].description).toBeUndefined(); + }); + + it('maps coverArtUrl from product_images["500"]', async () => { + const products = [ + makeProduct({ product_images: { '500': 'https://images.example.com/cover500.jpg' } }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].coverArtUrl).toBe('https://images.example.com/cover500.jpg'); + }); + + it('sets coverArtUrl to undefined when product_images is absent', async () => { + const { product_images: _pi, ...base } = makeProduct(); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base]))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].coverArtUrl).toBeUndefined(); + }); + + it('maps durationMinutes from runtime_length_min', async () => { + const products = [makeProduct({ runtime_length_min: 480 })]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].durationMinutes).toBe(480); + }); + + it('maps releaseDate from release_date', async () => { + const products = [makeProduct({ release_date: '2023-06-15' })]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].releaseDate).toBe('2023-06-15'); + }); + + it('maps rating from rating.overall_distribution.display_stars', async () => { + const products = [ + makeProduct({ rating: { overall_distribution: { display_stars: 4.7 } } }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].rating).toBe(4.7); + }); + + it('sets rating to undefined when rating field is absent', async () => { + const { rating: _r, ...base } = makeProduct(); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base]))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].rating).toBeUndefined(); + }); + + it('flattens, deduplicates, and caps genres at 5 from category_ladders', async () => { + const products = [ + makeProduct({ + category_ladders: [ + { ladder: [{ name: 'Fiction' }, { name: 'Fantasy' }, { name: 'Epic Fantasy' }] }, + { ladder: [{ name: 'Fiction' }, { name: 'Adventure' }] }, // "Fiction" is a duplicate + { ladder: [{ name: 'Young Adult' }, { name: 'Coming of Age' }] }, + ], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + // After dedupe: Fiction, Fantasy, Epic Fantasy, Adventure, Young Adult, Coming of Age = 6 → capped at 5 + expect(results[0].genres).toHaveLength(5); + expect(results[0].genres).not.toContain('Coming of Age'); + // Duplicates removed + const genreSet = new Set(results[0].genres); + expect(genreSet.size).toBe(5); + }); }); - it('extracts descriptions from fallback paragraphs', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + // ------------------------------------------------------------------------- + // Series selection rules + // ------------------------------------------------------------------------- - const html = ` -

    This description is intentionally long enough to satisfy the minimum length requirement for parsing.

    - `; + describe('series selection', () => { + it('picks the series entry that has a non-empty sequence (even if not first)', async () => { + const products = [ + makeProduct({ + series: [ + { asin: 'S000', title: 'Wrong Series', sequence: '' }, + { asin: 'S001', title: 'Right Series', sequence: '3' }, + ], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); - clientMock.get.mockResolvedValueOnce({ data: html }); + const service = new AudibleService(); + const { results } = await service.search('test', 1); - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000DESC'); + expect(results[0].series).toBe('Right Series'); + expect(results[0].seriesAsin).toBe('S001'); + expect(results[0].seriesPart).toBe('3'); + }); - expect(details?.description).toContain('intentionally long enough'); + it('falls back to series[0] when all sequence values are empty', async () => { + const products = [ + makeProduct({ + series: [ + { asin: 'S010', title: 'Fallback Series', sequence: '' }, + { asin: 'S011', title: 'Other Series', sequence: '' }, + ], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].series).toBe('Fallback Series'); + expect(results[0].seriesPart).toBeUndefined(); + }); + + it('leaves all series fields undefined when series array is absent', async () => { + const { series: _s, ...base } = makeProduct(); + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([base]))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].series).toBeUndefined(); + expect(results[0].seriesPart).toBeUndefined(); + expect(results[0].seriesAsin).toBeUndefined(); + }); + + it('extracts leading numeric part from a compound sequence string like "2, Dramatized Adaptation"', async () => { + const products = [ + makeProduct({ + series: [{ asin: 'S020', title: 'Drama Series', sequence: '2, Dramatized Adaptation' }], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].seriesPart).toBe('2'); + }); + + it('preserves decimal sequence values like "1.5"', async () => { + const products = [ + makeProduct({ + series: [{ asin: 'S021', title: 'Decimal Series', sequence: '1.5' }], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].seriesPart).toBe('1.5'); + }); + + it('keeps non-numeric sequence text as-is when there are no digits (e.g. "Prequel")', async () => { + const products = [ + makeProduct({ + series: [{ asin: 'S022', title: 'Prequel Series', sequence: 'Prequel' }], + }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); + + const service = new AudibleService(); + const { results } = await service.search('test', 1); + + expect(results[0].seriesPart).toBe('Prequel'); + }); }); - it('detects runtime from generic duration text', async () => { - configServiceMock.getAudibleRegion.mockResolvedValue('us'); - axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + // ------------------------------------------------------------------------- + // searchByAuthorAsin() + // ------------------------------------------------------------------------- - const html = ` - 10 hr 2 min - `; + describe('searchByAuthorAsin()', () => { + it('sends author name (not ASIN) as the author param', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); - clientMock.get.mockResolvedValueOnce({ data: html }); + const service = new AudibleService(); + await service.searchByAuthorAsin('Brandon Sanderson', 'A000AUTHOR', 1); - const service = new AudibleService(); - const details = await service.getAudiobookDetails('B000TIME'); + expect(apiClientMock.get.mock.calls[0][1].params.author).toBe('Brandon Sanderson'); + }); - expect(details?.durationMinutes).toBe(602); + it('subtracts 1 from public page=1 before calling the API', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + + await service.searchByAuthorAsin('Test Author', 'AASIN', 1); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0); + }); + + it('subtracts 1 from public page=2 before calling the API', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + + await service.searchByAuthorAsin('Test Author', 'AASIN', 2); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(1); + }); + + it('filters out products whose authors array does not contain the target ASIN', async () => { + const matchingAsin = 'A000AUTHOR'; + const products = [ + makeProduct({ asin: 'B001', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }), + makeProduct({ asin: 'B002', authors: [{ asin: 'A999OTHER', name: 'Other' }], language: 'english' }), + makeProduct({ asin: 'B003', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 3))); + + const service = new AudibleService(); + const result = await service.searchByAuthorAsin('Author', matchingAsin, 1); + + expect(result.books).toHaveLength(2); + expect(result.books.map((b) => b.asin)).toEqual(['B001', 'B003']); + }); + + it('filters out products whose language does not match the region accepted values', async () => { + const matchingAsin = 'A000AUTHOR'; + const products = [ + makeProduct({ asin: 'B004', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }), + makeProduct({ asin: 'B005', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'spanish' }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 2))); + + const service = new AudibleService(); + // US region only accepts 'english' + const result = await service.searchByAuthorAsin('Author', matchingAsin, 1); + + expect(result.books).toHaveLength(1); + expect(result.books[0].asin).toBe('B004'); + }); + + it('applies both ASIN and language filters together (AND logic)', async () => { + const matchingAsin = 'A000AUTHOR'; + const products = [ + // passes both + makeProduct({ asin: 'B006', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'english' }), + // wrong ASIN + makeProduct({ asin: 'B007', authors: [{ asin: 'A999OTHER', name: 'Other' }], language: 'english' }), + // wrong language + makeProduct({ asin: 'B008', authors: [{ asin: matchingAsin, name: 'Author' }], language: 'spanish' }), + // wrong ASIN + wrong language + makeProduct({ asin: 'B009', authors: [{ asin: 'A999OTHER', name: 'Other' }], language: 'spanish' }), + ]; + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products, 4))); + + const service = new AudibleService(); + const result = await service.searchByAuthorAsin('Author', matchingAsin, 1); + + expect(result.books).toHaveLength(1); + expect(result.books[0].asin).toBe('B006'); + }); + }); + + // ------------------------------------------------------------------------- + // getPopularAudiobooks() + // ------------------------------------------------------------------------- + + describe('getPopularAudiobooks()', () => { + it('uses products_sort_by: BestSellers', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.getPopularAudiobooks(1); + + expect(apiClientMock.get.mock.calls[0][1].params.products_sort_by).toBe('BestSellers'); + }); + + it('subtracts 1 from public page=1 before calling the API', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + + await service.getPopularAudiobooks(1); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0); + delaySpy.mockRestore(); + }); + + it('makes a second call with page=1 when paginating to page 2', async () => { + const page1Products = Array.from({ length: 50 }, (_, i) => + makeProduct({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }), + ); + const page2Products = Array.from({ length: 25 }, (_, i) => + makeProduct({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }), + ); + + apiClientMock.get + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 75))) + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 75))); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + + await service.getPopularAudiobooks(75); + + expect(apiClientMock.get.mock.calls[1][1].params.page).toBe(1); + delaySpy.mockRestore(); + }); + + it('paginates and returns up to the requested limit', async () => { + const page1Products = Array.from({ length: 50 }, (_, i) => + makeProduct({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }), + ); + const page2Products = Array.from({ length: 25 }, (_, i) => + makeProduct({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }), + ); + + apiClientMock.get + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 75))) + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 75))); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + const results = await service.getPopularAudiobooks(75); + + expect(results).toHaveLength(75); + delaySpy.mockRestore(); + }); + + it('stops early when a page returns fewer than the page size', async () => { + const products = [makeProduct()]; + apiClientMock.get.mockResolvedValueOnce(apiResponse(makeProductsResponse(products, 1))); + + const service = new AudibleService(); + const results = await service.getPopularAudiobooks(50); + + expect(results).toHaveLength(1); + expect(apiClientMock.get).toHaveBeenCalledTimes(1); + }); + + it('deduplicates by ASIN across pages', async () => { + const sharedProduct = makeProduct({ asin: 'BDUP000001', title: 'Duplicated Book' }); + const uniqueProduct = makeProduct({ asin: 'BUNIQ000001', title: 'Unique Book' }); + + apiClientMock.get + .mockResolvedValueOnce( + apiResponse(makeProductsResponse([sharedProduct], 51)), + ) + .mockResolvedValueOnce( + // page 2 returns the same ASIN plus a new one + apiResponse(makeProductsResponse([sharedProduct, uniqueProduct], 51)), + ); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + const results = await service.getPopularAudiobooks(100); + + const asins = results.map((r) => r.asin); + expect(asins.filter((a) => a === 'BDUP000001')).toHaveLength(1); + delaySpy.mockRestore(); + }); + + it('returns empty array on error without throwing', async () => { + const error: Error & { response?: { status: number } } = new Error('Not Found'); + error.response = { status: 404 }; + apiClientMock.get.mockRejectedValue(error); + + const service = new AudibleService(); + const results = await service.getPopularAudiobooks(5); + + expect(results).toEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // getNewReleases() + // ------------------------------------------------------------------------- + + describe('getNewReleases()', () => { + it('uses products_sort_by: -ReleaseDate', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.getNewReleases(1); + + expect(apiClientMock.get.mock.calls[0][1].params.products_sort_by).toBe('-ReleaseDate'); + }); + + it('subtracts 1 from public page=1 before calling the API', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + + await service.getNewReleases(1); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0); + delaySpy.mockRestore(); + }); + + it('subtracts 1 from public page=2 when paginating to the second page', async () => { + const page1Products = Array.from({ length: 50 }, (_, i) => + makeProduct({ asin: `B${String(i).padStart(9, '0')}` }), + ); + const page2Products = [makeProduct({ asin: 'BNEW000099' })]; + + apiClientMock.get + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 51))) + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 51))); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + + await service.getNewReleases(51); + expect(apiClientMock.get.mock.calls[1][1].params.page).toBe(1); + delaySpy.mockRestore(); + }); + + it('deduplicates by ASIN across pages', async () => { + const sharedProduct = makeProduct({ asin: 'BDUP000002' }); + apiClientMock.get + .mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51))) + .mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51))); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + const results = await service.getNewReleases(100); + + expect(results.filter((r) => r.asin === 'BDUP000002')).toHaveLength(1); + delaySpy.mockRestore(); + }); + + it('returns empty array on error without throwing', async () => { + const error: Error & { response?: { status: number } } = new Error('Not Found'); + error.response = { status: 404 }; + apiClientMock.get.mockRejectedValue(error); + + const service = new AudibleService(); + const results = await service.getNewReleases(5); + + expect(results).toEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // getCategoryBooks() + // ------------------------------------------------------------------------- + + describe('getCategoryBooks()', () => { + it('sends category_id and BestSellers sort param', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + + const service = new AudibleService(); + await service.getCategoryBooks('18685580011', 1); + + const params = apiClientMock.get.mock.calls[0][1].params; + expect(params.category_id).toBe('18685580011'); + expect(params.products_sort_by).toBe('BestSellers'); + }); + + it('subtracts 1 from public page=1 before calling the API', async () => { + apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + + await service.getCategoryBooks('CAT001', 1); + expect(apiClientMock.get.mock.calls[0][1].params.page).toBe(0); + delaySpy.mockRestore(); + }); + + it('subtracts 1 from public page=2 when paginating to the second page', async () => { + const page1Products = Array.from({ length: 50 }, (_, i) => + makeProduct({ asin: `B${String(i).padStart(9, '0')}` }), + ); + const page2Products = [makeProduct({ asin: 'BCAT000099' })]; + + apiClientMock.get + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page1Products, 51))) + .mockResolvedValueOnce(apiResponse(makeProductsResponse(page2Products, 51))); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + + await service.getCategoryBooks('CAT001', 51); + expect(apiClientMock.get.mock.calls[1][1].params.page).toBe(1); + delaySpy.mockRestore(); + }); + + it('deduplicates by ASIN across pages', async () => { + const sharedProduct = makeProduct({ asin: 'BDUP000003' }); + apiClientMock.get + .mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51))) + .mockResolvedValueOnce(apiResponse(makeProductsResponse([sharedProduct], 51))); + + const service = new AudibleService(); + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + const results = await service.getCategoryBooks('CAT001', 100); + + expect(results.filter((r) => r.asin === 'BDUP000003')).toHaveLength(1); + delaySpy.mockRestore(); + }); + }); + + // ------------------------------------------------------------------------- + // getCategories() + // ------------------------------------------------------------------------- + + describe('getCategories()', () => { + it('hits /1.0/catalog/categories and maps top-level categories to id+name', async () => { + apiClientMock.get.mockResolvedValue( + apiResponse({ + categories: [ + { id: '18685580011', name: 'Science Fiction & Fantasy' }, + { id: '18685812011', name: 'Mystery, Thriller & Suspense' }, + ], + }), + ); + + const service = new AudibleService(); + const categories = await service.getCategories(); + + expect(apiClientMock.get).toHaveBeenCalledWith('/1.0/catalog/categories', expect.anything()); + expect(categories).toHaveLength(2); + expect(categories[0]).toEqual({ id: '18685580011', name: 'Science Fiction & Fantasy' }); + }); + + it('returns empty array when categories field is missing', async () => { + apiClientMock.get.mockResolvedValue(apiResponse({})); + + const service = new AudibleService(); + const categories = await service.getCategories(); + + expect(categories).toEqual([]); + }); + + it('returns empty array on error without throwing', async () => { + const error: Error & { response?: { status: number } } = new Error('Not Found'); + error.response = { status: 404 }; + apiClientMock.get.mockRejectedValue(error); + + const service = new AudibleService(); + const categories = await service.getCategories(); + + expect(categories).toEqual([]); + }); + }); + + // ------------------------------------------------------------------------- + // getAudiobookDetails() — Audnexus primary + catalog fallback + // ------------------------------------------------------------------------- + + describe('getAudiobookDetails()', () => { + it('returns Audnexus data directly when Audnexus succeeds', async () => { + axiosMock.get.mockResolvedValueOnce({ + data: { + title: 'Audnexus Book', + authors: [{ name: 'Author A', asin: 'A111' }], + narrators: [{ name: 'Narrator A' }], + description: 'A fine description.', + image: 'https://images.example.com/cover._SL500_.jpg', + runtimeLengthMin: '300', + genres: ['Fiction'], + rating: '4.7', + }, + }); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000AAAAAA'); + + expect(details?.title).toBe('Audnexus Book'); + expect(details?.author).toBe('Author A'); + expect(details?.durationMinutes).toBe(300); + // Catalog API should NOT be called when Audnexus succeeds. + expect(apiClientMock.get).not.toHaveBeenCalled(); + }); + + it('falls back to the catalog API when Audnexus returns 404', async () => { + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + const product = makeProduct({ asin: 'B000BBBBBB', title: 'Catalog Book' }); + apiClientMock.get.mockResolvedValue( + apiResponse({ product }), + ); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000BBBBBB'); + + expect(details?.title).toBe('Catalog Book'); + expect(apiClientMock.get).toHaveBeenCalled(); + }); + + it('returns null when the catalog API returns a stub body (product.title missing)', async () => { + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + // Stub body: asin present but no title + apiClientMock.get.mockResolvedValue( + apiResponse({ product: { asin: 'B000STUB01' } }), + ); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000STUB01'); + + expect(details).toBeNull(); + }); + + it('returns null when both Audnexus and the catalog API fail', async () => { + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + // Use a non-retryable 404 so the test does not incur retry delays. + const error: Error & { response?: { status: number } } = new Error('Not Found'); + error.response = { status: 404 }; + apiClientMock.get.mockRejectedValue(error); + + const service = new AudibleService(); + const details = await service.getAudiobookDetails('B000FAIL01'); + + expect(details).toBeNull(); + }); + + it('returns null when fetchAudibleDetailsFromApi throws unexpectedly', async () => { + axiosMock.get.mockRejectedValueOnce({ response: { status: 404 }, message: 'Not found' }); + + const service = new AudibleService(); + vi.spyOn(service as any, 'fetchAudibleDetailsFromApi').mockRejectedValue( + new Error('unexpected boom'), + ); + + const result = await service.getAudiobookDetails('B000TEST'); + + expect(result).toBeNull(); + }); + }); + + // ------------------------------------------------------------------------- + // getRuntime() + // ------------------------------------------------------------------------- + + describe('getRuntime()', () => { + it('returns runtime in minutes from Audnexus runtimeLengthMin', async () => { + axiosMock.get.mockResolvedValue({ data: { runtimeLengthMin: '480' } }); + + const service = new AudibleService(); + const runtime = await service.getRuntime('B000123456'); + + expect(runtime).toBe(480); + }); + + it('returns null when Audnexus returns 404', async () => { + axiosMock.get.mockRejectedValue({ response: { status: 404 }, message: 'Not found' }); + + const service = new AudibleService(); + const runtime = await service.getRuntime('B000404404'); + + expect(runtime).toBeNull(); + }); + + it('returns null when Audnexus errors unexpectedly', async () => { + axiosMock.get.mockRejectedValue({ response: { status: 500 }, message: 'Boom' }); + + const service = new AudibleService(); + // Suppress retry delays so the test runs instantly. + const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); + const runtime = await service.getRuntime('B000500500'); + + expect(runtime).toBeNull(); + delaySpy.mockRestore(); + }); + }); + + // ------------------------------------------------------------------------- + // fetch() public wrapper — must use htmlClient + // ------------------------------------------------------------------------- + + describe('fetch() public wrapper', () => { + it('routes through htmlClient so audible-series.ts callers continue to work', async () => { + htmlClientMock.get.mockResolvedValue({ data: 'test' }); + + const service = new AudibleService(); + await service.fetch('/some-path'); + + expect(htmlClientMock.get).toHaveBeenCalledWith('/some-path', expect.anything()); + expect(apiClientMock.get).not.toHaveBeenCalled(); + }); }); });