mirror of
https://github.com/kikootwo/ReadMeABook.git
synced 2026-06-02 20:30:10 +00:00
dd5a5962b4
Show human-friendly per-job descriptions on the Admin Jobs page (JOB_DESCRIPTIONS) and remove the old "About Scheduled Jobs" info box. Add STALE_NAME_REWRITES and renameStaleJobNames() in SchedulerService to automatically rewrite legacy exact-literal job names (e.g. "Plex Library Scan") to neutral defaults on startup; updates are type-gated and use updateMany with exact matches so admin-customized names are not touched. Log successful renames and swallow rename errors so startup remains idempotent. Tests and documentation were updated to reflect the new UI text and to cover rename behavior.
549 lines
18 KiB
TypeScript
549 lines
18 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(),
|
|
addFindMissingEbooksJob: 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(10);
|
|
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'],
|
|
['find_missing_ebooks', 'addFindMissingEbooksJob'],
|
|
['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');
|
|
});
|
|
|
|
it('rewrites stale literal job names with type-gated updateMany on startup', async () => {
|
|
prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'existing' });
|
|
prismaMock.scheduledJob.updateMany.mockResolvedValue({ count: 1 });
|
|
prismaMock.scheduledJob.findMany
|
|
.mockResolvedValueOnce([]) // cleanupDeprecatedJobs
|
|
.mockResolvedValueOnce([]) // scheduleAllJobs
|
|
.mockResolvedValue([]); // triggerOverdueJobs
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
await service.start();
|
|
|
|
const updateManyCalls = prismaMock.scheduledJob.updateMany.mock.calls;
|
|
expect(updateManyCalls).toHaveLength(2);
|
|
|
|
// Type-gating safety: each WHERE must match BOTH name AND type exact-equals.
|
|
expect(updateManyCalls[0][0]).toEqual({
|
|
where: { name: 'Plex Library Scan', type: 'plex_library_scan' },
|
|
data: { name: 'Library Scan' },
|
|
});
|
|
expect(updateManyCalls[1][0]).toEqual({
|
|
where: { name: 'Plex Recently Added Check', type: 'plex_recently_added_check' },
|
|
data: { name: 'Recently Added Check' },
|
|
});
|
|
});
|
|
|
|
it('swallows rename errors and continues startup (idempotent)', async () => {
|
|
prismaMock.scheduledJob.findFirst.mockResolvedValue({ id: 'existing' });
|
|
prismaMock.scheduledJob.updateMany.mockRejectedValue(new Error('db blip'));
|
|
prismaMock.scheduledJob.findMany
|
|
.mockResolvedValueOnce([]) // cleanupDeprecatedJobs
|
|
.mockResolvedValueOnce([]) // scheduleAllJobs
|
|
.mockResolvedValue([]); // triggerOverdueJobs
|
|
|
|
const { SchedulerService } = await import('@/lib/services/scheduler.service');
|
|
const service = new SchedulerService();
|
|
|
|
// Must not throw — rename failure is non-fatal.
|
|
await expect(service.start()).resolves.toBeUndefined();
|
|
});
|
|
});
|