Files
ReadMeABook/tests/processors/scan-plex.processor.test.ts
T
kikootwo 94dbaf073b Add backend unit test framework and modularize settings UI
Introduced a Vitest-based backend unit testing framework with supporting scripts, helpers, and GitHub Actions integration. Refactored the admin settings page to a modular architecture, splitting monolithic logic into feature-specific tabs and hooks for improved maintainability and testability. Updated documentation to reflect the new testing setup and settings architecture, and added new dependencies for testing utilities.
2026-01-28 11:41:59 -05:00

252 lines
7.8 KiB
TypeScript

/**
* Component: Library Scan Processor Tests
* Documentation: documentation/backend/services/jobs.md
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createPrismaMock } from '../helpers/prisma';
const prismaMock = createPrismaMock();
const libraryServiceMock = vi.hoisted(() => ({ getLibraryItems: vi.fn() }));
const configMock = vi.hoisted(() => ({
getBackendMode: vi.fn(),
getPlexConfig: vi.fn(),
get: vi.fn(),
}));
vi.mock('@/lib/utils/audiobook-matcher', () => ({
findPlexMatch: vi.fn(),
}));
vi.mock('@/lib/services/audiobookshelf/api', () => ({
triggerABSItemMatch: vi.fn(),
}));
vi.mock('@/lib/db', () => ({
prisma: prismaMock,
}));
vi.mock('@/lib/services/library', () => ({
getLibraryService: () => libraryServiceMock,
}));
vi.mock('@/lib/services/config.service', () => ({
getConfigService: () => configMock,
}));
describe('processScanPlex', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates and updates library items, matches requests', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
libraryId: 'lib-1',
machineIdentifier: 'machine',
});
libraryServiceMock.getLibraryItems.mockResolvedValue([
{
id: 'rating-1',
externalId: 'guid-1',
title: 'New Book',
author: 'Author',
addedAt: new Date(),
updatedAt: new Date(),
},
{
id: 'rating-2',
externalId: 'guid-2',
title: 'Existing Book',
author: 'Author',
addedAt: new Date(),
updatedAt: new Date(),
},
]);
prismaMock.plexLibrary.findFirst.mockImplementation(async (query: any) => {
if (query.where.plexGuid === 'guid-2') {
return { id: 'existing-id', plexGuid: 'guid-2' };
}
return null;
});
prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-1' });
prismaMock.plexLibrary.update.mockResolvedValue({});
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
prismaMock.audiobook.findMany.mockResolvedValue([]);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-1',
status: 'downloaded',
audiobook: {
id: 'a1',
title: 'New Book',
author: 'Author',
narrator: null,
audibleAsin: 'ASIN1',
},
},
]);
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
const matcher = await import('@/lib/utils/audiobook-matcher');
vi.spyOn(matcher, 'findPlexMatch').mockResolvedValue({
plexGuid: 'guid-1',
plexRatingKey: 'rating-1',
title: 'New Book',
author: 'Author',
});
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
const result = await processScanPlex({ jobId: 'job-1' });
expect(result.success).toBe(true);
expect(prismaMock.plexLibrary.create).toHaveBeenCalled();
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'available' }),
})
);
});
it('throws when audiobookshelf library is not configured', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue(null);
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
await expect(processScanPlex({ jobId: 'job-2' })).rejects.toThrow(
'Audiobookshelf library not configured'
);
expect(libraryServiceMock.getLibraryItems).not.toHaveBeenCalled();
});
it('removes stale items and resets linked audiobooks and requests', async () => {
configMock.getBackendMode.mockResolvedValue('plex');
configMock.getPlexConfig.mockResolvedValue({
serverUrl: 'http://plex',
authToken: 'token',
libraryId: 'lib-1',
machineIdentifier: 'machine',
});
libraryServiceMock.getLibraryItems.mockResolvedValue([
{
id: 'rating-1',
externalId: 'guid-1',
title: 'Current Book',
author: 'Author',
addedAt: new Date(),
updatedAt: new Date(),
},
]);
prismaMock.plexLibrary.findFirst.mockResolvedValue(null);
prismaMock.plexLibrary.create.mockResolvedValue({ id: 'new-id', plexGuid: 'guid-1' });
prismaMock.plexLibrary.findMany
.mockResolvedValueOnce([{ id: 'stale-1', plexGuid: 'stale-guid', title: 'Stale Book' }])
.mockResolvedValueOnce([{ plexGuid: 'guid-1' }]);
prismaMock.plexLibrary.delete.mockResolvedValue({});
prismaMock.audiobook.findMany
.mockResolvedValueOnce([
{
id: 'ab-1',
title: 'Stale Book',
requests: [{ id: 'req-1', status: 'available' }],
},
])
.mockResolvedValueOnce([
{
id: 'ab-valid',
title: 'Valid Book',
plexGuid: 'guid-1',
absItemId: null,
requests: [],
},
{
id: 'ab-orphan',
title: 'Orphaned Book',
plexGuid: null,
absItemId: 'missing-guid',
requests: [{ id: 'req-2', status: 'available' }],
},
]);
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
prismaMock.request.findMany.mockResolvedValue([]);
const matcher = await import('@/lib/utils/audiobook-matcher');
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue(null);
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
const result = await processScanPlex({ jobId: 'job-3' });
expect(result.success).toBe(true);
expect(prismaMock.plexLibrary.delete).toHaveBeenCalledWith({ where: { id: 'stale-1' } });
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: 'ab-orphan' },
data: expect.objectContaining({ plexGuid: null, absItemId: null }),
})
);
expect(prismaMock.request.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ status: 'downloaded' }),
})
);
});
it('matches audiobookshelf requests and triggers metadata match', async () => {
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
configMock.get.mockResolvedValue('abs-lib');
libraryServiceMock.getLibraryItems.mockResolvedValue([]);
prismaMock.plexLibrary.findMany.mockResolvedValue([]);
prismaMock.audiobook.findMany.mockResolvedValue([]);
prismaMock.request.findMany.mockResolvedValue([
{
id: 'req-abs',
status: 'downloaded',
audiobook: {
id: 'abs-audio',
title: 'ABS Title',
author: 'ABS Author',
narrator: 'Narrator',
audibleAsin: 'ASIN123',
},
},
]);
prismaMock.audiobook.update.mockResolvedValue({});
prismaMock.request.update.mockResolvedValue({});
const matcher = await import('@/lib/utils/audiobook-matcher');
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue({
plexGuid: 'abs-item-1',
plexRatingKey: 'rating-abs',
title: 'ABS Title',
author: 'ABS Author',
});
const absApi = await import('@/lib/services/audiobookshelf/api');
const { processScanPlex } = await import('@/lib/processors/scan-plex.processor');
const result = await processScanPlex({ jobId: 'job-4' });
expect(result.success).toBe(true);
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({ absItemId: 'abs-item-1' }),
})
);
expect(absApi.triggerABSItemMatch).toHaveBeenCalledWith('abs-item-1', 'ASIN123');
});
});