mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
cbf02d3e24
Introduce watched lists for series and authors end-to-end. - Add DB migration to create watched_series and watched_authors tables with indexes and foreign keys. - Implement API routes: GET/POST for listing/adding and DELETE by id for both /api/user/watched-series and /api/user/watched-authors. Validation, ownership checks, and immediate targeted job triggers are included. - Add client hooks (useWatchedSeries, useWatchedAuthors) with add/delete helpers and SWR revalidation. - Add UI components: WatchButton (toggle/confirm) and WatchedListsSection for profile display and removal UX. - Add processor (check-watched-lists.processor) and service (watched-lists.service) to scrape Audible, deduplicate, check library ownership, and auto-create requests; supports targeted checks for newly watched items. - Include tests for the watched-lists service. These changes implement the watched-lists feature to let users watch series/authors and have the system automatically detect and request new releases.
589 lines
18 KiB
TypeScript
589 lines
18 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|