diff --git a/packages/backend-test-utils/api-report.md b/packages/backend-test-utils/api-report.md index 4a5d93390f..41928af40b 100644 --- a/packages/backend-test-utils/api-report.md +++ b/packages/backend-test-utils/api-report.md @@ -32,6 +32,19 @@ import { UrlReaderService } from '@backstage/backend-plugin-api'; // @public (undocumented) export function isDockerDisabledForTests(): boolean; +// @public (undocumented) +export function makeServiceFactoryTester< + TService, + TScope extends 'root' | 'plugin', +>( + subject: + | ServiceFactory + | (() => ServiceFactory), + dependencies?: Array ServiceFactory)>, +): 'root' extends TScope + ? () => Promise + : (pluginId: string) => Promise; + // @public (undocumented) export namespace mockServices { // (undocumented) diff --git a/packages/backend-test-utils/src/next/wiring/TestBackend.ts b/packages/backend-test-utils/src/next/wiring/TestBackend.ts index 97da3543ae..0b92ec3353 100644 --- a/packages/backend-test-utils/src/next/wiring/TestBackend.ts +++ b/packages/backend-test-utils/src/next/wiring/TestBackend.ts @@ -61,7 +61,7 @@ export interface TestBackend extends Backend { readonly server: ExtendedHttpServer; } -const defaultServiceFactories = [ +export const defaultServiceFactories = [ mockServices.cache.factory(), mockServices.rootConfig.factory(), mockServices.database.factory(), diff --git a/packages/backend-test-utils/src/next/wiring/index.ts b/packages/backend-test-utils/src/next/wiring/index.ts index 7c39474d4c..451a7aaece 100644 --- a/packages/backend-test-utils/src/next/wiring/index.ts +++ b/packages/backend-test-utils/src/next/wiring/index.ts @@ -14,5 +14,6 @@ * limitations under the License. */ +export { makeServiceFactoryTester } from './makeServiceFactoryTester'; export { startTestBackend } from './TestBackend'; export type { TestBackend, TestBackendOptions } from './TestBackend'; diff --git a/packages/backend-test-utils/src/next/wiring/makeServiceFactoryTester.test.ts b/packages/backend-test-utils/src/next/wiring/makeServiceFactoryTester.test.ts new file mode 100644 index 0000000000..b30700cdec --- /dev/null +++ b/packages/backend-test-utils/src/next/wiring/makeServiceFactoryTester.test.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2023 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, + createServiceRef, +} from '@backstage/backend-plugin-api'; +import { makeServiceFactoryTester } from './makeServiceFactoryTester'; + +const rootServiceRef = createServiceRef({ id: 'a', scope: 'root' }); +const pluginServiceRef = createServiceRef({ id: 'b', scope: 'plugin' }); +const sharedPluginServiceRef = createServiceRef({ + id: 'c', + scope: 'plugin', +}); + +const rootFactory = createServiceFactory({ + service: rootServiceRef, + deps: {}, + factory: async () => 'root', +}); + +const pluginFactory = createServiceFactory({ + service: pluginServiceRef, + deps: { plugin: coreServices.pluginMetadata }, + factory: async ({ plugin }) => `${plugin.getId()}-plugin`, +}); + +const sharedPluginFactory = createServiceFactory({ + service: sharedPluginServiceRef, + deps: { plugin: coreServices.pluginMetadata }, + createRootContext() { + return { counter: 0 }; + }, + factory: async ({ plugin }, state) => { + state.counter += 1; + return `${plugin.getId()}-${state.counter}-plugin`; + }, +}); + +describe('makeServiceFactoryTester', () => { + it('should test a root service factory', async () => { + const factoryTester = makeServiceFactoryTester(rootFactory()); + + await expect(factoryTester()).resolves.toBe('root'); + }); + + it('should test a plugin service factory', async () => { + const factoryTester = makeServiceFactoryTester(pluginFactory()); + + await expect(factoryTester('x')).resolves.toBe('x-plugin'); + await expect(factoryTester('y')).resolves.toBe('y-plugin'); + await expect(factoryTester('z')).resolves.toBe('z-plugin'); + }); + + it('should test a plugin service factory with root context', async () => { + const factoryTester = makeServiceFactoryTester(sharedPluginFactory()); + + await expect(factoryTester('x')).resolves.toBe('x-1-plugin'); + await expect(factoryTester('y')).resolves.toBe('y-2-plugin'); + await expect(factoryTester('y')).resolves.toBe('y-2-plugin'); + await expect(factoryTester('y')).resolves.toBe('y-2-plugin'); + await expect(factoryTester('z')).resolves.toBe('z-3-plugin'); + + const tester2 = makeServiceFactoryTester(sharedPluginFactory()); + + await expect(tester2('z')).resolves.toBe('z-1-plugin'); + await expect(tester2('y')).resolves.toBe('y-2-plugin'); + await expect(tester2('x')).resolves.toBe('x-3-plugin'); + await expect(tester2('x')).resolves.toBe('x-3-plugin'); + await expect(tester2('y')).resolves.toBe('y-2-plugin'); + await expect(tester2('z')).resolves.toBe('z-1-plugin'); + }); + + it('should use dependencies', async () => { + const factoryTester = makeServiceFactoryTester( + createServiceFactory({ + service: createServiceRef({ id: 'concat' }), + deps: { root: rootServiceRef, plugin: pluginServiceRef }, + factory: async ({ root, plugin }) => `${root}, ${plugin}`, + }), + [rootFactory, pluginFactory()], + ); + + await expect(factoryTester('x')).resolves.toBe('root, x-plugin'); + }); + + it('should use dependencies with root context', async () => { + const factoryTester = makeServiceFactoryTester( + createServiceFactory({ + service: createServiceRef({ id: 'concat' }), + deps: { shared: sharedPluginServiceRef, plugin: pluginServiceRef }, + factory: async ({ shared, plugin }) => `${shared}, ${plugin}`, + }), + [sharedPluginFactory(), pluginFactory], + ); + + await expect(factoryTester('x')).resolves.toBe('x-1-plugin, x-plugin'); + await expect(factoryTester('y')).resolves.toBe('y-2-plugin, y-plugin'); + await expect(factoryTester('y')).resolves.toBe('y-2-plugin, y-plugin'); + await expect(factoryTester('y')).resolves.toBe('y-2-plugin, y-plugin'); + await expect(factoryTester('z')).resolves.toBe('z-3-plugin, z-plugin'); + }); + + it('should prioritize the subject implementation', async () => { + const factoryTester = makeServiceFactoryTester( + createServiceFactory({ + service: rootServiceRef, + deps: {}, + factory: async () => 'other-root', + }), + [rootFactory], + ); + + await expect(factoryTester()).resolves.toBe('other-root'); + }); + + it('should throw on missing dependencies', async () => { + const factoryTester = makeServiceFactoryTester( + createServiceFactory({ + service: pluginServiceRef, + deps: { root: rootServiceRef }, + factory: async () => 'plugin', + }), + ); + + await expect(factoryTester('x')).rejects.toThrow( + "Failed to instantiate service 'b' for 'x' because the following dependent services are missing: 'a'", + ); + }); +}); diff --git a/packages/backend-test-utils/src/next/wiring/makeServiceFactoryTester.ts b/packages/backend-test-utils/src/next/wiring/makeServiceFactoryTester.ts new file mode 100644 index 0000000000..5bc1aae775 --- /dev/null +++ b/packages/backend-test-utils/src/next/wiring/makeServiceFactoryTester.ts @@ -0,0 +1,49 @@ +/* + * Copyright 2023 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 { ServiceFactory } from '@backstage/backend-plugin-api'; +import { defaultServiceFactories } from './TestBackend'; +// Direct internal import to avoid duplication. +// This is a relative import in order to make sure that the implementation is duplicated +// rather than leading to an import from @backstage/backend-app-api. +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { ServiceRegistry } from '../../../../backend-app-api/src/wiring/ServiceRegistry'; + +/** @public */ +export function makeServiceFactoryTester< + TService, + TScope extends 'root' | 'plugin', +>( + subject: + | ServiceFactory + | (() => ServiceFactory), + dependencies?: Array ServiceFactory)>, +): 'root' extends TScope + ? () => Promise + : (pluginId: string) => Promise { + const subjectFactory = typeof subject === 'function' ? subject() : subject; + const services = [ + ...defaultServiceFactories, + ...(dependencies?.map(f => (typeof f === 'function' ? f() : f)) ?? []), + subjectFactory, + ]; + + const registry = new ServiceRegistry(services); + + return async (pluginId?: string) => { + return registry.get(subjectFactory.service, pluginId ?? '')! as TService; + }; +}