mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
Merge branch 'main' into feature/hardover-shelves
This commit is contained in:
@@ -47,17 +47,22 @@ vi.mock('@/components/ui/CardSizeControls', () => ({
|
||||
CardSizeControls: ({ size }: { size: number }) => <div data-testid="card-size" data-size={size} />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/StickyPagination', () => ({
|
||||
StickyPagination: ({
|
||||
label,
|
||||
onPageChange,
|
||||
vi.mock('@/components/ui/UnifiedPagination', () => ({
|
||||
UnifiedPagination: ({
|
||||
sections,
|
||||
}: {
|
||||
label: string;
|
||||
onPageChange: (page: number) => void;
|
||||
sections: Array<{
|
||||
label: string;
|
||||
onPageChange: (page: number) => void;
|
||||
}>;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onPageChange(2)}>
|
||||
{label} next
|
||||
</button>
|
||||
<div>
|
||||
{sections.map((s) => (
|
||||
<button key={s.label} type="button" onClick={() => s.onPageChange(2)}>
|
||||
{s.label} next
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -113,7 +118,7 @@ describe('HomePage', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Popular Audiobooks next' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2);
|
||||
expect(useAudiobooksMock).toHaveBeenCalledWith('popular', 20, 2, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
/**
|
||||
* Component: Sticky Pagination Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { StickyPagination } from '@/components/ui/StickyPagination';
|
||||
|
||||
type ObserverEntry = {
|
||||
isIntersecting: boolean;
|
||||
intersectionRatio: number;
|
||||
target: Element;
|
||||
};
|
||||
|
||||
describe('StickyPagination', () => {
|
||||
const observers: { callback: IntersectionObserverCallback }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
class MockIntersectionObserver {
|
||||
callback: IntersectionObserverCallback;
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
(global as any).IntersectionObserver = MockIntersectionObserver;
|
||||
});
|
||||
|
||||
it('returns null when there is only one page', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const { container } = render(
|
||||
<StickyPagination
|
||||
currentPage={1}
|
||||
totalPages={1}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('shows and hides based on section and footer visibility', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const footerRef = { current: document.createElement('div') };
|
||||
|
||||
const { container } = render(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={5}
|
||||
onPageChange={vi.fn()}
|
||||
sectionRef={sectionRef}
|
||||
footerRef={footerRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.2,
|
||||
target: sectionRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-100');
|
||||
|
||||
act(() => {
|
||||
observers[1].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.2,
|
||||
target: footerRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[1] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
});
|
||||
|
||||
it('handles navigation and jump input updates', () => {
|
||||
const sectionRef = { current: document.createElement('div') };
|
||||
const onPageChange = vi.fn();
|
||||
|
||||
render(
|
||||
<StickyPagination
|
||||
currentPage={2}
|
||||
totalPages={4}
|
||||
onPageChange={onPageChange}
|
||||
sectionRef={sectionRef}
|
||||
label="Popular"
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Next page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(3);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
expect(onPageChange).toHaveBeenCalledWith(1);
|
||||
|
||||
const input = screen.getByLabelText('Current page') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '4' } });
|
||||
fireEvent.blur(input);
|
||||
expect(onPageChange).toHaveBeenCalledWith(4);
|
||||
|
||||
fireEvent.change(input, { target: { value: '99' } });
|
||||
fireEvent.blur(input);
|
||||
expect(input.value).toBe('2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Component: Unified Pagination Tests
|
||||
* Documentation: documentation/frontend/components.md
|
||||
*/
|
||||
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import React from 'react';
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { UnifiedPagination, PaginationSection } from '@/components/ui/UnifiedPagination';
|
||||
|
||||
type ObserverEntry = {
|
||||
isIntersecting: boolean;
|
||||
intersectionRatio: number;
|
||||
target: Element;
|
||||
};
|
||||
|
||||
function makeSections(
|
||||
overrides?: Partial<PaginationSection>[]
|
||||
): [PaginationSection, PaginationSection] {
|
||||
const defaults: [PaginationSection, PaginationSection] = [
|
||||
{
|
||||
label: 'Popular',
|
||||
accentColor: 'bg-blue-500',
|
||||
currentPage: 1,
|
||||
totalPages: 3,
|
||||
onPageChange: vi.fn(),
|
||||
sectionRef: { current: document.createElement('section') },
|
||||
onScrollToSection: vi.fn(),
|
||||
},
|
||||
{
|
||||
label: 'New Releases',
|
||||
accentColor: 'bg-emerald-500',
|
||||
currentPage: 1,
|
||||
totalPages: 2,
|
||||
onPageChange: vi.fn(),
|
||||
sectionRef: { current: document.createElement('section') },
|
||||
onScrollToSection: vi.fn(),
|
||||
},
|
||||
];
|
||||
|
||||
if (overrides) {
|
||||
overrides.forEach((o, i) => {
|
||||
if (o) Object.assign(defaults[i], o);
|
||||
});
|
||||
}
|
||||
|
||||
return defaults;
|
||||
}
|
||||
|
||||
describe('UnifiedPagination', () => {
|
||||
const observers: { callback: IntersectionObserverCallback; observe: ReturnType<typeof vi.fn>; disconnect: ReturnType<typeof vi.fn> }[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
|
||||
class MockIntersectionObserver {
|
||||
callback: IntersectionObserverCallback;
|
||||
observe = vi.fn();
|
||||
unobserve = vi.fn();
|
||||
disconnect = vi.fn();
|
||||
takeRecords = vi.fn();
|
||||
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.callback = callback;
|
||||
observers.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
(global as any).IntersectionObserver = MockIntersectionObserver;
|
||||
});
|
||||
|
||||
it('renders nothing when both sections have only one page', () => {
|
||||
const sections = makeSections([{ totalPages: 1 }, { totalPages: 1 }]);
|
||||
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||
// The pill should be hidden (pointer-events-none, opacity-0)
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
expect(root).toHaveClass('pointer-events-none');
|
||||
});
|
||||
|
||||
it('shows pagination when the dominant section is visible and has pages', () => {
|
||||
const sections = makeSections();
|
||||
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
|
||||
// Simulate first section becoming visible with high ratio
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.5,
|
||||
target: sections[0].sectionRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-100');
|
||||
});
|
||||
|
||||
it('hides when footer becomes visible', () => {
|
||||
const sections = makeSections();
|
||||
const footerRef = { current: document.createElement('footer') };
|
||||
const { container } = render(
|
||||
<UnifiedPagination sections={sections} footerRef={footerRef} />
|
||||
);
|
||||
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
|
||||
// Make section visible
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.5,
|
||||
target: sections[0].sectionRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-100');
|
||||
|
||||
// Footer observer is the 3rd (index 2): section0, section1, footer
|
||||
act(() => {
|
||||
observers[2].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.1,
|
||||
target: footerRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[2] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
expect(root).toHaveClass('opacity-0');
|
||||
});
|
||||
|
||||
it('calls onPageChange for prev/next buttons', () => {
|
||||
const sections = makeSections([{ currentPage: 2, totalPages: 4 }]);
|
||||
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||
|
||||
// Make section visible so controls render interactably
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.5,
|
||||
target: sections[0].sectionRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Next page'));
|
||||
expect(sections[0].onPageChange).toHaveBeenCalledWith(3);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Previous page'));
|
||||
expect(sections[0].onPageChange).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('handles page jump input', () => {
|
||||
const sections = makeSections([{ currentPage: 2, totalPages: 5 }]);
|
||||
render(<UnifiedPagination sections={sections} />);
|
||||
|
||||
// Make section visible
|
||||
act(() => {
|
||||
observers[0].callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 0.5,
|
||||
target: sections[0].sectionRef.current as Element,
|
||||
} as ObserverEntry,
|
||||
],
|
||||
observers[0] as unknown as IntersectionObserver
|
||||
);
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText('Jump to page') as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: '4' } });
|
||||
fireEvent.blur(input);
|
||||
expect(sections[0].onPageChange).toHaveBeenCalledWith(4);
|
||||
});
|
||||
|
||||
it('uses pointer-events-none when hidden', () => {
|
||||
const sections = makeSections();
|
||||
const { container } = render(<UnifiedPagination sections={sections} />);
|
||||
const root = container.querySelector('div.fixed') as HTMLElement;
|
||||
expect(root).toHaveClass('pointer-events-none');
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,10 @@ export const createPrismaMock = () => ({
|
||||
goodreadsShelf: createModelMock(),
|
||||
goodreadsBookMapping: createModelMock(),
|
||||
hardcoverShelf: createModelMock(),
|
||||
work: createModelMock(),
|
||||
workAsin: createModelMock(),
|
||||
watchedSeries: createModelMock(),
|
||||
watchedAuthor: createModelMock(),
|
||||
$queryRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const processorsMock = vi.hoisted(() => ({
|
||||
processRetryFailedImports: vi.fn().mockResolvedValue('ok'),
|
||||
processCleanupSeededTorrents: vi.fn().mockResolvedValue('ok'),
|
||||
processSyncShelves: vi.fn().mockResolvedValue('ok'),
|
||||
processCheckWatchedLists: vi.fn().mockResolvedValue('ok'),
|
||||
// Ebook processors
|
||||
processSearchEbook: vi.fn().mockResolvedValue('ok'),
|
||||
processStartDirectDownload: vi.fn().mockResolvedValue('ok'),
|
||||
@@ -120,6 +121,10 @@ vi.mock('@/lib/processors/sync-shelves.processor', () => ({
|
||||
processSyncShelves: processorsMock.processSyncShelves,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/processors/check-watched-lists.processor', () => ({
|
||||
processCheckWatchedLists: processorsMock.processCheckWatchedLists,
|
||||
}));
|
||||
|
||||
// Ebook processors
|
||||
vi.mock('@/lib/processors/search-ebook.processor', () => ({
|
||||
processSearchEbook: processorsMock.processSearchEbook,
|
||||
@@ -565,6 +570,7 @@ describe('JobQueueService', () => {
|
||||
expect(processorsMock.processRetryFailedImports).toHaveBeenCalled();
|
||||
expect(processorsMock.processCleanupSeededTorrents).toHaveBeenCalled();
|
||||
expect(processorsMock.processSyncShelves).toHaveBeenCalled();
|
||||
expect(processorsMock.processCheckWatchedLists).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns repeatable jobs from the queue', async () => {
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('SchedulerService', () => {
|
||||
const service = new SchedulerService();
|
||||
await service.start();
|
||||
|
||||
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(8);
|
||||
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(9);
|
||||
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
||||
'audible_refresh',
|
||||
{ scheduledJobId: 'job-1' },
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* Component: Watched Lists Service Tests
|
||||
* Documentation: documentation/features/watched-lists.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
create: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
forJob: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock scrapeSeriesPage
|
||||
const mockScrapeSeriesPage = vi.fn();
|
||||
vi.mock('@/lib/integrations/audible-series', () => ({
|
||||
scrapeSeriesPage: (...args: any[]) => mockScrapeSeriesPage(...args),
|
||||
}));
|
||||
|
||||
// Mock AudibleService
|
||||
const mockSearchByAuthorAsin = vi.fn();
|
||||
vi.mock('@/lib/integrations/audible.service', () => ({
|
||||
getAudibleService: () => ({
|
||||
searchByAuthorAsin: mockSearchByAuthorAsin,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock deduplicateAndCollectGroups
|
||||
const mockDeduplicateAndCollectGroups = vi.fn();
|
||||
vi.mock('@/lib/utils/deduplicate-audiobooks', () => ({
|
||||
deduplicateAndCollectGroups: (...args: any[]) => mockDeduplicateAndCollectGroups(...args),
|
||||
}));
|
||||
|
||||
// Mock works service
|
||||
const mockPersistDedupGroups = vi.fn();
|
||||
const mockGetSiblingAsins = vi.fn();
|
||||
vi.mock('@/lib/services/works.service', () => ({
|
||||
persistDedupGroups: (...args: any[]) => mockPersistDedupGroups(...args),
|
||||
getSiblingAsins: (...args: any[]) => mockGetSiblingAsins(...args),
|
||||
}));
|
||||
|
||||
// Mock request creator
|
||||
const mockCreateRequestForUser = vi.fn();
|
||||
vi.mock('@/lib/services/request-creator.service', () => ({
|
||||
createRequestForUser: (...args: any[]) => mockCreateRequestForUser(...args),
|
||||
}));
|
||||
|
||||
// Mock findPlexMatch
|
||||
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
||||
findPlexMatch: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
describe('processWatchedLists', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
|
||||
// Default: empty library, no siblings
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
|
||||
mockGetSiblingAsins.mockResolvedValue(new Map());
|
||||
mockPersistDedupGroups.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('processes watched series and creates requests for new books', async () => {
|
||||
// Setup: one user watching one series
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Test Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||
|
||||
// Series page returns 2 books
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||
asin: 'B001SERIES1',
|
||||
title: 'Test Series',
|
||||
bookCount: 2,
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A', narrator: 'Narrator' },
|
||||
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A', narrator: 'Narrator' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
// No dedup (each book is unique)
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A', narrator: 'Narrator' },
|
||||
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A', narrator: 'Narrator' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
// Both requests succeed
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists();
|
||||
|
||||
expect(stats.seriesChecked).toBe(1);
|
||||
expect(stats.requestsCreated).toBe(2);
|
||||
expect(mockCreateRequestForUser).toHaveBeenCalledTimes(2);
|
||||
expect(prismaMock.watchedSeries.update).toHaveBeenCalledWith({
|
||||
where: { id: 'ws-1' },
|
||||
data: { lastCheckedAt: expect.any(Date) },
|
||||
});
|
||||
});
|
||||
|
||||
it('skips books already in the library', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Test Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||
asin: 'B001SERIES1',
|
||||
title: 'Test Series',
|
||||
bookCount: 2,
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
{ asin: 'B001BOOK02', title: 'Book Two', author: 'Author A' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
// Book One is already in library
|
||||
prismaMock.plexLibrary.findMany.mockResolvedValue([
|
||||
{ asin: 'B001BOOK01' },
|
||||
]);
|
||||
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists();
|
||||
|
||||
expect(stats.skippedOwned).toBe(1);
|
||||
expect(stats.requestsCreated).toBe(1);
|
||||
expect(mockCreateRequestForUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateRequestForUser).toHaveBeenCalledWith('user-1', expect.objectContaining({ asin: 'B001BOOK02' }));
|
||||
});
|
||||
|
||||
it('processes watched authors and creates requests', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'wa-1',
|
||||
userId: 'user-1',
|
||||
authorAsin: 'B001AUTH001',
|
||||
authorName: 'Author A',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.update.mockResolvedValue({});
|
||||
|
||||
// Author has 1 book
|
||||
mockSearchByAuthorAsin.mockResolvedValueOnce({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Author A' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
totalResults: 1,
|
||||
});
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Author A' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists();
|
||||
|
||||
expect(stats.authorsChecked).toBe(1);
|
||||
expect(stats.requestsCreated).toBe(1);
|
||||
expect(mockSearchByAuthorAsin).toHaveBeenCalledWith('Author A', 'B001AUTH001', 1);
|
||||
});
|
||||
|
||||
it('counts duplicate/already-available books as skippedExisting', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Test Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||
asin: 'B001SERIES1',
|
||||
title: 'Test Series',
|
||||
bookCount: 1,
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
// Request creation returns duplicate
|
||||
mockCreateRequestForUser.mockResolvedValue({
|
||||
success: false,
|
||||
reason: 'duplicate',
|
||||
message: 'Already requested',
|
||||
});
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists();
|
||||
|
||||
expect(stats.skippedExisting).toBe(1);
|
||||
expect(stats.requestsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('deduplicates scraping when multiple users watch same series', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Same Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'user1' },
|
||||
},
|
||||
{
|
||||
id: 'ws-2',
|
||||
userId: 'user-2',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Same Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-2', plexUsername: 'user2' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||
|
||||
// Should only scrape once despite 2 subscriptions
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||
asin: 'B001SERIES1',
|
||||
title: 'Same Series',
|
||||
bookCount: 1,
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists();
|
||||
|
||||
// Scraped once, but created requests for both users
|
||||
expect(mockScrapeSeriesPage).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreateRequestForUser).toHaveBeenCalledTimes(2);
|
||||
expect(stats.requestsCreated).toBe(2);
|
||||
});
|
||||
|
||||
it('handles empty series page gracefully', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Empty Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce(null);
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists();
|
||||
|
||||
expect(stats.seriesChecked).toBe(1);
|
||||
expect(stats.booksFound).toBe(0);
|
||||
expect(stats.requestsCreated).toBe(0);
|
||||
expect(mockCreateRequestForUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns empty stats when no watched items exist', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists();
|
||||
|
||||
expect(stats.seriesChecked).toBe(0);
|
||||
expect(stats.authorsChecked).toBe(0);
|
||||
expect(stats.booksFound).toBe(0);
|
||||
expect(stats.requestsCreated).toBe(0);
|
||||
expect(stats.errors).toBe(0);
|
||||
});
|
||||
|
||||
it('persists dedup groups to works table', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Test Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||
asin: 'B001SERIES1',
|
||||
title: 'Test Series',
|
||||
bookCount: 2,
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
{ asin: 'B001BOOK02', title: 'Book One (Remastered)', author: 'Author A' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
const dedupGroup = {
|
||||
canonicalAsin: 'B001BOOK01',
|
||||
allAsins: ['B001BOOK01', 'B001BOOK02'],
|
||||
title: 'Book One',
|
||||
author: 'Author A',
|
||||
};
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' }],
|
||||
groups: [dedupGroup],
|
||||
});
|
||||
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
await processWatchedLists();
|
||||
|
||||
expect(mockPersistDedupGroups).toHaveBeenCalledWith([dedupGroup]);
|
||||
});
|
||||
|
||||
// ---- Targeted processing tests ----
|
||||
|
||||
it('filters by seriesAsin when provided in options', async () => {
|
||||
// Two series exist, but we only want to process one
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Target Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||
asin: 'B001SERIES1',
|
||||
title: 'Target Series',
|
||||
bookCount: 1,
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists(undefined, {
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
});
|
||||
|
||||
// Should have passed both userId and seriesAsin to the Prisma query
|
||||
expect(prismaMock.watchedSeries.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', seriesAsin: 'B001SERIES1' },
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
expect(stats.seriesChecked).toBe(1);
|
||||
expect(stats.requestsCreated).toBe(1);
|
||||
});
|
||||
|
||||
it('filters by authorAsin when provided in options', async () => {
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'wa-1',
|
||||
userId: 'user-1',
|
||||
authorAsin: 'B001AUTH001',
|
||||
authorName: 'Target Author',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.update.mockResolvedValue({});
|
||||
|
||||
mockSearchByAuthorAsin.mockResolvedValueOnce({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Target Author' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
totalResults: 1,
|
||||
});
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Author Book', author: 'Target Author' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists(undefined, {
|
||||
userId: 'user-1',
|
||||
authorAsin: 'B001AUTH001',
|
||||
});
|
||||
|
||||
// Should have passed both userId and authorAsin to the Prisma query
|
||||
expect(prismaMock.watchedAuthor.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', authorAsin: 'B001AUTH001' },
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
expect(stats.authorsChecked).toBe(1);
|
||||
expect(stats.requestsCreated).toBe(1);
|
||||
});
|
||||
|
||||
it('skips authors when targeted for a specific series only', async () => {
|
||||
// When seriesAsin is provided but no authorAsin, authors should still be queried
|
||||
// but with no authorAsin filter (only userId), so they run normally.
|
||||
// The key behavior: seriesAsin filter applies to series, not authors.
|
||||
prismaMock.watchedSeries.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'ws-1',
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
seriesTitle: 'Target Series',
|
||||
coverArtUrl: null,
|
||||
lastCheckedAt: null,
|
||||
user: { id: 'user-1', plexUsername: 'testuser' },
|
||||
},
|
||||
]);
|
||||
|
||||
prismaMock.watchedAuthor.findMany.mockResolvedValue([]);
|
||||
prismaMock.watchedSeries.update.mockResolvedValue({});
|
||||
|
||||
mockScrapeSeriesPage.mockResolvedValueOnce({
|
||||
asin: 'B001SERIES1',
|
||||
title: 'Target Series',
|
||||
bookCount: 1,
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
hasMore: false,
|
||||
page: 1,
|
||||
});
|
||||
|
||||
mockDeduplicateAndCollectGroups.mockReturnValue({
|
||||
books: [
|
||||
{ asin: 'B001BOOK01', title: 'Book One', author: 'Author A' },
|
||||
],
|
||||
groups: [],
|
||||
});
|
||||
|
||||
mockCreateRequestForUser.mockResolvedValue({ success: true, request: {} });
|
||||
|
||||
const { processWatchedLists } = await import('@/lib/services/watched-lists.service');
|
||||
const stats = await processWatchedLists(undefined, {
|
||||
userId: 'user-1',
|
||||
seriesAsin: 'B001SERIES1',
|
||||
});
|
||||
|
||||
// Series should be filtered by seriesAsin
|
||||
expect(prismaMock.watchedSeries.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1', seriesAsin: 'B001SERIES1' },
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
// Authors query should only filter by userId (no authorAsin filter)
|
||||
expect(prismaMock.watchedAuthor.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: 'user-1' },
|
||||
include: { user: { select: { id: true, plexUsername: true } } },
|
||||
});
|
||||
|
||||
expect(stats.seriesChecked).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* Component: Works Service Tests
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createPrismaMock } from '../helpers/prisma';
|
||||
import type { DedupGroup } from '@/lib/utils/deduplicate-audiobooks';
|
||||
|
||||
const prismaMock = createPrismaMock();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: prismaMock,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils/logger', () => ({
|
||||
RMABLogger: {
|
||||
create: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('persistDedupGroups', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('creates new work + work_asins for a fresh group', async () => {
|
||||
prismaMock.workAsin.findMany.mockResolvedValue([]);
|
||||
prismaMock.work.create.mockResolvedValue({ id: 'work-1' });
|
||||
prismaMock.workAsin.create.mockResolvedValue({});
|
||||
prismaMock.workAsin.updateMany.mockResolvedValue({ count: 0 });
|
||||
|
||||
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||
|
||||
const groups: DedupGroup[] = [{
|
||||
canonicalAsin: 'ASIN_A',
|
||||
allAsins: ['ASIN_A', 'ASIN_B'],
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
narrator: 'Test Narrator',
|
||||
durationMinutes: 600,
|
||||
}];
|
||||
|
||||
await persistDedupGroups(groups);
|
||||
|
||||
expect(prismaMock.work.create).toHaveBeenCalledWith({
|
||||
data: { title: 'Test Book', author: 'Test Author' },
|
||||
});
|
||||
expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Canonical ASIN should have narrator, duration, isCanonical=true
|
||||
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
workId: 'work-1',
|
||||
asin: 'ASIN_A',
|
||||
narrator: 'Test Narrator',
|
||||
durationMinutes: 600,
|
||||
isCanonical: true,
|
||||
source: 'dedup_auto',
|
||||
}),
|
||||
});
|
||||
|
||||
// Non-canonical ASIN should have isCanonical=false
|
||||
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
workId: 'work-1',
|
||||
asin: 'ASIN_B',
|
||||
isCanonical: false,
|
||||
source: 'dedup_auto',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('adds new ASINs to existing work when canonical already exists', async () => {
|
||||
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||
{ asin: 'ASIN_A', workId: 'existing-work' },
|
||||
]);
|
||||
prismaMock.workAsin.create.mockResolvedValue({});
|
||||
prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||
|
||||
const groups: DedupGroup[] = [{
|
||||
canonicalAsin: 'ASIN_A',
|
||||
allAsins: ['ASIN_A', 'ASIN_B', 'ASIN_C'],
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
narrator: 'Narrator',
|
||||
durationMinutes: 500,
|
||||
}];
|
||||
|
||||
await persistDedupGroups(groups);
|
||||
|
||||
// Should NOT create a new work
|
||||
expect(prismaMock.work.create).not.toHaveBeenCalled();
|
||||
|
||||
// Should create entries for ASIN_B and ASIN_C only (ASIN_A already exists)
|
||||
expect(prismaMock.workAsin.create).toHaveBeenCalledTimes(2);
|
||||
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
workId: 'existing-work',
|
||||
asin: 'ASIN_B',
|
||||
}),
|
||||
});
|
||||
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
workId: 'existing-work',
|
||||
asin: 'ASIN_C',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('merges two separate works when dedup groups them together', async () => {
|
||||
// ASIN_A is in work-1, ASIN_B is in work-2
|
||||
prismaMock.workAsin.findMany.mockResolvedValue([
|
||||
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||
{ asin: 'ASIN_B', workId: 'work-2' },
|
||||
]);
|
||||
prismaMock.workAsin.updateMany.mockResolvedValue({ count: 1 });
|
||||
prismaMock.work.deleteMany.mockResolvedValue({ count: 1 });
|
||||
|
||||
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||
|
||||
const groups: DedupGroup[] = [{
|
||||
canonicalAsin: 'ASIN_A',
|
||||
allAsins: ['ASIN_A', 'ASIN_B'],
|
||||
title: 'Merged Book',
|
||||
author: 'Author',
|
||||
}];
|
||||
|
||||
await persistDedupGroups(groups);
|
||||
|
||||
// Should move work-2 ASINs to work-1
|
||||
expect(prismaMock.workAsin.updateMany).toHaveBeenCalledWith({
|
||||
where: { workId: { in: ['work-2'] } },
|
||||
data: { workId: 'work-1' },
|
||||
});
|
||||
|
||||
// Should delete work-2
|
||||
expect(prismaMock.work.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['work-2'] } },
|
||||
});
|
||||
});
|
||||
|
||||
it('silently catches and logs errors without throwing', async () => {
|
||||
prismaMock.workAsin.findMany.mockRejectedValue(new Error('DB connection failed'));
|
||||
|
||||
const { persistDedupGroups } = await import('@/lib/services/works.service');
|
||||
|
||||
const groups: DedupGroup[] = [{
|
||||
canonicalAsin: 'ASIN_A',
|
||||
allAsins: ['ASIN_A', 'ASIN_B'],
|
||||
title: 'Test',
|
||||
author: 'Auth',
|
||||
}];
|
||||
|
||||
// Should not throw
|
||||
await expect(persistDedupGroups(groups)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('seedAsin', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('creates single-ASIN work for new ASIN', async () => {
|
||||
prismaMock.workAsin.findUnique.mockResolvedValue(null);
|
||||
prismaMock.work.create.mockResolvedValue({ id: 'new-work' });
|
||||
prismaMock.workAsin.create.mockResolvedValue({});
|
||||
|
||||
const { seedAsin } = await import('@/lib/services/works.service');
|
||||
|
||||
await seedAsin('NEW_ASIN', 'New Book', 'Author', 'Narrator', 300);
|
||||
|
||||
expect(prismaMock.work.create).toHaveBeenCalledWith({
|
||||
data: { title: 'New Book', author: 'Author' },
|
||||
});
|
||||
expect(prismaMock.workAsin.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
workId: 'new-work',
|
||||
asin: 'NEW_ASIN',
|
||||
narrator: 'Narrator',
|
||||
durationMinutes: 300,
|
||||
isCanonical: true,
|
||||
source: 'dedup_auto',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('does nothing for already-tracked ASIN', async () => {
|
||||
prismaMock.workAsin.findUnique.mockResolvedValue({
|
||||
id: 'existing',
|
||||
asin: 'EXISTING_ASIN',
|
||||
workId: 'work-1',
|
||||
});
|
||||
|
||||
const { seedAsin } = await import('@/lib/services/works.service');
|
||||
|
||||
await seedAsin('EXISTING_ASIN', 'Book', 'Author');
|
||||
|
||||
expect(prismaMock.work.create).not.toHaveBeenCalled();
|
||||
expect(prismaMock.workAsin.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('silently catches and logs errors without throwing', async () => {
|
||||
prismaMock.workAsin.findUnique.mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const { seedAsin } = await import('@/lib/services/works.service');
|
||||
|
||||
await expect(seedAsin('ASIN', 'Book', 'Auth')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSiblingAsins', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('returns sibling ASINs correctly', async () => {
|
||||
// First query: find input ASINs and their work IDs
|
||||
prismaMock.workAsin.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||
{ asin: 'ASIN_C', workId: 'work-2' },
|
||||
])
|
||||
// Second query: all ASINs in those works
|
||||
.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||
{ asin: 'ASIN_B', workId: 'work-1' },
|
||||
{ asin: 'ASIN_C', workId: 'work-2' },
|
||||
{ asin: 'ASIN_D', workId: 'work-2' },
|
||||
{ asin: 'ASIN_E', workId: 'work-2' },
|
||||
]);
|
||||
|
||||
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||
|
||||
const result = await getSiblingAsins(['ASIN_A', 'ASIN_C']);
|
||||
|
||||
expect(result.get('ASIN_A')).toEqual(['ASIN_B']);
|
||||
expect(result.get('ASIN_C')).toEqual(['ASIN_D', 'ASIN_E']);
|
||||
});
|
||||
|
||||
it('returns empty map for unknown ASINs', async () => {
|
||||
prismaMock.workAsin.findMany.mockResolvedValue([]);
|
||||
|
||||
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||
|
||||
const result = await getSiblingAsins(['UNKNOWN']);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty map for empty input', async () => {
|
||||
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||
|
||||
const result = await getSiblingAsins([]);
|
||||
|
||||
expect(result.size).toBe(0);
|
||||
// Should not query DB
|
||||
expect(prismaMock.workAsin.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('excludes the input ASIN itself from siblings', async () => {
|
||||
prismaMock.workAsin.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN_A', workId: 'work-1' },
|
||||
{ asin: 'ASIN_B', workId: 'work-1' },
|
||||
]);
|
||||
|
||||
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||
|
||||
const result = await getSiblingAsins(['ASIN_A']);
|
||||
|
||||
expect(result.get('ASIN_A')).toEqual(['ASIN_B']);
|
||||
expect(result.get('ASIN_A')).not.toContain('ASIN_A');
|
||||
});
|
||||
|
||||
it('omits ASINs with no siblings (single-ASIN works)', async () => {
|
||||
prismaMock.workAsin.findMany
|
||||
.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN_LONELY', workId: 'work-solo' },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ asin: 'ASIN_LONELY', workId: 'work-solo' },
|
||||
]);
|
||||
|
||||
const { getSiblingAsins } = await import('@/lib/services/works.service');
|
||||
|
||||
const result = await getSiblingAsins(['ASIN_LONELY']);
|
||||
|
||||
// No siblings means it shouldn't be in the map at all
|
||||
expect(result.has('ASIN_LONELY')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,451 @@
|
||||
/**
|
||||
* Component: Audiobook Deduplication Tests
|
||||
* Documentation: documentation/integrations/audible.md
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
deduplicateAudiobooks,
|
||||
deduplicateAndCollectGroups,
|
||||
normalizeTitle,
|
||||
areDurationsCompatible,
|
||||
} from '@/lib/utils/deduplicate-audiobooks';
|
||||
import type { AudibleAudiobook } from '@/lib/integrations/audible.service';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: minimal AudibleAudiobook factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBook(overrides: Partial<AudibleAudiobook> & { asin: string; title: string; author: string }): AudibleAudiobook {
|
||||
return {
|
||||
narrator: undefined,
|
||||
coverArtUrl: undefined,
|
||||
durationMinutes: undefined,
|
||||
rating: undefined,
|
||||
description: undefined,
|
||||
releaseDate: undefined,
|
||||
genres: undefined,
|
||||
series: undefined,
|
||||
seriesPart: undefined,
|
||||
seriesAsin: undefined,
|
||||
authorAsin: undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// normalizeTitle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('normalizeTitle', () => {
|
||||
it('lowercases', () => {
|
||||
expect(normalizeTitle('The Black Prism')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (Unabridged)', () => {
|
||||
expect(normalizeTitle('The Black Prism (Unabridged)')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips [Abridged Edition]', () => {
|
||||
expect(normalizeTitle('The Black Prism [Abridged Edition]')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (2024 Remastered Edition)', () => {
|
||||
expect(normalizeTitle('The Hobbit (2024 Remastered Edition)')).toBe('the hobbit');
|
||||
});
|
||||
|
||||
it('strips subtitle after colon', () => {
|
||||
expect(normalizeTitle('The Black Prism: Lightbringer, Book 1')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips subtitle after long dash', () => {
|
||||
expect(normalizeTitle('The Black Prism \u2014 A Lightbringer Novel')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips trailing "A Novel"', () => {
|
||||
expect(normalizeTitle('The Black Prism: A Novel')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (Audiobook)', () => {
|
||||
expect(normalizeTitle('The Hobbit (Audiobook)')).toBe('the hobbit');
|
||||
});
|
||||
|
||||
it('strips (Dramatized Adaptation)', () => {
|
||||
expect(normalizeTitle('The Black Prism (Dramatized Adaptation)')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('strips (Full Cast Narration)', () => {
|
||||
expect(normalizeTitle('The Black Prism (Full Cast Narration)')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('collapses whitespace', () => {
|
||||
expect(normalizeTitle(' The Black Prism ')).toBe('the black prism');
|
||||
});
|
||||
|
||||
it('handles empty string', () => {
|
||||
expect(normalizeTitle('')).toBe('');
|
||||
});
|
||||
|
||||
it('preserves hyphenated words (not subtitles)', () => {
|
||||
// "well-known" has a short dash, not a subtitle separator
|
||||
expect(normalizeTitle('A Well-Known Book')).toBe('a well-known book');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// areDurationsCompatible
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('areDurationsCompatible', () => {
|
||||
it('returns true when both undefined', () => {
|
||||
expect(areDurationsCompatible(undefined, undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when one undefined', () => {
|
||||
expect(areDurationsCompatible(600, undefined)).toBe(true);
|
||||
expect(areDurationsCompatible(undefined, 600)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for identical durations', () => {
|
||||
expect(areDurationsCompatible(600, 600)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses 1% of longer duration as tolerance for long books', () => {
|
||||
// Two 40-hour books (2400 min): tolerance = max(2400*0.01, 5) = 24 min
|
||||
expect(areDurationsCompatible(2400, 2424)).toBe(true); // exactly at tolerance
|
||||
expect(areDurationsCompatible(2400, 2425)).toBe(false); // just over
|
||||
});
|
||||
|
||||
it('uses 5-minute minimum tolerance for short books', () => {
|
||||
// Two 2-hour books (120 min): tolerance = max(120*0.01, 5) = max(1.2, 5) = 5 min
|
||||
expect(areDurationsCompatible(120, 125)).toBe(true); // exactly at 5-min minimum
|
||||
expect(areDurationsCompatible(120, 126)).toBe(false); // just over
|
||||
});
|
||||
|
||||
it('keeps abridged vs unabridged separate (large duration gap)', () => {
|
||||
// Unabridged: 720 min (12 hrs), Abridged: 360 min (6 hrs)
|
||||
expect(areDurationsCompatible(720, 360)).toBe(false);
|
||||
});
|
||||
|
||||
it('symmetry: order does not matter', () => {
|
||||
expect(areDurationsCompatible(2400, 2424)).toBe(true);
|
||||
expect(areDurationsCompatible(2424, 2400)).toBe(true);
|
||||
expect(areDurationsCompatible(120, 126)).toBe(false);
|
||||
expect(areDurationsCompatible(126, 120)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deduplicateAudiobooks
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('deduplicateAudiobooks', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(deduplicateAudiobooks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns single book unchanged', () => {
|
||||
const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Author' });
|
||||
expect(deduplicateAudiobooks([book])).toEqual([book]);
|
||||
});
|
||||
|
||||
it('passes through all-unique books unchanged', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }),
|
||||
makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }),
|
||||
makeBook({ asin: 'A3', title: 'Book Three', author: 'Auth', narrator: 'Nar B', durationMinutes: 700 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('collapses simple duplicates (same title + narrator + similar duration)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('keeps books with different narrators (different production)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Full Cast', durationMinutes: 480 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('keeps abridged vs unabridged (same narrator, very different duration)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('collapses when one book has missing duration', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: undefined }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses when both books have missing duration', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance' }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses title variants with edition markers', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism (Unabridged)', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1258 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('collapses title variants with subtitles', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism: Lightbringer, Book 1', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('picks the representative with most metadata', () => {
|
||||
const sparse = makeBook({
|
||||
asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||
});
|
||||
const rich = makeBook({
|
||||
asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||
coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book',
|
||||
});
|
||||
const result = deduplicateAudiobooks([sparse, rich]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].asin).toBe('A2'); // rich entry wins
|
||||
});
|
||||
|
||||
it('preserves original order (first-seen position)', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }),
|
||||
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 400 }),
|
||||
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||
makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map(b => b.title)).toEqual(['Alpha', 'Beta', 'Charlie']);
|
||||
});
|
||||
|
||||
it('handles Lightbringer-style scenario: unabridged + dramatized', () => {
|
||||
// Simon Vance full narration (long)
|
||||
const vance1 = makeBook({
|
||||
asin: 'SV1', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||
coverArtUrl: 'cover1.jpg', rating: 4.7,
|
||||
});
|
||||
// Re-listed Simon Vance (same duration, different ASIN)
|
||||
const vance2 = makeBook({
|
||||
asin: 'SV2', title: 'The Black Prism: Lightbringer Book 1', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||
});
|
||||
// Dramatized with full cast (shorter, different narrator)
|
||||
const drama = makeBook({
|
||||
asin: 'DR1', title: 'The Black Prism (Dramatized Adaptation)', author: 'Brent Weeks',
|
||||
narrator: 'Full Cast', durationMinutes: 480,
|
||||
coverArtUrl: 'cover-drama.jpg',
|
||||
});
|
||||
|
||||
const result = deduplicateAudiobooks([vance1, vance2, drama]);
|
||||
expect(result).toHaveLength(2);
|
||||
// Simon Vance should collapse to 1, Full Cast stays
|
||||
expect(result.find(b => b.narrator === 'Simon Vance')).toBeTruthy();
|
||||
expect(result.find(b => b.narrator === 'Full Cast')).toBeTruthy();
|
||||
// Should pick the richer entry for Simon Vance
|
||||
const svResult = result.find(b => b.narrator === 'Simon Vance')!;
|
||||
expect(svResult.asin).toBe('SV1'); // has cover + rating
|
||||
});
|
||||
|
||||
it('uses percentage tolerance for very long audiobooks', () => {
|
||||
// Two 40-hour books: tolerance = max(2400*0.01, 5) = 24 min
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2420 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||
|
||||
// Beyond tolerance
|
||||
const booksFar = [
|
||||
makeBook({ asin: 'A1', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2400 }),
|
||||
makeBook({ asin: 'A2', title: 'Long Book', author: 'Auth', narrator: 'Nar', durationMinutes: 2430 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(booksFar)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('treats missing narrator as its own group', () => {
|
||||
// Two entries with same title but no narrator - should collapse
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),
|
||||
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 302 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not collapse empty-narrator with named narrator', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: undefined, durationMinutes: 300 }),
|
||||
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'John Smith', durationMinutes: 302 }),
|
||||
];
|
||||
expect(deduplicateAudiobooks(books)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('collapses duplicates when narrators are listed in different order', () => {
|
||||
const books = [
|
||||
makeBook({
|
||||
asin: 'A1', title: 'The Passengers', author: 'John Marrs',
|
||||
narrator: 'Kristin Atherton, Roy McMillan, Clare Corbett, Tom Bateman, Patience Tomlinson, Shaheen Khan',
|
||||
durationMinutes: 600,
|
||||
}),
|
||||
makeBook({
|
||||
asin: 'A2', title: 'The Passengers', author: 'John Marrs',
|
||||
narrator: 'Clare Corbett, Roy McMillan, Tom Bateman, Shaheen Khan, Kristin Atherton, Patience Tomlinson',
|
||||
durationMinutes: 602,
|
||||
}),
|
||||
];
|
||||
const result = deduplicateAudiobooks(books);
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// deduplicateAndCollectGroups
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('deduplicateAndCollectGroups', () => {
|
||||
it('returns empty groups array when no duplicates', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Book One', author: 'Auth', narrator: 'Nar A', durationMinutes: 600 }),
|
||||
makeBook({ asin: 'A2', title: 'Book Two', author: 'Auth', narrator: 'Nar A', durationMinutes: 500 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty groups for empty input', () => {
|
||||
const { books: result, groups } = deduplicateAndCollectGroups([]);
|
||||
expect(result).toHaveLength(0);
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty groups for single book', () => {
|
||||
const book = makeBook({ asin: 'A1', title: 'Book One', author: 'Auth' });
|
||||
const { books: result, groups } = deduplicateAndCollectGroups([book]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(groups).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns group with 2 ASINs when 2 books match', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1260 }),
|
||||
makeBook({ asin: 'A2', title: 'The Black Prism', author: 'Brent Weeks', narrator: 'Simon Vance', durationMinutes: 1262 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].allAsins).toHaveLength(2);
|
||||
expect(groups[0].allAsins).toContain('A1');
|
||||
expect(groups[0].allAsins).toContain('A2');
|
||||
});
|
||||
|
||||
it('returns group with 3+ ASINs for multi-duplicate scenario', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 662 }),
|
||||
makeBook({ asin: 'A3', title: 'The Hobbit (Unabridged)', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 658 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].allAsins).toHaveLength(3);
|
||||
expect(groups[0].allAsins).toContain('A1');
|
||||
expect(groups[0].allAsins).toContain('A2');
|
||||
expect(groups[0].allAsins).toContain('A3');
|
||||
});
|
||||
|
||||
it('canonicalAsin is the one with highest metadata score', () => {
|
||||
const sparse = makeBook({
|
||||
asin: 'SPARSE', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1260,
|
||||
});
|
||||
const rich = makeBook({
|
||||
asin: 'RICH', title: 'The Black Prism', author: 'Brent Weeks',
|
||||
narrator: 'Simon Vance', durationMinutes: 1262,
|
||||
coverArtUrl: 'https://img.jpg', rating: 4.5, description: 'Great book',
|
||||
});
|
||||
const { groups } = deduplicateAndCollectGroups([sparse, rich]);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].canonicalAsin).toBe('RICH');
|
||||
});
|
||||
|
||||
it('groups only include entries with 2+ ASINs', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300 }),
|
||||
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||
];
|
||||
const { groups } = deduplicateAndCollectGroups(books);
|
||||
// Only Alpha group should appear (Beta is a singleton)
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].allAsins).toContain('A1');
|
||||
expect(groups[0].allAsins).toContain('A2');
|
||||
});
|
||||
|
||||
it('duration-incompatible books produce separate entries (no group for singletons)', () => {
|
||||
// Same title/narrator but very different durations (abridged vs unabridged)
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 660 }),
|
||||
makeBook({ asin: 'A2', title: 'The Hobbit', author: 'Tolkien', narrator: 'Andy Serkis', durationMinutes: 330 }),
|
||||
];
|
||||
const { books: result, groups } = deduplicateAndCollectGroups(books);
|
||||
expect(result).toHaveLength(2); // Not collapsed
|
||||
expect(groups).toHaveLength(0); // No multi-ASIN groups
|
||||
});
|
||||
|
||||
it('books field matches what deduplicateAudiobooks returns', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 300, coverArtUrl: 'img.jpg', rating: 4.5 }),
|
||||
makeBook({ asin: 'A2', title: 'Alpha', author: 'Auth', narrator: 'Nar', durationMinutes: 302 }),
|
||||
makeBook({ asin: 'B1', title: 'Beta', author: 'Auth', narrator: 'Nar', durationMinutes: 500 }),
|
||||
makeBook({ asin: 'C1', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 600 }),
|
||||
makeBook({ asin: 'C2', title: 'Charlie', author: 'Auth', narrator: 'Nar', durationMinutes: 601 }),
|
||||
];
|
||||
const dedupOnly = deduplicateAudiobooks(books);
|
||||
const { books: withGroups } = deduplicateAndCollectGroups(books);
|
||||
expect(withGroups.map(b => b.asin)).toEqual(dedupOnly.map(b => b.asin));
|
||||
});
|
||||
|
||||
it('includes narrator and durationMinutes from canonical entry in group', () => {
|
||||
const books = [
|
||||
makeBook({ asin: 'A1', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 480 }),
|
||||
makeBook({ asin: 'A2', title: 'Test Book', author: 'Auth', narrator: 'Jane Doe', durationMinutes: 482, coverArtUrl: 'img.jpg', rating: 4.0 }),
|
||||
];
|
||||
const { groups } = deduplicateAndCollectGroups(books);
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].canonicalAsin).toBe('A2'); // richer metadata
|
||||
expect(groups[0].narrator).toBe('Jane Doe');
|
||||
expect(groups[0].durationMinutes).toBe(482);
|
||||
expect(groups[0].author).toBe('Auth');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user