From 5f0855b2f8d929846263524f36675a53b6a0402f Mon Sep 17 00:00:00 2001 From: kikootwo Date: Tue, 21 Apr 2026 03:21:25 -0400 Subject: [PATCH] Refactor AudibleService tests and mocks Restructure and expand tests for AudibleService: replace a single hoisted axios client mock with separate htmlClientMock and apiClientMock, update axios.create to return clients in initialization order, and remove the fs mock. Add reusable fixture helpers (makeProduct, makeProductsResponse, apiResponse) and many new/spec-complete test cases organized into describe blocks (initialization, search, mapping, series rules, author search, popular/new releases, categories, and audiobook details). Improve assertions for pagination, deduplication, field mapping, error handling, and region/config behavior; reset and clear mocks in beforeEach to ensure isolation. --- tests/integrations/audible.service.test.ts | 1446 ++++++++++++++------ 1 file changed, 1027 insertions(+), 419 deletions(-) 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(); + }); }); });