mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
Merge branch 'main' into feature/hardover-shelves
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user