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
+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);
});
});