Files
ReadMeABook/tests/services/scheduler.service.test.ts
T
kikootwo 338331d006 Add Hardcover shelf sync & unify book mappings
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.
2026-03-04 10:11:19 -05:00

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