backend-test-utils: add mock implementation for scheduler service
Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/backend-test-utils': minor
|
||||
---
|
||||
|
||||
Switched out `mockServices.scheduler` to use a mocked implementation instead of the default scheduler implementation. This implementation runs any scheduled tasks immediately on startup, as long as they don't have an initial delay or a manual trigger. After the initial run, the tasks are never run again unless manually triggered.
|
||||
@@ -129,3 +129,31 @@ Responds with
|
||||
- `200 OK` if successful
|
||||
- `404 Not Found` if there was no such registered task for this plugin
|
||||
- `409 Conflict` if the task was already in a running state
|
||||
|
||||
## Testing
|
||||
|
||||
The `@backstage/backend-test-utils` package provides `mockServices.scheduler`, which provides a mocked implementation of the scheduler service that can be used in tests. This mocked implementation is used by default in `startTestBackend`, and it will immediately run any registered tasks on startup as long as they're not configured to run manually or with an initial delay.
|
||||
|
||||
A dedicated instance can be used for more control during testing, with the mock implementation providing additional utilities to trigger and wait for tasks to complete:
|
||||
|
||||
```ts
|
||||
it('should trigger a task', async () => {
|
||||
const scheduler = mockServices.scheduler();
|
||||
|
||||
const { server } = await startTestBackend({
|
||||
features: [scheduler.factory()],
|
||||
});
|
||||
|
||||
// Start waiting for some task to complete
|
||||
const waitForTask = scheduler.waitForTask('some-task-id');
|
||||
|
||||
// Call an endpoit that triggers a task
|
||||
const res = await request(server).post(
|
||||
'/api/my-plugin/route-that-triggers-a-task',
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
// Wait for the task to complete
|
||||
await waitForTask;
|
||||
});
|
||||
```
|
||||
|
||||
@@ -366,9 +366,15 @@ export namespace mockServices {
|
||||
) => ServiceMock<RootLoggerService>;
|
||||
}
|
||||
// (undocumented)
|
||||
export function scheduler(): SchedulerService;
|
||||
// (undocumented)
|
||||
export namespace scheduler {
|
||||
const // (undocumented)
|
||||
factory: () => ServiceFactory<SchedulerService, 'plugin', 'singleton'>;
|
||||
factory: (options?: {
|
||||
skipTaskRunOnStartup?: boolean;
|
||||
includeManualTasksOnStartup?: boolean;
|
||||
includeInitialDelayedTasksOnStartup?: boolean;
|
||||
}) => ServiceFactory<SchedulerService, 'plugin', 'singleton'>;
|
||||
const // (undocumented)
|
||||
mock: (
|
||||
partialImpl?: Partial<SchedulerService> | undefined,
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
coreServices,
|
||||
createBackendPlugin,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { startTestBackend } from '../wiring';
|
||||
import { MockSchedulerService } from './MockSchedulerService';
|
||||
import { mockServices } from './mockServices';
|
||||
import { setTimeout } from 'timers/promises';
|
||||
|
||||
const baseOpts = {
|
||||
frequency: { seconds: 10 },
|
||||
timeout: { seconds: 10 },
|
||||
};
|
||||
|
||||
describe('MockSchedulerService', () => {
|
||||
it('should run a task', async () => {
|
||||
const scheduler = new MockSchedulerService();
|
||||
expect(scheduler).toBeDefined();
|
||||
|
||||
const taskFn = jest.fn();
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test',
|
||||
fn: taskFn,
|
||||
});
|
||||
|
||||
expect(taskFn).not.toHaveBeenCalled();
|
||||
|
||||
await scheduler.triggerTask('test');
|
||||
|
||||
expect(taskFn).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should run tasks on startup', async () => {
|
||||
const testFnPlain = jest.fn();
|
||||
const testFnInitialDelay = jest.fn();
|
||||
const testFnManual = jest.fn();
|
||||
const testFnLocal = jest.fn();
|
||||
|
||||
// Relying on the fact that the mock scheduler service is used by default
|
||||
await startTestBackend({
|
||||
features: [
|
||||
createBackendPlugin({
|
||||
pluginId: 'tester',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { scheduler: coreServices.scheduler },
|
||||
async init({ scheduler }) {
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-plain',
|
||||
fn: testFnPlain,
|
||||
});
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-local',
|
||||
scope: 'local',
|
||||
fn: testFnLocal,
|
||||
});
|
||||
|
||||
// Should not run by default
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-with-initial-delay',
|
||||
initialDelay: { seconds: 1 },
|
||||
fn: testFnInitialDelay,
|
||||
});
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-manual',
|
||||
frequency: { trigger: 'manual' },
|
||||
fn: testFnManual,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(testFnPlain).toHaveBeenCalled();
|
||||
expect(testFnLocal).toHaveBeenCalled();
|
||||
expect(testFnInitialDelay).not.toHaveBeenCalled();
|
||||
expect(testFnManual).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not run tasks on startup if skipped', async () => {
|
||||
const testFnPlain = jest.fn();
|
||||
|
||||
await startTestBackend({
|
||||
features: [
|
||||
new MockSchedulerService().factory({ skipTaskRunOnStartup: true }),
|
||||
createBackendPlugin({
|
||||
pluginId: 'tester',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { scheduler: coreServices.scheduler },
|
||||
async init({ scheduler }) {
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-plain',
|
||||
fn: testFnPlain,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(testFnPlain).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should run all tasks on startup if configured', async () => {
|
||||
const testFnPlain = jest.fn();
|
||||
const testFnInitialDelay = jest.fn();
|
||||
const testFnManual = jest.fn();
|
||||
const testFnLocal = jest.fn();
|
||||
|
||||
await startTestBackend({
|
||||
features: [
|
||||
mockServices.scheduler.factory({
|
||||
includeManualTasksOnStartup: true,
|
||||
includeInitialDelayedTasksOnStartup: true,
|
||||
}),
|
||||
createBackendPlugin({
|
||||
pluginId: 'tester',
|
||||
register(reg) {
|
||||
reg.registerInit({
|
||||
deps: { scheduler: coreServices.scheduler },
|
||||
async init({ scheduler }) {
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-plain',
|
||||
fn: testFnPlain,
|
||||
});
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-local',
|
||||
scope: 'local',
|
||||
fn: testFnLocal,
|
||||
});
|
||||
|
||||
// Should not run by default
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-with-initial-delay',
|
||||
initialDelay: { seconds: 1 },
|
||||
fn: testFnInitialDelay,
|
||||
});
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test-manual',
|
||||
frequency: { trigger: 'manual' },
|
||||
fn: testFnManual,
|
||||
});
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(testFnPlain).toHaveBeenCalled();
|
||||
expect(testFnLocal).toHaveBeenCalled();
|
||||
expect(testFnInitialDelay).toHaveBeenCalled();
|
||||
expect(testFnManual).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should wait for a specific task to complete', async () => {
|
||||
const scheduler = new MockSchedulerService();
|
||||
const taskFn = jest.fn();
|
||||
scheduler.scheduleTask({
|
||||
...baseOpts,
|
||||
id: 'test',
|
||||
fn: taskFn,
|
||||
});
|
||||
|
||||
const wait = scheduler.waitForTask('test');
|
||||
|
||||
const isDone = () =>
|
||||
Promise.race([wait.then(() => true), setTimeout(1, false)]);
|
||||
|
||||
expect(taskFn).not.toHaveBeenCalled();
|
||||
await expect(isDone()).resolves.toBe(false);
|
||||
|
||||
await scheduler.triggerTask('test');
|
||||
|
||||
expect(taskFn).toHaveBeenCalled();
|
||||
await expect(isDone()).resolves.toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
/*
|
||||
* Copyright 2025 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
coreServices,
|
||||
createServiceFactory,
|
||||
SchedulerService,
|
||||
SchedulerServiceTaskDescriptor,
|
||||
SchedulerServiceTaskInvocationDefinition,
|
||||
SchedulerServiceTaskRunner,
|
||||
SchedulerServiceTaskScheduleDefinition,
|
||||
} from '@backstage/backend-plugin-api';
|
||||
import { createDeferred, DeferredPromise } from '@backstage/types';
|
||||
|
||||
export class MockSchedulerService implements SchedulerService {
|
||||
readonly #tasks = new Map<
|
||||
string,
|
||||
SchedulerServiceTaskInvocationDefinition &
|
||||
SchedulerServiceTaskScheduleDefinition & {
|
||||
descriptor: SchedulerServiceTaskDescriptor;
|
||||
}
|
||||
>();
|
||||
readonly #runningTasks = new Set<string>();
|
||||
readonly #deferredTaskCompletions = new Map<string, DeferredPromise<void>>();
|
||||
|
||||
/**
|
||||
* Creates a service factory for this mock scheduler instance, which can be installed in a test backend
|
||||
*/
|
||||
factory(options?: {
|
||||
skipTaskRunOnStartup?: boolean;
|
||||
includeManualTasksOnStartup?: boolean;
|
||||
includeInitialDelayedTasksOnStartup?: boolean;
|
||||
}) {
|
||||
return createServiceFactory({
|
||||
service: coreServices.scheduler,
|
||||
deps: { lifecycle: coreServices.lifecycle },
|
||||
factory: async ({ lifecycle }) => {
|
||||
if (!options?.skipTaskRunOnStartup) {
|
||||
lifecycle.addStartupHook(async () => {
|
||||
await this.triggerAllTasks({
|
||||
includeManualTasks: options?.includeManualTasksOnStartup,
|
||||
includeInitialDelayedTasks:
|
||||
options?.includeInitialDelayedTasksOnStartup,
|
||||
});
|
||||
});
|
||||
}
|
||||
return this;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
createScheduledTaskRunner(
|
||||
schedule: SchedulerServiceTaskScheduleDefinition,
|
||||
): SchedulerServiceTaskRunner {
|
||||
return {
|
||||
run: async task => {
|
||||
await this.scheduleTask({ ...task, ...schedule });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getScheduledTasks(): Promise<SchedulerServiceTaskDescriptor[]> {
|
||||
return Array.from(this.#tasks.values()).map(({ descriptor }) => descriptor);
|
||||
}
|
||||
|
||||
async scheduleTask(
|
||||
task: SchedulerServiceTaskScheduleDefinition &
|
||||
SchedulerServiceTaskInvocationDefinition,
|
||||
): Promise<void> {
|
||||
this.#tasks.set(task.id, {
|
||||
...task,
|
||||
descriptor: {
|
||||
id: task.id,
|
||||
scope: task.scope ?? 'global',
|
||||
settings: { version: 1 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async triggerTask(id: string): Promise<void> {
|
||||
const task = this.#tasks.get(id);
|
||||
if (!task) {
|
||||
throw new Error(`Task ${id} not found`);
|
||||
}
|
||||
if (this.#runningTasks.has(id)) {
|
||||
return;
|
||||
}
|
||||
this.#runningTasks.add(id);
|
||||
try {
|
||||
await task.fn(new AbortController().signal);
|
||||
this.#deferredTaskCompletions.get(id)?.resolve();
|
||||
} catch (error) {
|
||||
this.#deferredTaskCompletions.get(id)?.reject(error);
|
||||
} finally {
|
||||
this.#runningTasks.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger all tasks that match the given options, and wait for them to complete.
|
||||
*
|
||||
* @param options - The options to filter the tasks to trigger
|
||||
*/
|
||||
async triggerAllTasks(options?: {
|
||||
scope?: 'all' | 'global' | 'local';
|
||||
includeInitialDelayedTasks?: boolean;
|
||||
includeManualTasks?: boolean;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
scope = 'all',
|
||||
includeManualTasks = false,
|
||||
includeInitialDelayedTasks = false,
|
||||
} = options ?? {};
|
||||
|
||||
const selectedTaskIds = new Array<string>();
|
||||
for (const task of this.#tasks.values()) {
|
||||
if (task.initialDelay && !includeInitialDelayedTasks) {
|
||||
continue;
|
||||
}
|
||||
if ('trigger' in task.frequency && task.frequency.trigger === 'manual') {
|
||||
if (includeManualTasks) {
|
||||
selectedTaskIds.push(task.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (scope === 'all' || scope === task.scope) {
|
||||
selectedTaskIds.push(task.id);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(selectedTaskIds.map(id => this.triggerTask(id)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the task with the given ID to complete.
|
||||
*
|
||||
* If the task has not yet been scheduler or started, this will wait for it to be scheduled, started, and completed
|
||||
*
|
||||
* @param id - The task ID to wait for
|
||||
* @returns A promise that resolves when the task is completed
|
||||
*/
|
||||
async waitForTask(id: string): Promise<void> {
|
||||
const existing = this.#deferredTaskCompletions.get(id);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const defferred = createDeferred<void>();
|
||||
this.#deferredTaskCompletions.set(id, defferred);
|
||||
return defferred;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ import { permissionsRegistryServiceFactory } from '@backstage/backend-defaults/p
|
||||
import { rootHealthServiceFactory } from '@backstage/backend-defaults/rootHealth';
|
||||
import { rootHttpRouterServiceFactory } from '@backstage/backend-defaults/rootHttpRouter';
|
||||
import { rootLifecycleServiceFactory } from '@backstage/backend-defaults/rootLifecycle';
|
||||
import { schedulerServiceFactory } from '@backstage/backend-defaults/scheduler';
|
||||
import { urlReaderServiceFactory } from '@backstage/backend-defaults/urlReader';
|
||||
import {
|
||||
AuthService,
|
||||
@@ -57,6 +56,7 @@ import { mockCredentials } from './mockCredentials';
|
||||
import { MockEventsService } from './MockEventsService';
|
||||
import { MockPermissionsService } from './MockPermissionsService';
|
||||
import { simpleMock } from './simpleMock';
|
||||
import { MockSchedulerService } from './MockSchedulerService';
|
||||
|
||||
/** @internal */
|
||||
function createLoggerMock() {
|
||||
@@ -496,8 +496,15 @@ export namespace mockServices {
|
||||
}));
|
||||
}
|
||||
|
||||
export function scheduler(): MockSchedulerService {
|
||||
return new MockSchedulerService();
|
||||
}
|
||||
export namespace scheduler {
|
||||
export const factory = () => schedulerServiceFactory;
|
||||
export const factory = (options?: {
|
||||
skipTaskRunOnStartup?: boolean;
|
||||
includeManualTasksOnStartup?: boolean;
|
||||
includeInitialDelayedTasksOnStartup?: boolean;
|
||||
}) => new MockSchedulerService().factory(options);
|
||||
export const mock = simpleMock(coreServices.scheduler, () => ({
|
||||
createScheduledTaskRunner: jest.fn(),
|
||||
getScheduledTasks: jest.fn(),
|
||||
|
||||
Reference in New Issue
Block a user