mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
338331d006
Introduce Hardcover provider support and consolidate per-provider book mapping tables into a unified BookMapping model. Adds two Prisma migrations (add_hardcover_shelves, unify_book_mappings), new backend services (hardcover-api, shelf-sync-core), and provider-specific sync logic and API routes for hardcover shelves with token/list validation. Frontend: new HardcoverForm component, refactor AddShelfModal to support Hardcover, hook updates, and small UI/accessibility tweaks. Also add documentation for Goodreads and Hardcover sync flows and update tests to cover scheduler/prisma helpers.
506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
/**
|
|
* Component: Scheduler Service Tests
|
|
* Documentation: documentation/backend/services/scheduler.md
|
|
*/
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { createPrismaMock } from '../helpers/prisma';
|
|
|
|
const prismaMock = createPrismaMock();
|
|
|
|
const jobQueueMock = vi.hoisted(() => ({
|
|
addRepeatableJob: vi.fn(),
|
|
removeRepeatableJob: vi.fn(),
|
|
addPlexScanJob: vi.fn(),
|
|
addPlexRecentlyAddedJob: vi.fn(),
|
|
addAudibleRefreshJob: vi.fn(),
|
|
addRetryMissingTorrentsJob: vi.fn(),
|
|
addRetryFailedImportsJob: vi.fn(),
|
|
addCleanupSeededTorrentsJob: vi.fn(),
|
|
addMonitorRssFeedsJob: vi.fn(),
|
|
addSyncShelvesJob: vi.fn(),
|
|
}));
|
|
|
|
const configServiceMock = vi.hoisted(() => ({
|
|
getBackendMode: vi.fn(),
|
|
getMany: vi.fn(),
|
|
}));
|
|
|
|
const notificationServiceMock = vi.hoisted(() => ({
|
|
reEncryptUnprotectedBackends: vi.fn().mockResolvedValue(0),
|
|
}));
|
|
|
|
vi.mock('@/lib/services/job-queue.service', () => ({
|
|
getJobQueueService: () => jobQueueMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/config.service', () => ({
|
|
getConfigService: () => configServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/services/notification', () => ({
|
|
getNotificationService: () => notificationServiceMock,
|
|
}));
|
|
|
|
vi.mock('@/lib/db', () => ({
|
|
prisma: prismaMock,
|
|
}));
|
|
|
|
describe('SchedulerService', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
prismaMock.scheduledJob.findFirst.mockReset();
|
|
prismaMock.scheduledJob.create.mockReset();
|
|
prismaMock.scheduledJob.findMany.mockReset();
|
|
prismaMock.scheduledJob.findUnique.mockReset();
|
|
prismaMock.scheduledJob.update.mockReset();
|
|
prismaMock.scheduledJob.delete.mockReset();
|
|
configServiceMock.getBackendMode.mockReset();
|
|
configServiceMock.getMany.mockReset();
|
|
});
|
|
|
|
it('initializes defaults and schedules enabled jobs', async () => {
|
|
prismaMock.scheduledJob.findFirst.mockResolvedValue(null);
|
|
prismaMock.scheduledJob.create.mockResolvedValue({});
|
|
prismaMock.scheduledJob.findMany
|
|
.mockResolvedValueOnce([]) // cleanupDeprecatedJobs
|
|
.mockResolvedValueOnce([
|
|
// scheduleAllJobs
|
|
{
|
|
id: 'job-1',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: true,
|
|
},
|
|
])
|
|
.mockResolvedValue([]); // triggerOverdueJobs
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.start();
|
|
|
|
expect(prismaMock.scheduledJob.create).toHaveBeenCalledTimes(9);
|
|
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
|
'audible_refresh',
|
|
{ scheduledJobId: 'job-1' },
|
|
'0 0 * * *',
|
|
'scheduled-job-1'
|
|
);
|
|
});
|
|
|
|
it('rejects invalid cron expressions', async () => {
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
|
|
await expect(
|
|
service.createScheduledJob({
|
|
name: 'Bad job',
|
|
type: 'audible_refresh',
|
|
schedule: 'bad',
|
|
})
|
|
).rejects.toThrow('Invalid cron expression format');
|
|
});
|
|
|
|
it('creates and schedules enabled jobs', async () => {
|
|
prismaMock.scheduledJob.create.mockResolvedValue({
|
|
id: 'job-2',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.createScheduledJob({
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: true,
|
|
});
|
|
|
|
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
|
'audible_refresh',
|
|
{ scheduledJobId: 'job-2' },
|
|
'0 0 * * *',
|
|
'scheduled-job-2'
|
|
);
|
|
});
|
|
|
|
it('returns scheduled jobs and single jobs', async () => {
|
|
prismaMock.scheduledJob.findMany.mockResolvedValue([{ id: 'job-2' }]);
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({ id: 'job-2' });
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
const jobs = await service.getScheduledJobs();
|
|
const job = await service.getScheduledJob('job-2');
|
|
|
|
expect(prismaMock.scheduledJob.findMany).toHaveBeenCalledWith({ orderBy: { name: 'asc' } });
|
|
expect(prismaMock.scheduledJob.findUnique).toHaveBeenCalledWith({ where: { id: 'job-2' } });
|
|
expect(jobs).toEqual([{ id: 'job-2' }]);
|
|
expect(job).toEqual({ id: 'job-2' });
|
|
});
|
|
|
|
it('updates jobs and reschedules when enabled', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-3',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
prismaMock.scheduledJob.update.mockResolvedValue({
|
|
id: 'job-3',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '*/15 * * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.updateScheduledJob('job-3', { schedule: '*/15 * * * *' });
|
|
|
|
expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
|
|
'audible_refresh',
|
|
'0 0 * * *',
|
|
'scheduled-job-3'
|
|
);
|
|
expect(jobQueueMock.addRepeatableJob).toHaveBeenCalledWith(
|
|
'audible_refresh',
|
|
{ scheduledJobId: 'job-3' },
|
|
'*/15 * * * *',
|
|
'scheduled-job-3'
|
|
);
|
|
});
|
|
|
|
it('unschedules jobs when disabling updates', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-3b',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
prismaMock.scheduledJob.update.mockResolvedValue({
|
|
id: 'job-3b',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: false,
|
|
payload: {},
|
|
});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.updateScheduledJob('job-3b', { enabled: false });
|
|
|
|
expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
|
|
'audible_refresh',
|
|
'0 0 * * *',
|
|
'scheduled-job-3b'
|
|
);
|
|
expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('triggers Plex scan jobs with validated config', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-4',
|
|
name: 'Library Scan',
|
|
type: 'plex_library_scan',
|
|
schedule: '0 */6 * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('plex');
|
|
configServiceMock.getMany.mockResolvedValue({
|
|
plex_url: 'http://plex',
|
|
plex_token: 'token',
|
|
plex_audiobook_library_id: 'lib-1',
|
|
});
|
|
jobQueueMock.addPlexScanJob.mockResolvedValue('bull-1');
|
|
prismaMock.scheduledJob.update.mockResolvedValue({});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
const jobId = await service.triggerJobNow('job-4');
|
|
|
|
expect(jobId).toBe('bull-1');
|
|
expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('lib-1', undefined, undefined);
|
|
expect(prismaMock.scheduledJob.update).toHaveBeenCalledWith({
|
|
where: { id: 'job-4' },
|
|
data: {
|
|
lastRun: expect.any(Date),
|
|
lastRunJobId: 'bull-1',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('triggers Audiobookshelf scans when configured', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-4b',
|
|
name: 'Library Scan',
|
|
type: 'plex_library_scan',
|
|
schedule: '0 */6 * * *',
|
|
enabled: true,
|
|
payload: { libraryId: 'abs-lib' },
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
configServiceMock.getMany.mockResolvedValue({
|
|
'audiobookshelf.server_url': 'http://abs',
|
|
'audiobookshelf.api_token': 'token',
|
|
'audiobookshelf.library_id': 'abs-lib-2',
|
|
});
|
|
jobQueueMock.addPlexScanJob.mockResolvedValue('bull-abs');
|
|
prismaMock.scheduledJob.update.mockResolvedValue({});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
const jobId = await service.triggerJobNow('job-4b');
|
|
|
|
expect(jobId).toBe('bull-abs');
|
|
expect(jobQueueMock.addPlexScanJob).toHaveBeenCalledWith('abs-lib', undefined, undefined);
|
|
});
|
|
|
|
it('throws on unknown scheduled job types', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-5',
|
|
name: 'Mystery',
|
|
type: 'unknown',
|
|
schedule: '* * * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
|
|
await expect(service.triggerJobNow('job-5')).rejects.toThrow('Unknown job type');
|
|
});
|
|
|
|
it.each([
|
|
['plex_recently_added_check', 'addPlexRecentlyAddedJob'],
|
|
['audible_refresh', 'addAudibleRefreshJob'],
|
|
['retry_missing_torrents', 'addRetryMissingTorrentsJob'],
|
|
['retry_failed_imports', 'addRetryFailedImportsJob'],
|
|
['cleanup_seeded_torrents', 'addCleanupSeededTorrentsJob'],
|
|
['monitor_rss_feeds', 'addMonitorRssFeedsJob'],
|
|
['sync_reading_shelves', 'addSyncShelvesJob'],
|
|
])('triggers %s jobs with job queue', async (type, queueMethod) => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-type',
|
|
name: 'Job',
|
|
type,
|
|
schedule: '* * * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
(jobQueueMock as any)[queueMethod].mockResolvedValue('bull-type');
|
|
prismaMock.scheduledJob.update.mockResolvedValue({});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
const jobId = await service.triggerJobNow('job-type');
|
|
|
|
expect(jobId).toBe('bull-type');
|
|
expect((jobQueueMock as any)[queueMethod]).toHaveBeenCalledWith('job-type');
|
|
});
|
|
|
|
it('parses cron intervals for common patterns', async () => {
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
|
|
expect((service as any).getIntervalFromCron('*/15 * * * *')).toBe(15 * 60 * 1000);
|
|
expect((service as any).getIntervalFromCron('0 */6 * * *')).toBe(6 * 60 * 60 * 1000);
|
|
expect((service as any).getIntervalFromCron('0 4 * * *')).toBe(24 * 60 * 60 * 1000);
|
|
expect((service as any).getIntervalFromCron('0 4 * * 1')).toBe(7 * 24 * 60 * 60 * 1000);
|
|
expect((service as any).getIntervalFromCron('invalid cron')).toBeNull();
|
|
});
|
|
|
|
it('does not schedule disabled jobs on creation', async () => {
|
|
prismaMock.scheduledJob.create.mockResolvedValue({
|
|
id: 'job-6',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: false,
|
|
payload: {},
|
|
});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.createScheduledJob({
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: false,
|
|
});
|
|
|
|
expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not reschedule when updated job stays disabled', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-7',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: false,
|
|
payload: {},
|
|
});
|
|
prismaMock.scheduledJob.update.mockResolvedValue({
|
|
id: 'job-7',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 1 * * *',
|
|
enabled: false,
|
|
payload: {},
|
|
});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.updateScheduledJob('job-7', { schedule: '0 1 * * *', enabled: false });
|
|
|
|
expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled();
|
|
expect(jobQueueMock.addRepeatableJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('unschedules jobs when deleted', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-8',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
prismaMock.scheduledJob.delete.mockResolvedValue({});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.deleteScheduledJob('job-8');
|
|
|
|
expect(jobQueueMock.removeRepeatableJob).toHaveBeenCalledWith(
|
|
'audible_refresh',
|
|
'0 0 * * *',
|
|
'scheduled-job-8'
|
|
);
|
|
expect(prismaMock.scheduledJob.delete).toHaveBeenCalled();
|
|
});
|
|
|
|
it('deletes disabled jobs without unscheduling', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-8b',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '0 0 * * *',
|
|
enabled: false,
|
|
payload: {},
|
|
});
|
|
prismaMock.scheduledJob.delete.mockResolvedValue({});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.deleteScheduledJob('job-8b');
|
|
|
|
expect(jobQueueMock.removeRepeatableJob).not.toHaveBeenCalled();
|
|
expect(prismaMock.scheduledJob.delete).toHaveBeenCalled();
|
|
});
|
|
|
|
it('triggers overdue jobs based on lastRun and schedule', async () => {
|
|
const overdueJob = {
|
|
id: 'job-9',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '*/5 * * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
};
|
|
|
|
prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]);
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockResolvedValue('bull-9');
|
|
|
|
await (service as any).triggerOverdueJobs();
|
|
|
|
expect(triggerSpy).toHaveBeenCalledWith('job-9');
|
|
});
|
|
|
|
it('logs and continues when overdue jobs fail to trigger', async () => {
|
|
const overdueJob = {
|
|
id: 'job-9b',
|
|
name: 'Audible Data Refresh',
|
|
type: 'audible_refresh',
|
|
schedule: '*/5 * * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
lastRun: new Date(Date.now() - 10 * 60 * 1000).toISOString(),
|
|
};
|
|
|
|
prismaMock.scheduledJob.findMany.mockResolvedValueOnce([overdueJob]);
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
const triggerSpy = vi.spyOn(service, 'triggerJobNow').mockRejectedValue(new Error('fail'));
|
|
|
|
await expect((service as any).triggerOverdueJobs()).resolves.toBeUndefined();
|
|
expect(triggerSpy).toHaveBeenCalledWith('job-9b');
|
|
});
|
|
it('identifies overdue jobs when lastRun is missing', async () => {
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
|
|
const overdue = (service as any).isJobOverdue({
|
|
name: 'No last run',
|
|
schedule: '0 * * * *',
|
|
lastRun: null,
|
|
});
|
|
|
|
expect(overdue).toBe(true);
|
|
});
|
|
|
|
it('returns false for unparseable cron intervals', async () => {
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
|
|
const overdue = (service as any).isJobOverdue({
|
|
name: 'Bad cron',
|
|
schedule: 'bad',
|
|
lastRun: new Date().toISOString(),
|
|
});
|
|
|
|
expect(overdue).toBe(false);
|
|
});
|
|
|
|
it('throws when Audiobookshelf scan configuration is missing', async () => {
|
|
prismaMock.scheduledJob.findUnique.mockResolvedValue({
|
|
id: 'job-10',
|
|
name: 'Library Scan',
|
|
type: 'plex_library_scan',
|
|
schedule: '0 */6 * * *',
|
|
enabled: true,
|
|
payload: {},
|
|
});
|
|
configServiceMock.getBackendMode.mockResolvedValue('audiobookshelf');
|
|
configServiceMock.getMany.mockResolvedValue({
|
|
'audiobookshelf.server_url': null,
|
|
'audiobookshelf.api_token': null,
|
|
'audiobookshelf.library_id': null,
|
|
});
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
|
|
await expect(service.triggerJobNow('job-10')).rejects.toThrow('Audiobookshelf is not configured');
|
|
});
|
|
});
|