Files
ReadMeABook/tests/integrations/audible.service.test.ts
T
kikootwo fcae3bcf09 Audible: HTML refresh, multi-narrator & works dedup
Switch nightly discovery refresh to scrape Audible's curated HTML storefronts (popular, new releases, category pages) while keeping real-time user paths on the JSON catalog API. Add robust HTML resilience knobs (increased retries, capped jittered backoff, AdaptivePacer changes and per-batch cooldowns) to avoid failing nightly jobs during 503 storms. Implement multi-narrator capture via a new extractAllNarrators helper and update parsers to preserve all narrator anchors. Introduce two-pass dedup: in-memory deduplicateAndCollectGroups + collapseByExistingWorks that consults the works table, export metadataScore for consistent representative selection, and persist dedup groups (fire-and-forget). Wire collapseByExistingWorks into search/author/series routes and make defensive dedup in the refresh processor. Add HTML parsing helpers, runtime/lang-aware parsing, jitteredBackoff cap, and tests for the new behaviors.
2026-05-14 15:23:15 -04:00

1386 lines
52 KiB
TypeScript

/**
* 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<string, string>;
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: <catalog_envelope> }.
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) =>
`<a href="/search?searchNarrator=${encodeURIComponent(name)}">${name}</a>`,
)
.join(', ');
}
/**
* Produces a single .productListItem block matching the selectors parsed by
* parseProductListItems(). The parser looks for an `<li data-asin>` descendant,
* with an `<a href="/pd/...">` fallback — using a real `<li>` 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
? `<span class="narratorLabel">Narrated by: ${renderNarratorLinks(narrators)}</span>`
: `<span class="narratorLabel">${narrator}</span>`;
return `
<div class="productListItem">
<ul>
<li data-asin="${asin}">
<img src="${coverArtUrl}" />
<h3><a href="/pd/test/${asin}">${title}</a></h3>
<a class="authorLabel" href="/author/test/${authorAsin}">${author}</a>
${narratorMarkup}
<span class="ratingsLabel">${rating} out of 5</span>
</li>
</ul>
</div>
`;
}
/**
* Produces a single .s-result-item block matching the selectors parsed by
* parseSearchResultItems(). Used for /search?node=<categoryId> 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)
: `<a href="/search?searchNarrator=${encodeURIComponent(narrator)}">${narrator}</a>`;
return `
<div class="s-result-item">
<ul>
<li data-asin="${asin}">
<img src="${coverArtUrl}" />
<h2><a href="/pd/test/${asin}">${title}</a></h2>
<a href="/author/test/${authorAsin}">${author}</a>
${narratorLinks}
<span class="ratingsLabel">${rating} out of 5</span>
</li>
</ul>
</div>
`;
}
/** Wrap one or more item-HTML strings in a minimal page document. */
function makeHtmlPage(items: string[]): string {
return `<html><body>${items.join('')}</body></html>`;
}
/**
* 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 <br/> so whitespace is preserved after tag removal.
publisher_summary:
'<p>A &amp; B book with&nbsp;smart text. <br/>More here.</p>',
}),
];
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);
});
});
// -------------------------------------------------------------------------
// 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=<categoryId>
// -------------------------------------------------------------------------
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',
},
});
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: '<html>test</html>' });
const service = new AudibleService();
await service.fetch('/some-path');
expect(htmlClientMock.get).toHaveBeenCalledWith('/some-path', expect.anything());
expect(apiClientMock.get).not.toHaveBeenCalled();
});
});
});