Merge branch 'main' into feature/hardover-shelves

This commit is contained in:
kikootwo
2026-03-03 22:23:41 -05:00
43 changed files with 4489 additions and 423 deletions
+15 -10
View File
@@ -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');
});
});
+4
View File
@@ -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(),
});
+6
View File
@@ -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 () => {
+1 -1
View File
@@ -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);
});
});
+306
View File
@@ -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);
});
});
+451
View File
@@ -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');
});
});