/** * Component: Audible Integration Service Tests * Documentation: documentation/integrations/audible.md */ 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'; // --------------------------------------------------------------------------- // 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(() => ({ // First call → htmlClient, second call → apiClient (matches initialize() order). create: vi.fn(), get: vi.fn(), })); const configServiceMock = vi.hoisted(() => ({ getAudibleRegion: vi.fn(), })); vi.mock('axios', () => ({ default: axiosMock, ...axiosMock, })); vi.mock('@/lib/services/config.service', () => ({ getConfigService: () => configServiceMock, })); // --------------------------------------------------------------------------- // 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; format_type?: string; publisher_name?: 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 }; } // --------------------------------------------------------------------------- // HTML fixture helpers (for getPopularAudiobooks / getNewReleases / getCategoryBooks, // which scrape Audible's curated HTML pages) // --------------------------------------------------------------------------- interface HtmlBookOverrides { asin?: string; title?: string; author?: string; authorAsin?: string; /** Single-narrator shorthand; mutually exclusive with `narrators`. */ narrator?: string; /** Multi-narrator productions render each name as its own searchNarrator anchor. */ narrators?: string[]; coverArtUrl?: string; rating?: number; } /** Render one or more narrator anchor links suitable for embedding in .narratorLabel. */ function renderNarratorLinks(names: string[]): string { return names .map( (name) => `${name}`, ) .join(', '); } /** * Produces a single .productListItem block matching the selectors parsed by * parseProductListItems(). The parser looks for an `
  • ` descendant, * with an `` fallback — using a real `
  • ` here both * exercises the primary path and keeps the markup well-formed. */ function makeProductListItemHtml(overrides: HtmlBookOverrides = {}): string { const { asin = 'B000000001', title = 'Test Book', author = 'Test Author', authorAsin = 'A000000001', narrator = 'Test Narrator', narrators, coverArtUrl = 'https://images.example.com/cover._SL500_.jpg', rating = 4.5, } = overrides; // Real Audible storefront markup embeds each narrator as its own anchor inside // .narratorLabel for multi-narrator productions. The single-narrator case keeps // the original plain-text span for backward compatibility with existing tests. const narratorMarkup = narrators && narrators.length > 0 ? `Narrated by: ${renderNarratorLinks(narrators)}` : `${narrator}`; return `
    `; } /** * Produces a single .s-result-item block matching the selectors parsed by * parseSearchResultItems(). Used for /search?node= category pages. */ function makeSearchResultItemHtml(overrides: HtmlBookOverrides = {}): string { const { asin = 'B000000001', title = 'Test Book', author = 'Test Author', authorAsin = 'A000000001', narrator = 'Test Narrator', narrators, coverArtUrl = 'https://images.example.com/cover._SL500_.jpg', rating = 4.5, } = overrides; const narratorLinks = narrators && narrators.length > 0 ? renderNarratorLinks(narrators) : `${narrator}`; return `
    `; } /** Wrap one or more item-HTML strings in a minimal page document. */ function makeHtmlPage(items: string[]): string { return `${items.join('')}`; } /** * Produces the value that client.get() should resolve to for HTML responses. * cheerio.load() is called on response.data, so .data must be the raw HTML string. */ function htmlResponse(html: string) { return { data: html }; } // --------------------------------------------------------------------------- // Test setup // --------------------------------------------------------------------------- describe('AudibleService', () => { beforeEach(() => { vi.clearAllMocks(); htmlClientMock.get.mockReset(); apiClientMock.get.mockReset(); axiosMock.get.mockReset(); configServiceMock.getAudibleRegion.mockReset(); // Default: first create() → htmlClient, second → apiClient. axiosMock.create .mockReturnValueOnce(htmlClientMock) .mockReturnValueOnce(apiClientMock); configServiceMock.getAudibleRegion.mockResolvedValue('us'); }); // ------------------------------------------------------------------------- // Initialization // ------------------------------------------------------------------------- describe('initialization', () => { it('calls axios.create twice on first search (htmlClient + apiClient)', async () => { apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); const service = new AudibleService(); await service.search('test', 1); expect(axiosMock.create).toHaveBeenCalledTimes(2); }); it('creates htmlClient with the region baseUrl', async () => { apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); const service = new AudibleService(); await service.search('test', 1); expect(axiosMock.create.mock.calls[0][0].baseURL).toBe(AUDIBLE_REGIONS.us.baseUrl); }); it('creates apiClient with the region apiBaseUrl', async () => { apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); const service = new AudibleService(); await service.search('test', 1); 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); }); }); // ------------------------------------------------------------------------- // search() // ------------------------------------------------------------------------- describe('search()', () => { it('sends correct endpoint, keywords, num_results, and response_groups to apiClient', async () => { apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); const service = new AudibleService(); await service.search('fantasy', 1); expect(apiClientMock.get).toHaveBeenCalledWith( '/1.0/catalog/products', expect.objectContaining({ params: expect.objectContaining({ keywords: 'fantasy', num_results: 50, response_groups: expect.stringContaining('contributors'), }), }), ); }); 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(); }); }); // ------------------------------------------------------------------------- // mapCatalogProduct correctness (tested via search()) // ------------------------------------------------------------------------- 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))); const service = new AudibleService(); const { results } = await service.search('test', 1); expect(results[0].asin).toBe('B000AAABBB'); expect(results[0].title).toBe('My Great Book'); }); 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('maps language from catalog product', async () => { const products = [makeProduct({ language: 'english' })]; apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); const service = new AudibleService(); const { results } = await service.search('test', 1); expect(results[0].language).toBe('english'); }); it('maps format_type to formatType from catalog product', async () => { const products = [makeProduct({ format_type: 'unabridged' })]; apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); const service = new AudibleService(); const { results } = await service.search('test', 1); expect(results[0].formatType).toBe('unabridged'); }); it('maps publisher_name to publisherName from catalog product', async () => { const products = [makeProduct({ publisher_name: 'Penguin Random House Audio' })]; apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); const service = new AudibleService(); const { results } = await service.search('test', 1); expect(results[0].publisherName).toBe('Penguin Random House Audio'); }); it('leaves formatType and publisherName undefined when catalog product omits them', async () => { const products = [makeProduct()]; apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse(products))); const service = new AudibleService(); const { results } = await service.search('test', 1); expect(results[0].formatType).toBeUndefined(); expect(results[0].publisherName).toBeUndefined(); }); }); // ------------------------------------------------------------------------- // Series selection rules // ------------------------------------------------------------------------- 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))); const service = new AudibleService(); const { results } = await service.search('test', 1); expect(results[0].series).toBe('Right Series'); expect(results[0].seriesAsin).toBe('S001'); expect(results[0].seriesPart).toBe('3'); }); 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'); }); }); // ------------------------------------------------------------------------- // searchByAuthorAsin() // ------------------------------------------------------------------------- describe('searchByAuthorAsin()', () => { it('sends author name (not ASIN) as the author param', async () => { apiClientMock.get.mockResolvedValue(apiResponse(makeProductsResponse([]))); const service = new AudibleService(); await service.searchByAuthorAsin('Brandon Sanderson', 'A000AUTHOR', 1); expect(apiClientMock.get.mock.calls[0][1].params.author).toBe('Brandon Sanderson'); }); 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() — HTML scraping of /adblbestsellers // ------------------------------------------------------------------------- describe('getPopularAudiobooks()', () => { it('hits /adblbestsellers on the htmlClient with pageSize=50', async () => { htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()]))); const service = new AudibleService(); await service.getPopularAudiobooks(1); expect(htmlClientMock.get).toHaveBeenCalledWith( '/adblbestsellers', expect.objectContaining({ params: expect.objectContaining({ pageSize: 50 }), }), ); }); it('does not include a page param on the first request (only from page 2 onward)', async () => { htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()]))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); await service.getPopularAudiobooks(1); expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined(); delaySpy.mockRestore(); }); it('includes page=2 on the second request when paginating', async () => { const page1Items = Array.from({ length: 50 }, (_, i) => makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }), ); const page2Items = Array.from({ length: 25 }, (_, i) => makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }), ); htmlClientMock.get .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items))) .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); await service.getPopularAudiobooks(75); expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2); delaySpy.mockRestore(); }); it('paginates across pages and returns up to the requested limit', async () => { const page1Items = Array.from({ length: 50 }, (_, i) => makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}`, title: `Book ${i}` }), ); const page2Items = Array.from({ length: 25 }, (_, i) => makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}`, title: `Book ${i + 50}` }), ); htmlClientMock.get .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items))) .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items))); 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 half the page size', async () => { htmlClientMock.get.mockResolvedValueOnce( htmlResponse(makeHtmlPage([makeProductListItemHtml()])), ); const service = new AudibleService(); const results = await service.getPopularAudiobooks(50); expect(results).toHaveLength(1); expect(htmlClientMock.get).toHaveBeenCalledTimes(1); }); it('deduplicates by ASIN across pages', async () => { const sharedAsin = 'BDUP000001'; const uniqueAsin = 'BUNIQ000001'; // Build a "full" first page (50 items, all with the shared ASIN duplicated as filler) // so the parser proceeds to page 2. const page1Items = [ makeProductListItemHtml({ asin: sharedAsin, title: 'Duplicated Book' }), ...Array.from({ length: 49 }, (_, i) => makeProductListItemHtml({ asin: `BFILL${String(i).padStart(5, '0')}`, title: `Filler ${i}` }), ), ]; const page2Items = [ makeProductListItemHtml({ asin: sharedAsin, title: 'Duplicated Book' }), makeProductListItemHtml({ asin: uniqueAsin, title: 'Unique Book' }), ...Array.from({ length: 48 }, (_, i) => makeProductListItemHtml({ asin: `BFILL2${String(i).padStart(4, '0')}`, title: `Filler2 ${i}` }), ), ]; htmlClientMock.get .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items))) .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); const results = await service.getPopularAudiobooks(150); const asins = results.map((r) => r.asin); expect(asins.filter((a) => a === sharedAsin)).toHaveLength(1); expect(asins).toContain(uniqueAsin); 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 }; htmlClientMock.get.mockRejectedValue(error); const service = new AudibleService(); const results = await service.getPopularAudiobooks(5); expect(results).toEqual([]); }); it('uses htmlClient (not apiClient) for the request', async () => { htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()]))); const service = new AudibleService(); await service.getPopularAudiobooks(1); expect(htmlClientMock.get).toHaveBeenCalled(); expect(apiClientMock.get).not.toHaveBeenCalled(); }); it('maps title, author, narrator, and rating from the parsed item', async () => { htmlClientMock.get.mockResolvedValue( htmlResponse( makeHtmlPage([ makeProductListItemHtml({ asin: 'B0HTMLMAP1', title: 'Mapped Title', author: 'Mapped Author', authorAsin: 'A00MAPAUTH', narrator: 'Mapped Narrator', rating: 4.7, }), ]), ), ); const service = new AudibleService(); const [book] = await service.getPopularAudiobooks(1); expect(book.asin).toBe('B0HTMLMAP1'); expect(book.title).toBe('Mapped Title'); expect(book.author).toBe('Mapped Author'); expect(book.authorAsin).toBe('A00MAPAUTH'); expect(book.narrator).toBe('Mapped Narrator'); expect(book.rating).toBeCloseTo(4.7); }); it('captures every co-narrator on multi-narrator productions (regression: prior code took only the first link)', async () => { htmlClientMock.get.mockResolvedValue( htmlResponse( makeHtmlPage([ makeProductListItemHtml({ asin: 'B0FULLCAST', narrators: [ 'Kristin Atherton', 'Roy McMillan', 'Clare Corbett', 'Tom Bateman', 'Patience Tomlinson', 'Shaheen Khan', ], }), ]), ), ); const service = new AudibleService(); const [book] = await service.getPopularAudiobooks(1); // Every narrator must round-trip — order is not significant downstream, // but document order should be preserved for stable cache values. expect(book.narrator).toBe( 'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan', ); }); }); // ------------------------------------------------------------------------- // getNewReleases() — HTML scraping of /newreleases // ------------------------------------------------------------------------- describe('getNewReleases()', () => { it('hits /newreleases on the htmlClient with pageSize=50', async () => { htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()]))); const service = new AudibleService(); await service.getNewReleases(1); expect(htmlClientMock.get).toHaveBeenCalledWith( '/newreleases', expect.objectContaining({ params: expect.objectContaining({ pageSize: 50 }), }), ); }); it('does not include a page param on the first request', async () => { htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()]))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); await service.getNewReleases(1); expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined(); delaySpy.mockRestore(); }); it('includes page=2 on the second request when paginating', async () => { const page1Items = Array.from({ length: 50 }, (_, i) => makeProductListItemHtml({ asin: `B${String(i).padStart(9, '0')}` }), ); const page2Items = Array.from({ length: 50 }, (_, i) => makeProductListItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}` }), ); htmlClientMock.get .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items))) .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); await service.getNewReleases(100); expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2); delaySpy.mockRestore(); }); it('deduplicates by ASIN across pages', async () => { const sharedAsin = 'BDUP000002'; const page1Items = [ makeProductListItemHtml({ asin: sharedAsin }), ...Array.from({ length: 49 }, (_, i) => makeProductListItemHtml({ asin: `BNEW${String(i).padStart(6, '0')}` }), ), ]; const page2Items = [ makeProductListItemHtml({ asin: sharedAsin }), ...Array.from({ length: 49 }, (_, i) => makeProductListItemHtml({ asin: `BNEW2${String(i).padStart(5, '0')}` }), ), ]; htmlClientMock.get .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items))) .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); const results = await service.getNewReleases(150); expect(results.filter((r) => r.asin === sharedAsin)).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 }; htmlClientMock.get.mockRejectedValue(error); const service = new AudibleService(); const results = await service.getNewReleases(5); expect(results).toEqual([]); }); it('uses htmlClient (not apiClient) for the request', async () => { htmlClientMock.get.mockResolvedValue(htmlResponse(makeHtmlPage([makeProductListItemHtml()]))); const service = new AudibleService(); await service.getNewReleases(1); expect(htmlClientMock.get).toHaveBeenCalled(); expect(apiClientMock.get).not.toHaveBeenCalled(); }); }); // ------------------------------------------------------------------------- // getCategoryBooks() — HTML scraping of /search?node= // ------------------------------------------------------------------------- describe('getCategoryBooks()', () => { it('hits /search on the htmlClient with node, pageSize, and popularity-rank sort', async () => { htmlClientMock.get.mockResolvedValue( htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])), ); const service = new AudibleService(); await service.getCategoryBooks('18685580011', 1); const params = htmlClientMock.get.mock.calls[0][1].params; expect(htmlClientMock.get.mock.calls[0][0]).toBe('/search'); expect(params.node).toBe('18685580011'); expect(params.pageSize).toBe(50); expect(params.sort).toBe('popularity-rank'); }); it('does not include a page param on the first request', async () => { htmlClientMock.get.mockResolvedValue( htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])), ); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); await service.getCategoryBooks('CAT001', 1); expect(htmlClientMock.get.mock.calls[0][1].params.page).toBeUndefined(); delaySpy.mockRestore(); }); it('includes page=2 on the second request when paginating', async () => { const page1Items = Array.from({ length: 50 }, (_, i) => makeSearchResultItemHtml({ asin: `B${String(i).padStart(9, '0')}` }), ); const page2Items = Array.from({ length: 50 }, (_, i) => makeSearchResultItemHtml({ asin: `B${String(i + 50).padStart(9, '0')}` }), ); htmlClientMock.get .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items))) .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); await service.getCategoryBooks('CAT001', 100); expect(htmlClientMock.get.mock.calls[1][1].params.page).toBe(2); delaySpy.mockRestore(); }); it('deduplicates by ASIN across pages', async () => { const sharedAsin = 'BDUP000003'; const page1Items = [ makeSearchResultItemHtml({ asin: sharedAsin }), ...Array.from({ length: 49 }, (_, i) => makeSearchResultItemHtml({ asin: `BCAT${String(i).padStart(6, '0')}` }), ), ]; const page2Items = [ makeSearchResultItemHtml({ asin: sharedAsin }), ...Array.from({ length: 49 }, (_, i) => makeSearchResultItemHtml({ asin: `BCAT2${String(i).padStart(5, '0')}` }), ), ]; htmlClientMock.get .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page1Items))) .mockResolvedValueOnce(htmlResponse(makeHtmlPage(page2Items))); const service = new AudibleService(); const delaySpy = vi.spyOn(service as any, 'delay').mockResolvedValue(undefined); const results = await service.getCategoryBooks('CAT001', 150); expect(results.filter((r) => r.asin === sharedAsin)).toHaveLength(1); delaySpy.mockRestore(); }); it('uses htmlClient (not apiClient) for the request', async () => { htmlClientMock.get.mockResolvedValue( htmlResponse(makeHtmlPage([makeSearchResultItemHtml()])), ); const service = new AudibleService(); await service.getCategoryBooks('CAT001', 1); expect(htmlClientMock.get).toHaveBeenCalled(); expect(apiClientMock.get).not.toHaveBeenCalled(); }); it('captures every co-narrator on multi-narrator productions (regression: prior code took only the first link)', async () => { htmlClientMock.get.mockResolvedValue( htmlResponse( makeHtmlPage([ makeSearchResultItemHtml({ asin: 'B0FULLCAST', narrators: ['Alice', 'Bob', 'Carol', 'Dan'], }), ]), ), ); const service = new AudibleService(); const [book] = await service.getCategoryBooks('CAT001', 1); expect(book.narrator).toBe('Alice, Bob, Carol, Dan'); }); }); // ------------------------------------------------------------------------- // 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', language: 'english', formatType: 'unabridged', publisherName: 'Test Publisher', }, }); 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); expect(details?.language).toBe('english'); expect(details?.formatType).toBe('unabridged'); expect(details?.publisherName).toBe('Test Publisher'); // 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(); }); }); });