mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
fcae3bcf09
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.
1386 lines
52 KiB
TypeScript
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 & B book with 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();
|
|
});
|
|
});
|
|
});
|