mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-03 04:40:09 +00:00
a97979358f
Adds file hash-based matching for Audiobookshelf library items to ensure 100% accurate ASIN assignment for RMAB-organized content. Removes fuzzy matching from library availability checks, making all matching ASIN-only to eliminate false positives and race conditions. Updates database schema, processors, and matcher utilities; adds new tests and documentation for the new matching strategy. Removes obsolete scripts, Dockerfile, and related tests; updates docker-compose for test environments.
207 lines
6.3 KiB
TypeScript
207 lines
6.3 KiB
TypeScript
/**
|
|
* Component: Recently Added Processor Tests
|
|
* Documentation: documentation/backend/services/scheduler.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPrismaMock } from '../helpers/prisma';
|
|
|
|
const prismaMock = createPrismaMock();
|
|
const libraryServiceMock = vi.hoisted(() => ({
|
|
getRecentlyAdded: vi.fn(),
|
|
getCoverCachingParams: vi.fn(),
|
|
}));
|
|
const configMock = vi.hoisted(() => ({
|
|
getBackendMode: vi.fn(),
|
|
getMany: vi.fn(),
|
|
get: vi.fn(),
|
|
}));
|
|
const thumbnailCacheServiceMock = vi.hoisted(() => ({
|
|
cacheLibraryThumbnail: vi.fn(),
|
|
}));
|
|
const jobQueueMock = vi.hoisted(() => ({
|
|
addNotificationJob: vi.fn(() => Promise.resolve()),
|
|
}));
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
|
getJobQueueService: () => jobQueueMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/library', () => ({
|
|
getLibraryService: async () => libraryServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/utils/audiobook-matcher', () => ({
|
|
findPlexMatch: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/services/audiobookshelf/api', () => ({
|
|
triggerABSItemMatch: vi.fn(),
|
|
getABSItem: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('@/lib/services/thumbnail-cache.service', () => ({
|
|
getThumbnailCacheService: () => thumbnailCacheServiceMock,
|
|
}));
|
|
|
|
describe('processPlexRecentlyAddedCheck', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it('skips when Plex configuration is missing', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getMany.mockResolvedValue({
|
|
plex_url: '',
|
|
plex_token: '',
|
|
plex_audiobook_library_id: '',
|
|
});
|
|
|
|
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
|
|
const result = await processPlexRecentlyAddedCheck({ jobId: 'job-1' });
|
|
|
|
expect(result.skipped).toBe(true);
|
|
expect(prismaMock.plexLibrary.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates and updates recently added library items', async () => {
|
|
configMock.getBackendMode.mockResolvedValue('plex');
|
|
configMock.getMany.mockResolvedValue({
|
|
plex_url: 'http://plex',
|
|
plex_token: 'token',
|
|
plex_audiobook_library_id: 'lib-1',
|
|
});
|
|
configMock.get.mockResolvedValue('lib-1');
|
|
|
|
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
|
backendBaseUrl: 'http://plex',
|
|
authToken: 'token',
|
|
backendMode: 'plex',
|
|
});
|
|
|
|
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
|
|
|
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
|
|
{
|
|
id: 'rating-1',
|
|
externalId: 'guid-1',
|
|
title: 'New Item',
|
|
author: 'Author A',
|
|
addedAt: new Date(),
|
|
},
|
|
{
|
|
id: 'rating-2',
|
|
externalId: 'guid-2',
|
|
title: 'Existing Item',
|
|
author: 'Author B',
|
|
addedAt: new Date(),
|
|
},
|
|
]);
|
|
|
|
prismaMock.plexLibrary.findUnique.mockImplementation(async (query: any) => {
|
|
if (query.where.plexGuid === 'guid-2') {
|
|
return { id: 'existing-id', plexGuid: 'guid-2', author: 'Author B' };
|
|
}
|
|
return null;
|
|
});
|
|
prismaMock.plexLibrary.create.mockResolvedValue({});
|
|
prismaMock.plexLibrary.update.mockResolvedValue({});
|
|
prismaMock.request.findMany.mockResolvedValue([]);
|
|
|
|
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
|
|
const result = await processPlexRecentlyAddedCheck({ jobId: 'job-2' });
|
|
|
|
expect(result.newCount).toBe(1);
|
|
expect(result.updatedCount).toBe(1);
|
|
expect(prismaMock.plexLibrary.create).toHaveBeenCalled();
|
|
expect(prismaMock.plexLibrary.update).toHaveBeenCalled();
|
|
});
|
|
|
|
it('matches requests without re-triggering ABS metadata match for audiobookshelf', async () => {
|
|
const matcher = await import('@/lib/utils/audiobook-matcher');
|
|
const absApi = await import('@/lib/services/audiobookshelf/api');
|
|
|
|
configMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
configMock.getMany.mockResolvedValue({
|
|
'audiobookshelf.server_url': 'http://abs',
|
|
'audiobookshelf.api_token': 'token',
|
|
'audiobookshelf.library_id': 'abs-lib',
|
|
});
|
|
configMock.get.mockResolvedValue('abs-lib');
|
|
|
|
libraryServiceMock.getCoverCachingParams.mockResolvedValue({
|
|
backendBaseUrl: 'http://abs',
|
|
authToken: 'token',
|
|
backendMode: 'audiobookshelf',
|
|
});
|
|
|
|
thumbnailCacheServiceMock.cacheLibraryThumbnail.mockResolvedValue('/app/cache/library/test.jpg');
|
|
|
|
libraryServiceMock.getRecentlyAdded.mockResolvedValue([
|
|
{
|
|
id: 'abs-1',
|
|
externalId: 'abs-item-1',
|
|
title: 'New ABS Item',
|
|
author: 'Author A',
|
|
asin: 'ASIN-ABS', // Item already has ASIN from ABS
|
|
addedAt: new Date(),
|
|
},
|
|
]);
|
|
prismaMock.plexLibrary.findUnique.mockResolvedValue(null);
|
|
prismaMock.plexLibrary.create.mockResolvedValue({});
|
|
|
|
prismaMock.request.findMany.mockResolvedValue([
|
|
{
|
|
id: 'req-1',
|
|
status: 'downloaded',
|
|
audiobook: {
|
|
id: 'ab-1',
|
|
title: 'Match Me',
|
|
author: 'Author A',
|
|
narrator: 'Narrator A',
|
|
audibleAsin: 'ASIN-ABS',
|
|
},
|
|
user: {
|
|
plexUsername: 'testuser',
|
|
},
|
|
},
|
|
] as any);
|
|
|
|
(matcher.findPlexMatch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
plexGuid: 'abs-item-1',
|
|
plexRatingKey: 'rating-abs',
|
|
title: 'Match Me',
|
|
author: 'Author A',
|
|
});
|
|
prismaMock.audiobook.update.mockResolvedValue({});
|
|
prismaMock.request.update.mockResolvedValue({});
|
|
|
|
const { processPlexRecentlyAddedCheck } = await import('@/lib/processors/plex-recently-added.processor');
|
|
const result = await processPlexRecentlyAddedCheck({ jobId: 'job-3' });
|
|
|
|
expect(result.matchedDownloads).toBe(1);
|
|
expect(prismaMock.audiobook.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ absItemId: 'abs-item-1' }),
|
|
})
|
|
);
|
|
expect(prismaMock.request.update).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
data: expect.objectContaining({ status: 'available' }),
|
|
})
|
|
);
|
|
// Should NOT trigger metadata match - items already have metadata from ABS
|
|
expect(absApi.triggerABSItemMatch).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
|