From 0455f3f91bd9344d65526d842845940925586cf8 Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Fri, 12 Jun 2026 08:41:01 -0400 Subject: [PATCH 1/2] test(3scale): add module wiring, contract tests, and contributor guide Assisted-by: Cursor AI Signed-off-by: Patrick Knight --- .../3scale/.changeset/pink-parrots-rush.md | 5 + .../plugins/3scale-backend/CONTRIBUTING.md | 78 ++++++ .../3scale/plugins/3scale-backend/README.md | 2 + .../clients/ThreeScaleAPIConnector.test.ts | 136 +++++++++++ .../plugins/3scale-backend/src/module.test.ts | 181 ++++++++++++++ .../ThreeScaleApiEntityProvider.test.ts | 229 ++++++++++++++---- .../src/providers/config.test.ts | 93 +++++++ .../open-api-merger-converter.test.ts | 82 +++++++ 8 files changed, 759 insertions(+), 47 deletions(-) create mode 100644 workspaces/3scale/.changeset/pink-parrots-rush.md create mode 100644 workspaces/3scale/plugins/3scale-backend/CONTRIBUTING.md create mode 100644 workspaces/3scale/plugins/3scale-backend/src/clients/ThreeScaleAPIConnector.test.ts create mode 100644 workspaces/3scale/plugins/3scale-backend/src/module.test.ts create mode 100644 workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts create mode 100644 workspaces/3scale/plugins/3scale-backend/src/providers/open-api-merger-converter.test.ts diff --git a/workspaces/3scale/.changeset/pink-parrots-rush.md b/workspaces/3scale/.changeset/pink-parrots-rush.md new file mode 100644 index 00000000000..67b7ea8e790 --- /dev/null +++ b/workspaces/3scale/.changeset/pink-parrots-rush.md @@ -0,0 +1,5 @@ +--- +'@backstage-community/plugin-3scale-backend': patch +--- + +Added module wiring and contract tests, and a contributor guide for local development. diff --git a/workspaces/3scale/plugins/3scale-backend/CONTRIBUTING.md b/workspaces/3scale/plugins/3scale-backend/CONTRIBUTING.md new file mode 100644 index 00000000000..8bddb6115c7 --- /dev/null +++ b/workspaces/3scale/plugins/3scale-backend/CONTRIBUTING.md @@ -0,0 +1,78 @@ +# Contributing to `@backstage-community/plugin-3scale-backend` + +This guide is for **contributors and maintainers** working on the 3scale catalog entity provider. For operator install and production configuration, see [README.md](./README.md). + +## Prerequisites + +- Node.js **22 or 24** (see `engines` in the workspace `package.json`) +- Yarn (monorepo package manager) +- Clone [backstage/community-plugins](https://github.com/backstage/community-plugins) and work from `workspaces/3scale` + +## Default development path + +Day-to-day changes do **not** require the full workspace Backstage app (`packages/app`, `packages/backend`). Use the plugin `dev/` harness: + +```console +cd workspaces/3scale +yarn install +yarn workspace @backstage-community/plugin-3scale-backend start +``` + +The harness starts a minimal backend with `@backstage/plugin-catalog-backend` and this module (`dev/index.ts`). + +### Configuration + +Set environment variables before starting (see [app-config.example.yaml](./app-config.example.yaml)): + +| Variable | Purpose | +| ------------------------- | -------------------------------------------------------------------- | +| `THREESCALE_BASE_URL` | 3scale Admin API base URL (e.g. `https://-admin.3scale.net`) | +| `THREESCALE_ACCESS_TOKEN` | Admin API access token | + +Do not commit real credentials. Use placeholders in tests and local-only env injection. + +The workspace root `app-config.yaml` supplies backend infrastructure (database, listen port) when running from the monorepo. Provider-specific keys are documented in `app-config.example.yaml`. + +## Validation commands + +From `workspaces/3scale`: + +```console +yarn workspace @backstage-community/plugin-3scale-backend test +yarn workspace @backstage-community/plugin-3scale-backend lint +yarn tsc +``` + +All automated tests run under the package `test` script—no extra CI pipeline configuration. + +When reviewing dependency updates, read the relevant upstream release notes and changelogs, then run the commands above and any additional manual checks appropriate to what changed. + +## Manual smoke checklist + +After `yarn workspace @backstage-community/plugin-3scale-backend start` with valid `THREESCALE_*` env vars, expect log lines similar to: + +```log +catalog info Discovering ApiEntities from 3scale type=plugin target=ThreeScaleApiEntityProvider:dev +catalog info Discovered ApiEntity type=plugin target=ThreeScaleApiEntityProvider:dev +catalog info Applying the mutation with entities type=plugin target=ThreeScaleApiEntityProvider:dev +``` + +You can also inspect ingested APIs via the catalog backend API (backend-only, no UI required): + +```console +curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=API' | jq . +``` + +## When to use the full workspace app + +The workspace includes `packages/app` and `packages/backend` as an **optional** integration harness. Examples of when it may be useful: + +- Catalog **UI** validation (API definition cards, Swagger rendering) +- Workspace Playwright smoke (`packages/app/e2e-tests`)—currently a generic welcome-page check, not 3scale-specific +- Manual validation that mirrors a full Backstage deployment + +Do not add a second full Backstage application to this workspace. The plugin `dev/` harness is the documented default for backend work here. + +## Workspace app removal note + +The plugin `dev/` harness supports backend development and manual smoke described above. `packages/app` and `packages/backend` may be retired in a future cleanup without blocking contributor workflows, provided this guide remains the documented default path. diff --git a/workspaces/3scale/plugins/3scale-backend/README.md b/workspaces/3scale/plugins/3scale-backend/README.md index 7a940a7c4f3..db54eebbb0e 100644 --- a/workspaces/3scale/plugins/3scale-backend/README.md +++ b/workspaces/3scale/plugins/3scale-backend/README.md @@ -2,6 +2,8 @@ The 3scale Backstage provider plugin synchronizes the 3scale content into the [Backstage](https://backstage.io/) catalog. +For local development and contributor workflows, see [CONTRIBUTING.md](./CONTRIBUTING.md). + ## For administrators ### Installation diff --git a/workspaces/3scale/plugins/3scale-backend/src/clients/ThreeScaleAPIConnector.test.ts b/workspaces/3scale/plugins/3scale-backend/src/clients/ThreeScaleAPIConnector.test.ts new file mode 100644 index 00000000000..9a036b2e64a --- /dev/null +++ b/workspaces/3scale/plugins/3scale-backend/src/clients/ThreeScaleAPIConnector.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright 2024 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 { + getProxyConfig, + listApiDocs, + listServices, +} from './ThreeScaleAPIConnector'; +import type { APIDocs, Proxy, Services } from './types'; + +describe('ThreeScaleAPIConnector', () => { + const baseUrl = 'https://example-admin.3scale.net'; + const accessToken = 'test-token'; + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('listServices', () => { + it('returns parsed JSON for a successful response', async () => { + const services: Services = { services: [] }; + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => services, + } as Response); + + await expect(listServices(baseUrl, accessToken, 2, 100)).resolves.toEqual( + services, + ); + + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain('/admin/api/services.json'); + expect(calledUrl).toContain(`access_token=${accessToken}`); + expect(calledUrl).toContain('page=2'); + expect(calledUrl).toContain('size=100'); + }); + + it('throws with statusText for a non-OK response', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({}), + } as Response); + + await expect(listServices(baseUrl, accessToken, 0, 500)).rejects.toThrow( + 'Unauthorized', + ); + }); + }); + + describe('listApiDocs', () => { + it('returns parsed JSON for a successful response', async () => { + const apiDocs: APIDocs = { api_docs: [] }; + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => apiDocs, + } as Response); + + await expect(listApiDocs(baseUrl, accessToken)).resolves.toEqual(apiDocs); + + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain('/admin/api/active_docs.json'); + expect(calledUrl).toContain(`access_token=${accessToken}`); + }); + + it('throws with statusText for a non-OK response', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({}), + } as Response); + + await expect(listApiDocs(baseUrl, accessToken)).rejects.toThrow( + 'Unauthorized', + ); + }); + }); + + describe('getProxyConfig', () => { + it('returns parsed JSON for a successful response', async () => { + const proxy: Proxy = { + proxy: { + service_id: 2, + endpoint: 'https://production.example.com', + sandbox_endpoint: 'https://staging.example.com', + } as Proxy['proxy'], + }; + const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => proxy, + } as Response); + + await expect(getProxyConfig(baseUrl, accessToken, 2)).resolves.toEqual( + proxy, + ); + + const calledUrl = fetchMock.mock.calls[0][0] as string; + expect(calledUrl).toContain('/admin/api/services/2/proxy.json'); + expect(calledUrl).toContain(`access_token=${accessToken}`); + }); + + it('throws with statusText for a non-OK response', async () => { + jest.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + status: 401, + statusText: 'Unauthorized', + json: async () => ({}), + } as Response); + + await expect(getProxyConfig(baseUrl, accessToken, 2)).rejects.toThrow( + 'Unauthorized', + ); + }); + }); +}); diff --git a/workspaces/3scale/plugins/3scale-backend/src/module.test.ts b/workspaces/3scale/plugins/3scale-backend/src/module.test.ts new file mode 100644 index 00000000000..a750b1fcc75 --- /dev/null +++ b/workspaces/3scale/plugins/3scale-backend/src/module.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright 2024 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 type { SchedulerServiceTaskScheduleDefinition } from '@backstage/backend-plugin-api'; +import { mockServices, startTestBackend } from '@backstage/backend-test-utils'; +import type { EntityProvider } from '@backstage/plugin-catalog-node'; +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; + +import { catalogModule3ScaleEntityProvider } from './module'; +import { ThreeScaleApiEntityProvider } from './providers'; + +const PROVIDER_CONFIG = { + catalog: { + providers: { + threeScaleApiEntity: { + dev: { + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + }, + }, + }, + }, +} as const; + +describe('catalogModule3ScaleEntityProvider', () => { + let addedProviders: EntityProvider[] | EntityProvider[][] | undefined; + + const extensionPoint = { + addEntityProvider: ( + ...providers: EntityProvider[] | EntityProvider[][] + ) => { + addedProviders = providers; + }, + }; + + it('registers an empty provider array when threeScaleApiEntity config is absent', async () => { + await startTestBackend({ + extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]], + features: [ + catalogModule3ScaleEntityProvider, + mockServices.rootConfig.factory({ data: {} }), + ], + }); + + expect((addedProviders as EntityProvider[][]).length).toEqual(1); + expect((addedProviders as EntityProvider[][])[0]).toEqual([]); + }); + + it('registers a provider with the expected name when config is present', async () => { + await startTestBackend({ + extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]], + features: [ + catalogModule3ScaleEntityProvider, + mockServices.rootConfig.factory({ data: PROVIDER_CONFIG }), + ], + }); + + const providers = (addedProviders as EntityProvider[][])[0]; + expect(providers).toHaveLength(1); + expect(providers[0]).toBeInstanceOf(ThreeScaleApiEntityProvider); + expect(providers[0].getProviderName()).toEqual( + 'ThreeScaleApiEntityProvider:dev', + ); + }); + + it('uses the module default schedule when per-provider schedule is absent', async () => { + const usedSchedules: SchedulerServiceTaskScheduleDefinition[] = []; + const runner = jest.fn(); + const scheduler = mockServices.scheduler.mock({ + createScheduledTaskRunner(schedule) { + usedSchedules.push(schedule); + return { run: runner }; + }, + }); + + await startTestBackend({ + extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]], + features: [ + catalogModule3ScaleEntityProvider, + mockServices.rootConfig.factory({ data: PROVIDER_CONFIG }), + scheduler.factory, + ], + }); + + expect(usedSchedules).toHaveLength(1); + expect(usedSchedules[0].frequency).toEqual({ minutes: 30 }); + expect(usedSchedules[0].timeout).toEqual({ minutes: 3 }); + expect(runner).not.toHaveBeenCalled(); + }); + + it('uses per-provider schedule when configured', async () => { + const usedSchedules: SchedulerServiceTaskScheduleDefinition[] = []; + const runner = jest.fn(); + const scheduler = mockServices.scheduler.mock({ + createScheduledTaskRunner(schedule) { + usedSchedules.push(schedule); + return { run: runner }; + }, + }); + + await startTestBackend({ + extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]], + features: [ + catalogModule3ScaleEntityProvider, + mockServices.rootConfig.factory({ + data: { + catalog: { + providers: { + threeScaleApiEntity: { + dev: { + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + schedule: { + frequency: 'P1M', + timeout: 'PT5M', + }, + }, + }, + }, + }, + }, + }), + scheduler.factory, + ], + }); + + expect(usedSchedules).toHaveLength(2); + expect(usedSchedules[0].frequency).toEqual({ minutes: 30 }); + expect(usedSchedules[0].timeout).toEqual({ minutes: 3 }); + expect(usedSchedules[1].frequency).toEqual({ months: 1 }); + expect(usedSchedules[1].timeout).toEqual({ minutes: 5 }); + expect(runner).not.toHaveBeenCalled(); + }); + + it('registers multiple providers from configuration', async () => { + await startTestBackend({ + extensionPoints: [[catalogProcessingExtensionPoint, extensionPoint]], + features: [ + catalogModule3ScaleEntityProvider, + mockServices.rootConfig.factory({ + data: { + catalog: { + providers: { + threeScaleApiEntity: { + dev: { + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + }, + production: { + baseUrl: 'https://production-admin.3scale.net', + accessToken: 'test-token', + }, + }, + }, + }, + }, + }), + ], + }); + + const providers = (addedProviders as EntityProvider[][])[0]; + expect(providers).toHaveLength(2); + expect(providers.map(p => p.getProviderName())).toEqual([ + 'ThreeScaleApiEntityProvider:dev', + 'ThreeScaleApiEntityProvider:production', + ]); + }); +}); diff --git a/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts b/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts index 256ef8ed799..3e79cb4841a 100644 --- a/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts +++ b/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts @@ -21,20 +21,36 @@ import { SchedulerService, SchedulerServiceTaskRunner, } from '@backstage/backend-plugin-api'; +import { InputError } from '@backstage/errors'; import { resolve } from 'path'; import fs from 'fs'; -const requestJsonDataMock = jest.fn().mockResolvedValue([]); +import type { APIDocElement } from '../clients/types'; -global.fetch = jest.fn(() => - Promise.resolve({ - ok: true, - status: 200, - json: () => Promise.resolve(requestJsonDataMock()), - headers: new Headers(), - text: () => Promise.resolve('mocked text data'), - } as Response), -); +type DeferredEntity = { + entity: { + kind: string; + apiVersion: string; + metadata: Record; + spec: Record; + }; + locationKey: string; +}; + +function mockFetchResponses(...responses: unknown[]): jest.SpyInstance { + const queue = [...responses]; + return jest.spyOn(global, 'fetch').mockImplementation(() => { + const data = queue.shift(); + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: () => Promise.resolve(data), + headers: new Headers(), + text: () => Promise.resolve(''), + } as Response); + }); +} const loggerMock = mockServices.logger.mock(); const schedulerMock = mockServices.scheduler.mock(); @@ -77,18 +93,121 @@ describe('ThreeScaleApiEntityProvider', () => { ); } - it('should be defined', () => { - const threeScaleApiEntityProvider = createApiEntityProvider( + it('returns a provider with the expected name from config', () => { + const providers = createApiEntityProvider( schedulerTaskRunnerMock, schedulerMock, ); - expect(threeScaleApiEntityProvider).toBeDefined(); - expect(threeScaleApiEntityProvider).toBeInstanceOf(Array); - expect(threeScaleApiEntityProvider.length).toBe(1); + expect(providers).toHaveLength(1); + expect(providers[0].getProviderName()).toEqual( + 'ThreeScaleApiEntityProvider:test', + ); + }); + + describe('fromConfig', () => { + it('throws when neither schedule nor scheduler is provided', () => { + expect(() => + ThreeScaleApiEntityProvider.fromConfig( + { config: conf, logger: loggerMock }, + {} as unknown as Parameters< + typeof ThreeScaleApiEntityProvider.fromConfig + >[1], + ), + ).toThrow('Either schedule or scheduler must be provided.'); + }); + + it('uses scheduler and per-provider schedule when both are configured', () => { + const config = new ConfigReader({ + catalog: { + providers: { + threeScaleApiEntity: { + test: { + baseUrl: 'test', + accessToken: 'test', + schedule: { + frequency: { hours: 1 }, + timeout: { minutes: 10 }, + }, + }, + }, + }, + }, + }); + const scheduler = mockServices.scheduler.mock(); + + ThreeScaleApiEntityProvider.fromConfig({ config, logger: loggerMock }, { + scheduler, + } as unknown as Parameters< + typeof ThreeScaleApiEntityProvider.fromConfig + >[1]); + + expect(scheduler.createScheduledTaskRunner).toHaveBeenCalledWith({ + frequency: { hours: 1 }, + timeout: { minutes: 10 }, + }); + }); + + it('uses an injected task runner when schedule is provided without scheduler', () => { + const config = new ConfigReader({ + catalog: { + providers: { + threeScaleApiEntity: { + test: { + baseUrl: 'test', + accessToken: 'test', + }, + }, + }, + }, + }); + const taskRunner = { run: jest.fn() }; + + const providers = ThreeScaleApiEntityProvider.fromConfig( + { config, logger: loggerMock }, + { schedule: taskRunner } as unknown as Parameters< + typeof ThreeScaleApiEntityProvider.fromConfig + >[1], + ); + + expect(providers).toHaveLength(1); + expect(providers[0].getProviderName()).toEqual( + 'ThreeScaleApiEntityProvider:test', + ); + }); + + it('throws InputError when scheduler is provided without per-provider or injected schedule', () => { + const config = new ConfigReader({ + catalog: { + providers: { + threeScaleApiEntity: { + test: { + baseUrl: 'test', + accessToken: 'test', + }, + }, + }, + }, + }); + const scheduler = mockServices.scheduler.mock(); + + expect(() => + ThreeScaleApiEntityProvider.fromConfig({ config, logger: loggerMock }, { + scheduler, + } as unknown as Parameters< + typeof ThreeScaleApiEntityProvider.fromConfig + >[1]), + ).toThrow( + new InputError( + 'No schedule provided via config for ThreeScaleApiEntityProvider:test.', + ), + ); + }); }); describe('run', () => { let threeScaleApiEntityProvider: ThreeScaleApiEntityProvider; + let fetchMock: jest.SpyInstance; + beforeEach(async () => { entityProviderConnection.applyMutation.mockClear(); threeScaleApiEntityProvider = createApiEntityProvider( @@ -98,10 +217,12 @@ describe('ThreeScaleApiEntityProvider', () => { await threeScaleApiEntityProvider.connect(entityProviderConnection); }); - it('should be created catalog entity with single open API 3.0 doc', async () => { - const services = readTestJSONFile('services'); - requestJsonDataMock.mockResolvedValueOnce(services); + afterEach(() => { + fetchMock?.mockRestore(); + }); + it('calls the expected 3scale Admin API endpoints', async () => { + const services = readTestJSONFile('services'); const openAPI3_0Spec = readTestJSONFile('input/open-api-3.0-doc'); const apiDoc = createAPIDoc( 'ping', @@ -110,10 +231,36 @@ describe('ThreeScaleApiEntityProvider', () => { openAPI3_0Spec, ); const apiDocs = { api_docs: [apiDoc] }; - requestJsonDataMock.mockResolvedValueOnce(apiDocs); + const proxy = readTestJSONFile('proxy'); + + fetchMock = mockFetchResponses(services, apiDocs, proxy); + + await threeScaleApiEntityProvider.run(); + + const calledUrls = fetchMock.mock.calls.map(call => String(call[0])); + expect(calledUrls[0]).toContain('/admin/api/services.json'); + expect(calledUrls[0]).toContain('access_token=test'); + expect(calledUrls[0]).toContain('page=0'); + expect(calledUrls[0]).toContain('size=500'); + expect(calledUrls[1]).toContain('/admin/api/active_docs.json'); + expect(calledUrls[1]).toContain('access_token=test'); + expect(calledUrls[2]).toContain('/admin/api/services/2/proxy.json'); + expect(calledUrls[2]).toContain('access_token=test'); + }); + it('should be created catalog entity with single open API 3.0 doc', async () => { + const services = readTestJSONFile('services'); + const openAPI3_0Spec = readTestJSONFile('input/open-api-3.0-doc'); + const apiDoc = createAPIDoc( + 'ping', + 'ping', + 'A simple API that responds with the input message.', + openAPI3_0Spec, + ); + const apiDocs = { api_docs: [apiDoc] }; const proxy = readTestJSONFile('proxy'); - requestJsonDataMock.mockResolvedValueOnce(proxy); + + fetchMock = mockFetchResponses(services, apiDocs, proxy); await threeScaleApiEntityProvider.run(); @@ -131,8 +278,6 @@ describe('ThreeScaleApiEntityProvider', () => { it('should be created catalog entity with single api doc but swagger 2.0 should not be converted to API 3.0', async () => { const services = readTestJSONFile('services'); - requestJsonDataMock.mockResolvedValueOnce(services); - const swagger2_0Spec = readTestJSONFile('input/swagger-2.0-doc'); const apiDoc = createAPIDoc( 'list-users', @@ -141,10 +286,9 @@ describe('ThreeScaleApiEntityProvider', () => { swagger2_0Spec, ); const apiDocs = { api_docs: [apiDoc] }; - requestJsonDataMock.mockResolvedValueOnce(apiDocs); - const proxy = readTestJSONFile('proxy'); - requestJsonDataMock.mockResolvedValueOnce(proxy); + + fetchMock = mockFetchResponses(services, apiDocs, proxy); await threeScaleApiEntityProvider.run(); @@ -159,7 +303,6 @@ describe('ThreeScaleApiEntityProvider', () => { it('should be created catalog entity with single api doc but converted from swagger 1.2 to swagger 2.0', async () => { const services = readTestJSONFile('services'); - requestJsonDataMock.mockResolvedValueOnce(services); const swagger1_2Spec = readTestJSONFile('input/swagger-1.2-doc'); const apiDoc = createAPIDoc( 'get-user-profile-by-id', @@ -168,9 +311,9 @@ describe('ThreeScaleApiEntityProvider', () => { swagger1_2Spec, ); const apiDocs = { api_docs: [apiDoc] }; - requestJsonDataMock.mockResolvedValueOnce(apiDocs); const proxy = readTestJSONFile('proxy'); - requestJsonDataMock.mockResolvedValueOnce(proxy); + + fetchMock = mockFetchResponses(services, apiDocs, proxy); await threeScaleApiEntityProvider.run(); @@ -188,8 +331,6 @@ describe('ThreeScaleApiEntityProvider', () => { it('should be created catalog entity with merged 2 open API 3.0 docs', async () => { const services = readTestJSONFile('services'); - requestJsonDataMock.mockResolvedValueOnce(services); - const openAPI3_0Spec1 = readTestJSONFile('input/open-api-3.0-doc'); const apiDoc1 = createAPIDoc( 'ping', @@ -206,11 +347,9 @@ describe('ThreeScaleApiEntityProvider', () => { ); const apiDocs = { api_docs: [apiDoc1, apiDoc2] }; - - requestJsonDataMock.mockResolvedValueOnce(apiDocs); - const proxy = readTestJSONFile('proxy'); - requestJsonDataMock.mockResolvedValueOnce(proxy); + + fetchMock = mockFetchResponses(services, apiDocs, proxy); await threeScaleApiEntityProvider.run(); @@ -228,8 +367,6 @@ describe('ThreeScaleApiEntityProvider', () => { it('should be created catalog entity with merged 3 api docs in different formats', async () => { const services = readTestJSONFile('services'); - requestJsonDataMock.mockResolvedValueOnce(services); - const openAPI3_0Spec1 = readTestJSONFile('input/open-api-3.0-doc'); const apiDoc1 = createAPIDoc( 'ping', @@ -253,11 +390,9 @@ describe('ThreeScaleApiEntityProvider', () => { ); const apiDocs = { api_docs: [apiDoc1, apiDoc2, apiDoc3] }; - - requestJsonDataMock.mockResolvedValueOnce(apiDocs); - const proxy = readTestJSONFile('proxy'); - requestJsonDataMock.mockResolvedValueOnce(proxy); + + fetchMock = mockFetchResponses(services, apiDocs, proxy); await threeScaleApiEntityProvider.run(); @@ -275,16 +410,16 @@ describe('ThreeScaleApiEntityProvider', () => { }); }); -function readTestJSONFile(fileName: string): any { +function readTestJSONFile(fileName: string): T { const file = resolve(__dirname, `./../__fixtures__/data/${fileName}.json`); const fileContent = fs.readFileSync(file, 'utf8'); - return JSON.parse(fileContent); + return JSON.parse(fileContent) as T; } function createExpectedEntity( fileWithExpectedOpenAPISpec: string, description: string, -): any { +): DeferredEntity { return { entity: { kind: 'API', @@ -332,8 +467,8 @@ function createAPIDoc( systemName: string, name: string, description: string, - apiDocBody: any, -) { + apiDocBody: unknown, +): APIDocElement { return { api_doc: { id: 1, @@ -344,8 +479,8 @@ function createAPIDoc( skip_swagger_validations: false, body: JSON.stringify(apiDocBody), service_id: 2, - created_at: '2024-09-17T10:09:04Z', - updated_at: '2024-09-17T10:09:04Z', + created_at: new Date('2024-09-17T10:09:04Z'), + updated_at: new Date('2024-09-17T10:09:04Z'), }, }; } diff --git a/workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts b/workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts new file mode 100644 index 00000000000..ece77c4b7db --- /dev/null +++ b/workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2024 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 { mockServices } from '@backstage/backend-test-utils'; +import { ConfigReader } from '@backstage/config'; + +import { readThreeScaleApiEntityConfigs } from './config'; + +describe('readThreeScaleApiEntityConfigs', () => { + it('returns an empty array when the provider section is absent', () => { + const config = mockServices.rootConfig({ data: {} }); + + expect(readThreeScaleApiEntityConfigs(config)).toEqual([]); + }); + + it('returns provider configs with required fields', () => { + const config = new ConfigReader({ + catalog: { + providers: { + threeScaleApiEntity: { + dev: { + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + }, + }, + }, + }, + }); + + expect(readThreeScaleApiEntityConfigs(config)).toEqual([ + { + id: 'dev', + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + systemLabel: undefined, + ownerLabel: undefined, + addLabels: true, + schedule: undefined, + }, + ]); + }); + + it('parses optional fields and schedule configuration', () => { + const config = new ConfigReader({ + catalog: { + providers: { + threeScaleApiEntity: { + dev: { + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + systemLabel: 'custom-system', + ownerLabel: 'custom-owner', + schedule: { + frequency: 'PT1H', + timeout: 'PT5M', + }, + }, + }, + }, + }, + }); + + expect(readThreeScaleApiEntityConfigs(config)).toEqual([ + { + id: 'dev', + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + systemLabel: 'custom-system', + ownerLabel: 'custom-owner', + addLabels: true, + schedule: { + frequency: { hours: 1 }, + timeout: { minutes: 5 }, + initialDelay: undefined, + scope: undefined, + }, + }, + ]); + }); +}); diff --git a/workspaces/3scale/plugins/3scale-backend/src/providers/open-api-merger-converter.test.ts b/workspaces/3scale/plugins/3scale-backend/src/providers/open-api-merger-converter.test.ts new file mode 100644 index 00000000000..1a2d1fdf6c1 --- /dev/null +++ b/workspaces/3scale/plugins/3scale-backend/src/providers/open-api-merger-converter.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright 2024 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 { resolve } from 'path'; +import fs from 'fs'; + +import { + isOpenAPI3_0, + isSwagger1_2, + isSwagger2_0, + OpenAPIMergerAndConverter, +} from './open-api-merger-converter'; + +function readFixture(fileName: string): T { + const file = resolve(__dirname, `./../__fixtures__/data/${fileName}.json`); + const fileContent = fs.readFileSync(file, 'utf8'); + return JSON.parse(fileContent) as T; +} + +describe('open-api-merger-converter', () => { + describe('format detection', () => { + it('detects OpenAPI 3.0 documents', () => { + const doc = readFixture>( + 'input/open-api-3.0-doc', + ); + expect(isOpenAPI3_0(doc)).toBeTruthy(); + expect(isSwagger2_0(doc)).toBeFalsy(); + expect(isSwagger1_2(doc)).toBeFalsy(); + }); + + it('detects Swagger 2.0 documents', () => { + const doc = readFixture>('input/swagger-2.0-doc'); + expect(isSwagger2_0(doc)).toBe(true); + expect(isOpenAPI3_0(doc)).toBeFalsy(); + expect(isSwagger1_2(doc)).toBeFalsy(); + }); + + it('detects Swagger 1.2 documents', () => { + const doc = readFixture>('input/swagger-1.2-doc'); + expect(isSwagger1_2(doc)).toBe(true); + expect(isSwagger2_0(doc)).toBeFalsy(); + expect(isOpenAPI3_0(doc)).toBeFalsy(); + }); + }); + + describe('OpenAPIMergerAndConverter', () => { + const converter = new OpenAPIMergerAndConverter(); + + it('throws for unsupported API document formats', async () => { + await expect( + converter.convertAPIDocToOpenAPI3({ title: 'not-a-spec' }), + ).rejects.toThrow( + 'Unsupported API document. Plugin supports Swagger 1.2, 2.0, 3.0(Open API 3.0)', + ); + }); + + it('converts swagger 1.2 to swagger 2.0', async () => { + const swagger1_2 = readFixture>( + 'input/swagger-1.2-doc', + ); + const converted = await converter.convertSwagger1_2To2_0(swagger1_2); + const expected = readFixture>( + 'output/swagger-1.2-converted-to-swagger-2.0', + ); + + expect(converted).toEqual(expected); + }); + }); +}); From f9fd4310a86211382fd9e37be32695350e497c69 Mon Sep 17 00:00:00 2001 From: Patrick Knight Date: Fri, 12 Jun 2026 13:21:17 -0400 Subject: [PATCH 2/2] test(3scale): address PR review feedback on tests and config parsing Assisted-by: Cursor AI Signed-off-by: Patrick Knight --- .../3scale/.changeset/pink-parrots-rush.md | 2 +- .../3scale-backend/src/clients/types.ts | 12 ++++---- .../plugins/3scale-backend/src/module.test.ts | 4 +++ .../ThreeScaleApiEntityProvider.test.ts | 17 +++++------ .../src/providers/config.test.ts | 28 +++++++++++++++++++ .../3scale-backend/src/providers/config.ts | 2 +- 6 files changed, 49 insertions(+), 16 deletions(-) diff --git a/workspaces/3scale/.changeset/pink-parrots-rush.md b/workspaces/3scale/.changeset/pink-parrots-rush.md index 67b7ea8e790..b1322d78822 100644 --- a/workspaces/3scale/.changeset/pink-parrots-rush.md +++ b/workspaces/3scale/.changeset/pink-parrots-rush.md @@ -2,4 +2,4 @@ '@backstage-community/plugin-3scale-backend': patch --- -Added module wiring and contract tests, and a contributor guide for local development. +Added module wiring and contract tests, a contributor guide for local development, and fixed `addLabels: false` config parsing. diff --git a/workspaces/3scale/plugins/3scale-backend/src/clients/types.ts b/workspaces/3scale/plugins/3scale-backend/src/clients/types.ts index d65398f3e81..9f83f485e4c 100644 --- a/workspaces/3scale/plugins/3scale-backend/src/clients/types.ts +++ b/workspaces/3scale/plugins/3scale-backend/src/clients/types.ts @@ -39,8 +39,8 @@ export interface ServiceService { mandatory_app_key: boolean; buyer_can_select_plan: boolean; buyer_plan_change_permission: string; - created_at: Date; - updated_at: Date; + created_at: string; + updated_at: string; links: Link[]; } @@ -64,8 +64,8 @@ export interface APIDoc { published: boolean; skip_swagger_validations: boolean; body: string; - created_at: Date; - updated_at: Date; + created_at: string; + updated_at: string; description?: string; service_id?: number; } @@ -98,8 +98,8 @@ export interface ProxyElement { sandbox_endpoint: string; api_test_path: string; policies_config: PoliciesConfig[]; - created_at: Date; - updated_at: Date; + created_at: string; + updated_at: string; deployment_option: string; lock_version: number; links: Link[]; diff --git a/workspaces/3scale/plugins/3scale-backend/src/module.test.ts b/workspaces/3scale/plugins/3scale-backend/src/module.test.ts index a750b1fcc75..125f64e524a 100644 --- a/workspaces/3scale/plugins/3scale-backend/src/module.test.ts +++ b/workspaces/3scale/plugins/3scale-backend/src/module.test.ts @@ -38,6 +38,10 @@ const PROVIDER_CONFIG = { describe('catalogModule3ScaleEntityProvider', () => { let addedProviders: EntityProvider[] | EntityProvider[][] | undefined; + beforeEach(() => { + addedProviders = undefined; + }); + const extensionPoint = { addEntityProvider: ( ...providers: EntityProvider[] | EntityProvider[][] diff --git a/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts b/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts index 3e79cb4841a..836d202f63d 100644 --- a/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts +++ b/workspaces/3scale/plugins/3scale-backend/src/providers/ThreeScaleApiEntityProvider.test.ts @@ -40,6 +40,11 @@ type DeferredEntity = { function mockFetchResponses(...responses: unknown[]): jest.SpyInstance { const queue = [...responses]; return jest.spyOn(global, 'fetch').mockImplementation(() => { + if (queue.length === 0) { + return Promise.reject( + new Error('Unexpected fetch call: no more mocked responses in queue'), + ); + } const data = queue.shift(); return Promise.resolve({ ok: true, @@ -137,9 +142,7 @@ describe('ThreeScaleApiEntityProvider', () => { ThreeScaleApiEntityProvider.fromConfig({ config, logger: loggerMock }, { scheduler, - } as unknown as Parameters< - typeof ThreeScaleApiEntityProvider.fromConfig - >[1]); + } as unknown as Parameters[1]); expect(scheduler.createScheduledTaskRunner).toHaveBeenCalledWith({ frequency: { hours: 1 }, @@ -193,9 +196,7 @@ describe('ThreeScaleApiEntityProvider', () => { expect(() => ThreeScaleApiEntityProvider.fromConfig({ config, logger: loggerMock }, { scheduler, - } as unknown as Parameters< - typeof ThreeScaleApiEntityProvider.fromConfig - >[1]), + } as unknown as Parameters[1]), ).toThrow( new InputError( 'No schedule provided via config for ThreeScaleApiEntityProvider:test.', @@ -479,8 +480,8 @@ function createAPIDoc( skip_swagger_validations: false, body: JSON.stringify(apiDocBody), service_id: 2, - created_at: new Date('2024-09-17T10:09:04Z'), - updated_at: new Date('2024-09-17T10:09:04Z'), + created_at: '2024-09-17T10:09:04Z', + updated_at: '2024-09-17T10:09:04Z', }, }; } diff --git a/workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts b/workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts index ece77c4b7db..a1bc086720f 100644 --- a/workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts +++ b/workspaces/3scale/plugins/3scale-backend/src/providers/config.test.ts @@ -90,4 +90,32 @@ describe('readThreeScaleApiEntityConfigs', () => { }, ]); }); + + it('parses addLabels: false when explicitly configured', () => { + const config = new ConfigReader({ + catalog: { + providers: { + threeScaleApiEntity: { + dev: { + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + addLabels: false, + }, + }, + }, + }, + }); + + expect(readThreeScaleApiEntityConfigs(config)).toEqual([ + { + id: 'dev', + baseUrl: 'https://example-admin.3scale.net', + accessToken: 'test-token', + systemLabel: undefined, + ownerLabel: undefined, + addLabels: false, + schedule: undefined, + }, + ]); + }); }); diff --git a/workspaces/3scale/plugins/3scale-backend/src/providers/config.ts b/workspaces/3scale/plugins/3scale-backend/src/providers/config.ts index cbb53ac9a24..00c282ff20e 100644 --- a/workspaces/3scale/plugins/3scale-backend/src/providers/config.ts +++ b/workspaces/3scale/plugins/3scale-backend/src/providers/config.ts @@ -42,7 +42,7 @@ function readThreeScaleApiEntityConfig( const accessToken = config.getString('accessToken'); const systemLabel = config.getOptionalString('systemLabel'); const ownerLabel = config.getOptionalString('ownerLabel'); - const addLabels = config.getOptionalBoolean('addLabels') || true; + const addLabels = config.getOptionalBoolean('addLabels') ?? true; const schedule = config.has('schedule') ? readSchedulerServiceTaskScheduleDefinitionFromConfig(