From 7f8a801e6d72d1fc132b4babc447ed3afd6b08e8 Mon Sep 17 00:00:00 2001 From: Diego Mondragon Date: Mon, 16 Oct 2023 12:00:24 -0700 Subject: [PATCH 1/7] Added examples for `sentry:project:create` action and unit tests. Signed-off-by: Diego Mondragon --- .changeset/tender-peas-smoke.md | 5 + .../package.json | 6 +- .../src/actions/createProject.examples.ts | 53 +++++ .../src/actions/createProject.test.ts | 207 ++++++++++++++++++ .../src/actions/createProject.ts | 6 +- 5 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 .changeset/tender-peas-smoke.md create mode 100644 plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts create mode 100644 plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts diff --git a/.changeset/tender-peas-smoke.md b/.changeset/tender-peas-smoke.md new file mode 100644 index 0000000000..5c84e1a131 --- /dev/null +++ b/.changeset/tender-peas-smoke.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-sentry': patch +--- + +Added examples for `sentry:project:create` scaffolder action and unit tests. diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index 58b39a6b5c..0aa4b5b1c8 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -30,10 +30,12 @@ "dependencies": { "@backstage/config": "workspace:^", "@backstage/errors": "workspace:^", - "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/plugin-scaffolder-node": "workspace:^", + "yaml": "^2.3.3" }, "devDependencies": { - "@backstage/cli": "workspace:^" + "@backstage/cli": "workspace:^", + "@backstage/types": "workspace:^" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts new file mode 100644 index 0000000000..9d4551c1b5 --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.examples.ts @@ -0,0 +1,53 @@ +/* + * Copyright 2021 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Creates a Sentry project with the specified parameters.', + example: yaml.stringify({ + steps: [ + { + id: 'create-sentry-project', + action: 'sentry:project:create', + name: 'Create a Sentry project with provided project slug.', + input: { + organizationSlug: 'my-org', + teamSlug: 'team-a', + name: 'Scaffolded project A', + slug: 'scaff-proj-a', + authToken: + 'a14711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d5df96', + }, + }, + { + id: 'create-sentry-project', + action: 'sentry:project:create', + name: 'Create a Sentry project without providing a project slug.', + input: { + organizationSlug: 'my-org', + teamSlug: 'team-b', + name: 'Scaffolded project B', + authToken: + 'b15711beb516e1e910d2ede554dc1bf725654ef3c75e5a9106de9aec13d4gf93', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts new file mode 100644 index 0000000000..e58f2dc177 --- /dev/null +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -0,0 +1,207 @@ +/* + * Copyright 2021 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 { ConfigReader } from '@backstage/config'; +import { JsonObject } from '@backstage/types'; +import { createSentryCreateProjectAction } from './createProject'; +import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { InputError } from '@backstage/errors'; + +describe('sentry:project:create action', () => { + const createScaffolderConfig = (configData: JsonObject = {}) => ({ + config: new ConfigReader({ + scaffolder: { + ...configData, + }, + }), + }); + + const mockFetch = (response = {}) => { + const mockedResponse = { + status: 201, + headers: { + get: () => 'application/json', + }, + json: async () => + Promise.resolve({ + detail: 'project creation mocked result', + }), + text: async () => Promise.resolve('Unexpected error.'), + ...response, + }; + global.fetch = jest + .fn() + .mockImplementation(() => Promise.resolve(mockedResponse)); + + return mockedResponse; + }; + + const getActionContext = (): ActionContext<{ + organizationSlug: string; + teamSlug: string; + name: string; + slug?: string; + authToken?: string; + }> => ({ + workspacePath: './dev/proj', + createTemporaryDirectory: jest.fn(), + logger: jest.createMockFromModule('winston'), + logStream: jest.createMockFromModule('stream'), + input: { + organizationSlug: 'org', + teamSlug: 'team', + name: 'test project', + authToken: '008hsd7f7123hhdsfhfds7123123881239889fdsaf1g', + }, + output: jest.fn(), + }); + + beforeEach(() => { + mockFetch(); + }); + + test('should request sentry project create with specified parameters.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + await action.handler(actionContext); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${actionContext.input.authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: actionContext.input.name, + }), + }, + ); + }); + + test('should request sentry project create with added optional specified project slug', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + actionContext.input = { ...actionContext.input, slug: 'project-slug' }; + + await action.handler(actionContext); + + expect(global.fetch).toHaveBeenNthCalledWith( + 1, + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${actionContext.input.authToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: actionContext.input.name, + slug: actionContext.input.slug, + }), + }, + ); + }); + + test('should take Sentry auth token from scaffolder config when input authToken is missing.', async () => { + const sentryScaffolderConfigToken = + 'scaffolder app-config.yaml scaffolder token'; + const action = createSentryCreateProjectAction( + createScaffolderConfig({ + sentry: { + token: sentryScaffolderConfigToken, + }, + }), + ); + + const actionContext = getActionContext(); + + actionContext.input.authToken = undefined; + + await action.handler(actionContext); + + expect(fetch).toHaveBeenNthCalledWith( + 1, + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${sentryScaffolderConfigToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name: actionContext.input.name, + }), + }, + ); + }); + + test('should throw InputError when auth token is missing from input parameters and scaffolder config.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + actionContext.input.authToken = undefined; + + expect.assertions(1); + + await expect(async () => { + await action.handler(actionContext); + }).rejects.toThrow(new InputError('No valid sentry token given')); + }); + + test('should throw InputError when sentry API returns unexpected content-type.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + const mockedFetchResponse = mockFetch({ + headers: { + get: () => 'text/html', + }, + }); + + expect.assertions(1); + + await expect(async () => { + await action.handler(actionContext); + }).rejects.toThrow( + new InputError( + `Unexpected Sentry Response Type: ${await mockedFetchResponse.text()}`, + ), + ); + }); + + test('should throw InputError when sentry API returns unexpected status code.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + const mockedFetchResponse = mockFetch({ + status: 400, + }); + + expect.assertions(1); + + await expect(async () => { + await action.handler(actionContext); + }).rejects.toThrow( + new InputError( + `Sentry Response was: ${(await mockedFetchResponse.json()).detail}`, + ), + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts index 85a74eda07..504084b9df 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts @@ -17,9 +17,10 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; +import { examples } from './createProject.examples'; /** - * Creates the `sentry:craete-project` Scaffolder action. + * Creates the `sentry:create-project` Scaffolder action. * * @remarks * @@ -39,6 +40,7 @@ export function createSentryCreateProjectAction(options: { config: Config }) { authToken?: string; }>({ id: 'sentry:project:create', + examples, schema: { input: { required: ['organizationSlug', 'teamSlug', 'name'], @@ -112,7 +114,7 @@ export function createSentryCreateProjectAction(options: { config: Config }) { const result = await response.json(); if (code !== 201) { - throw new InputError(`Sentry Response was: ${await result.detail}`); + throw new InputError(`Sentry Response was: ${result.detail}`); } ctx.output('id', result.id); From f35b34d10d770f973b8f71059889d4fa3b0eba23 Mon Sep 17 00:00:00 2001 From: Diego Mondragon Date: Thu, 19 Oct 2023 10:10:23 -0700 Subject: [PATCH 2/7] Fixed deps install yarn.lock file Signed-off-by: Diego Mondragon --- yarn.lock | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index 1b42d4915a..968acd6f80 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8662,6 +8662,8 @@ __metadata: "@backstage/config": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" + "@backstage/types": "workspace:^" + yaml: ^2.3.3 languageName: unknown linkType: soft @@ -45031,7 +45033,7 @@ __metadata: languageName: node linkType: hard -"yaml@npm:2.3.4, yaml@npm:^2.0.0, yaml@npm:^2.0.0-10, yaml@npm:^2.1.1, yaml@npm:^2.2.1, yaml@npm:^2.2.2, yaml@npm:^2.3.2": +"yaml@npm:2.3.4, yaml@npm:^2.0.0, yaml@npm:^2.0.0-10, yaml@npm:^2.1.1, yaml@npm:^2.2.1, yaml@npm:^2.2.2, yaml@npm:^2.3.2, yaml@npm:^2.3.3": version: 2.3.4 resolution: "yaml@npm:2.3.4" checksum: e6d1dae1c6383bcc8ba11796eef3b8c02d5082911c6723efeeb5ba50fc8e881df18d645e64de68e421b577296000bea9c75d6d9097c2f6699da3ae0406c030d8 From d58984cc484f7347b5f58780bd7a34793dbfc0fa Mon Sep 17 00:00:00 2001 From: Diego Mondragon Date: Thu, 19 Oct 2023 13:19:30 -0700 Subject: [PATCH 3/7] Generate fake tokens for tests. Signed-off-by: Diego Mondragon --- .../src/actions/createProject.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts index e58f2dc177..ea892882ea 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -18,6 +18,7 @@ import { JsonObject } from '@backstage/types'; import { createSentryCreateProjectAction } from './createProject'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; +import { randomBytes } from 'crypto'; describe('sentry:project:create action', () => { const createScaffolderConfig = (configData: JsonObject = {}) => ({ @@ -63,7 +64,7 @@ describe('sentry:project:create action', () => { organizationSlug: 'org', teamSlug: 'team', name: 'test project', - authToken: '008hsd7f7123hhdsfhfds7123123881239889fdsaf1g', + authToken: randomBytes(5).toString('hex'), }, output: jest.fn(), }); @@ -120,8 +121,7 @@ describe('sentry:project:create action', () => { }); test('should take Sentry auth token from scaffolder config when input authToken is missing.', async () => { - const sentryScaffolderConfigToken = - 'scaffolder app-config.yaml scaffolder token'; + const sentryScaffolderConfigToken = randomBytes(5).toString('hex'); const action = createSentryCreateProjectAction( createScaffolderConfig({ sentry: { From 15b9fbf89c29baa6b7d59d8e4dba5749a8de2ff2 Mon Sep 17 00:00:00 2001 From: Diego Mondragon Date: Mon, 23 Oct 2023 15:00:43 -0700 Subject: [PATCH 4/7] Added fetchApi param to enable passing fetch function implementation Signed-off-by: Diego Mondragon --- .../package.json | 1 + .../src/actions/createProject.test.ts | 33 ++++++++++++++----- .../src/actions/createProject.ts | 9 +++-- yarn.lock | 1 + 4 files changed, 33 insertions(+), 11 deletions(-) diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index 0aa4b5b1c8..eccae89620 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@backstage/config": "workspace:^", + "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "yaml": "^2.3.3" diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts index ea892882ea..b66f842688 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -29,6 +29,8 @@ describe('sentry:project:create action', () => { }), }); + let fetch: jest.Func; + const mockFetch = (response = {}) => { const mockedResponse = { status: 201, @@ -42,10 +44,8 @@ describe('sentry:project:create action', () => { text: async () => Promise.resolve('Unexpected error.'), ...response, }; - global.fetch = jest - .fn() - .mockImplementation(() => Promise.resolve(mockedResponse)); + fetch = jest.fn().mockImplementation(() => Promise.resolve(mockedResponse)); return mockedResponse; }; @@ -74,7 +74,10 @@ describe('sentry:project:create action', () => { }); test('should request sentry project create with specified parameters.', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig()); + const action = createSentryCreateProjectAction(createScaffolderConfig(), { + fetch, + }); + const actionContext = getActionContext(); await action.handler(actionContext); @@ -96,14 +99,17 @@ describe('sentry:project:create action', () => { }); test('should request sentry project create with added optional specified project slug', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig()); + const action = createSentryCreateProjectAction(createScaffolderConfig(), { + fetch, + }); + const actionContext = getActionContext(); actionContext.input = { ...actionContext.input, slug: 'project-slug' }; await action.handler(actionContext); - expect(global.fetch).toHaveBeenNthCalledWith( + expect(fetch).toHaveBeenNthCalledWith( 1, `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, { @@ -128,6 +134,7 @@ describe('sentry:project:create action', () => { token: sentryScaffolderConfigToken, }, }), + { fetch }, ); const actionContext = getActionContext(); @@ -153,7 +160,9 @@ describe('sentry:project:create action', () => { }); test('should throw InputError when auth token is missing from input parameters and scaffolder config.', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig()); + const action = createSentryCreateProjectAction(createScaffolderConfig(), { + fetch, + }); const actionContext = getActionContext(); actionContext.input.authToken = undefined; @@ -166,7 +175,6 @@ describe('sentry:project:create action', () => { }); test('should throw InputError when sentry API returns unexpected content-type.', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig()); const actionContext = getActionContext(); const mockedFetchResponse = mockFetch({ @@ -175,6 +183,10 @@ describe('sentry:project:create action', () => { }, }); + const action = createSentryCreateProjectAction(createScaffolderConfig(), { + fetch, + }); + expect.assertions(1); await expect(async () => { @@ -187,13 +199,16 @@ describe('sentry:project:create action', () => { }); test('should throw InputError when sentry API returns unexpected status code.', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig()); const actionContext = getActionContext(); const mockedFetchResponse = mockFetch({ status: 400, }); + const action = createSentryCreateProjectAction(createScaffolderConfig(), { + fetch, + }); + expect.assertions(1); await expect(async () => { diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts index 504084b9df..bb2ee6039e 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts @@ -18,6 +18,7 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { examples } from './createProject.examples'; +import { FetchApi } from '@backstage/core-plugin-api'; /** * Creates the `sentry:create-project` Scaffolder action. @@ -27,9 +28,13 @@ import { examples } from './createProject.examples'; * See {@link https://backstage.io/docs/features/software-templates/writing-custom-actions}. * * @param options - Configuration of the Sentry API. + * @param fetchApi - fetch implementation to use for calling Sentry service. * @public */ -export function createSentryCreateProjectAction(options: { config: Config }) { +export function createSentryCreateProjectAction( + options: { config: Config }, + fetchApi?: FetchApi, +) { const { config } = options; return createTemplateAction<{ @@ -90,7 +95,7 @@ export function createSentryCreateProjectAction(options: { config: Config }) { throw new InputError(`No valid sentry token given`); } - const response = await fetch( + const response = await (fetchApi?.fetch ?? fetch)( `https://sentry.io/api/0/teams/${organizationSlug}/${teamSlug}/projects/`, { method: 'POST', diff --git a/yarn.lock b/yarn.lock index 968acd6f80..7a0ca3c78a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8660,6 +8660,7 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" + "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" From 9b90f94c908ba1e3ba53f25a05e2d7034e0996fb Mon Sep 17 00:00:00 2001 From: Diego Mondragon Date: Mon, 23 Oct 2023 16:30:20 -0700 Subject: [PATCH 5/7] Updated api-report.md Signed-off-by: Diego Mondragon --- plugins/scaffolder-backend-module-sentry/api-report.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend-module-sentry/api-report.md b/plugins/scaffolder-backend-module-sentry/api-report.md index 746cce1348..e850c0f1c9 100644 --- a/plugins/scaffolder-backend-module-sentry/api-report.md +++ b/plugins/scaffolder-backend-module-sentry/api-report.md @@ -4,13 +4,17 @@ ```ts import { Config } from '@backstage/config'; +import { FetchApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; // @public -export function createSentryCreateProjectAction(options: { - config: Config; -}): TemplateAction< +export function createSentryCreateProjectAction( + options: { + config: Config; + }, + fetchApi?: FetchApi, +): TemplateAction< { organizationSlug: string; teamSlug: string; From 2c776eb0246fd50beaae9178c8a97d10b6f16f7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Thu, 16 Nov 2023 13:54:44 +0100 Subject: [PATCH 6/7] fix test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../api-report.md | 10 +- .../package.json | 5 +- .../src/actions/createProject.test.ts | 268 +++++++++--------- .../src/actions/createProject.ts | 15 +- yarn.lock | 3 +- 5 files changed, 147 insertions(+), 154 deletions(-) diff --git a/plugins/scaffolder-backend-module-sentry/api-report.md b/plugins/scaffolder-backend-module-sentry/api-report.md index e850c0f1c9..746cce1348 100644 --- a/plugins/scaffolder-backend-module-sentry/api-report.md +++ b/plugins/scaffolder-backend-module-sentry/api-report.md @@ -4,17 +4,13 @@ ```ts import { Config } from '@backstage/config'; -import { FetchApi } from '@backstage/core-plugin-api'; import { JsonObject } from '@backstage/types'; import { TemplateAction } from '@backstage/plugin-scaffolder-node'; // @public -export function createSentryCreateProjectAction( - options: { - config: Config; - }, - fetchApi?: FetchApi, -): TemplateAction< +export function createSentryCreateProjectAction(options: { + config: Config; +}): TemplateAction< { organizationSlug: string; teamSlug: string; diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index eccae89620..999cc7114c 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -29,14 +29,15 @@ }, "dependencies": { "@backstage/config": "workspace:^", - "@backstage/core-plugin-api": "workspace:^", "@backstage/errors": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", "yaml": "^2.3.3" }, "devDependencies": { + "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", - "@backstage/types": "workspace:^" + "@backstage/types": "workspace:^", + "msw": "^1.0.0" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts index b66f842688..0486012760 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -13,14 +13,20 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; -import { JsonObject } from '@backstage/types'; -import { createSentryCreateProjectAction } from './createProject'; -import { ActionContext } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; +import { ActionContext } from '@backstage/plugin-scaffolder-node'; +import { JsonObject } from '@backstage/types'; import { randomBytes } from 'crypto'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { createSentryCreateProjectAction } from './createProject'; describe('sentry:project:create action', () => { + const worker = setupServer(); + setupRequestMockHandlers(worker); + const createScaffolderConfig = (configData: JsonObject = {}) => ({ config: new ConfigReader({ scaffolder: { @@ -29,26 +35,6 @@ describe('sentry:project:create action', () => { }), }); - let fetch: jest.Func; - - const mockFetch = (response = {}) => { - const mockedResponse = { - status: 201, - headers: { - get: () => 'application/json', - }, - json: async () => - Promise.resolve({ - detail: 'project creation mocked result', - }), - text: async () => Promise.resolve('Unexpected error.'), - ...response, - }; - - fetch = jest.fn().mockImplementation(() => Promise.resolve(mockedResponse)); - return mockedResponse; - }; - const getActionContext = (): ActionContext<{ organizationSlug: string; teamSlug: string; @@ -69,64 +55,71 @@ describe('sentry:project:create action', () => { output: jest.fn(), }); - beforeEach(() => { - mockFetch(); - }); - - test('should request sentry project create with specified parameters.', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig(), { - fetch, - }); + it('should request sentry project create with specified parameters.', async () => { + expect.assertions(3); + const action = createSentryCreateProjectAction(createScaffolderConfig()); const actionContext = getActionContext(); + worker.use( + rest.post( + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${actionContext.input.authToken}`, + ); + expect(req.headers.get('Content-Type')).toBe(`application/json`); + await expect(req.json()).resolves.toEqual({ + name: actionContext.input.name, + }); + return res( + ctx.status(201), + ctx.json({ + detail: 'project creation mocked result', + }), + ); + }, + ), + ); + await action.handler(actionContext); - - expect(fetch).toHaveBeenNthCalledWith( - 1, - `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${actionContext.input.authToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: actionContext.input.name, - }), - }, - ); }); - test('should request sentry project create with added optional specified project slug', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig(), { - fetch, - }); + it('should request sentry project create with added optional specified project slug', async () => { + expect.assertions(3); + const action = createSentryCreateProjectAction(createScaffolderConfig()); const actionContext = getActionContext(); - actionContext.input = { ...actionContext.input, slug: 'project-slug' }; - await action.handler(actionContext); - - expect(fetch).toHaveBeenNthCalledWith( - 1, - `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${actionContext.input.authToken}`, - 'Content-Type': 'application/json', + worker.use( + rest.post( + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${actionContext.input.authToken}`, + ); + expect(req.headers.get('Content-Type')).toBe(`application/json`); + await expect(req.json()).resolves.toEqual({ + name: actionContext.input.name, + slug: actionContext.input.slug, + }); + return res( + ctx.status(201), + ctx.json({ + detail: 'project creation mocked result', + }), + ); }, - body: JSON.stringify({ - name: actionContext.input.name, - slug: actionContext.input.slug, - }), - }, + ), ); + + await action.handler(actionContext); }); - test('should take Sentry auth token from scaffolder config when input authToken is missing.', async () => { + it('should take Sentry auth token from scaffolder config when input authToken is missing.', async () => { + expect.assertions(3); + const sentryScaffolderConfigToken = randomBytes(5).toString('hex'); const action = createSentryCreateProjectAction( createScaffolderConfig({ @@ -134,89 +127,98 @@ describe('sentry:project:create action', () => { token: sentryScaffolderConfigToken, }, }), - { fetch }, ); - const actionContext = getActionContext(); - actionContext.input.authToken = undefined; + worker.use( + rest.post( + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${sentryScaffolderConfigToken}`, + ); + expect(req.headers.get('Content-Type')).toBe(`application/json`); + await expect(req.json()).resolves.toEqual({ + name: actionContext.input.name, + }); + return res( + ctx.status(201), + ctx.json({ + detail: 'project creation mocked result', + }), + ); + }, + ), + ); + await action.handler(actionContext); - - expect(fetch).toHaveBeenNthCalledWith( - 1, - `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - { - method: 'POST', - headers: { - Authorization: `Bearer ${sentryScaffolderConfigToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - name: actionContext.input.name, - }), - }, - ); }); - test('should throw InputError when auth token is missing from input parameters and scaffolder config.', async () => { - const action = createSentryCreateProjectAction(createScaffolderConfig(), { - fetch, - }); + it('should throw InputError when auth token is missing from input parameters and scaffolder config.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); const actionContext = getActionContext(); - actionContext.input.authToken = undefined; - expect.assertions(1); - - await expect(async () => { - await action.handler(actionContext); - }).rejects.toThrow(new InputError('No valid sentry token given')); - }); - - test('should throw InputError when sentry API returns unexpected content-type.', async () => { - const actionContext = getActionContext(); - - const mockedFetchResponse = mockFetch({ - headers: { - get: () => 'text/html', - }, - }); - - const action = createSentryCreateProjectAction(createScaffolderConfig(), { - fetch, - }); - - expect.assertions(1); - - await expect(async () => { - await action.handler(actionContext); - }).rejects.toThrow( - new InputError( - `Unexpected Sentry Response Type: ${await mockedFetchResponse.text()}`, + worker.use( + rest.post( + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${actionContext.input.authToken}`, + ); + expect(req.headers.get('Content-Type')).toBe(`application/json`); + await expect(req.json()).resolves.toEqual({ + name: actionContext.input.name, + }); + return res( + ctx.status(201), + ctx.json({ + detail: 'project creation mocked result', + }), + ); + }, ), ); + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError('No valid sentry token given'), + ); }); - test('should throw InputError when sentry API returns unexpected status code.', async () => { + it('should throw InputError when sentry API returns unexpected content-type.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); const actionContext = getActionContext(); - const mockedFetchResponse = mockFetch({ - status: 400, - }); - - const action = createSentryCreateProjectAction(createScaffolderConfig(), { - fetch, - }); - - expect.assertions(1); - - await expect(async () => { - await action.handler(actionContext); - }).rejects.toThrow( - new InputError( - `Sentry Response was: ${(await mockedFetchResponse.json()).detail}`, + worker.use( + rest.post( + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async (_, res, ctx) => { + return res(ctx.status(201), ctx.text('Bad response')); + }, ), ); + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError(`Unexpected Sentry Response Type: Bad response`), + ); + }); + + it('should throw InputError when sentry API returns unexpected status code.', async () => { + const action = createSentryCreateProjectAction(createScaffolderConfig()); + const actionContext = getActionContext(); + + worker.use( + rest.post( + `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, + async (_, res, ctx) => { + return res(ctx.status(400), ctx.json({ detail: 'OUCH' })); + }, + ), + ); + + await expect(() => action.handler(actionContext)).rejects.toThrow( + new InputError(`Sentry Response was: OUCH`), + ); }); }); diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts index bb2ee6039e..259b670f9e 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.ts @@ -17,24 +17,18 @@ import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; -import { examples } from './createProject.examples'; -import { FetchApi } from '@backstage/core-plugin-api'; /** - * Creates the `sentry:create-project` Scaffolder action. + * Creates the `sentry:project:create` Scaffolder action. * * @remarks * * See {@link https://backstage.io/docs/features/software-templates/writing-custom-actions}. * * @param options - Configuration of the Sentry API. - * @param fetchApi - fetch implementation to use for calling Sentry service. * @public */ -export function createSentryCreateProjectAction( - options: { config: Config }, - fetchApi?: FetchApi, -) { +export function createSentryCreateProjectAction(options: { config: Config }) { const { config } = options; return createTemplateAction<{ @@ -45,7 +39,6 @@ export function createSentryCreateProjectAction( authToken?: string; }>({ id: 'sentry:project:create', - examples, schema: { input: { required: ['organizationSlug', 'teamSlug', 'name'], @@ -95,7 +88,7 @@ export function createSentryCreateProjectAction( throw new InputError(`No valid sentry token given`); } - const response = await (fetchApi?.fetch ?? fetch)( + const response = await fetch( `https://sentry.io/api/0/teams/${organizationSlug}/${teamSlug}/projects/`, { method: 'POST', @@ -119,7 +112,7 @@ export function createSentryCreateProjectAction( const result = await response.json(); if (code !== 201) { - throw new InputError(`Sentry Response was: ${result.detail}`); + throw new InputError(`Sentry Response was: ${await result.detail}`); } ctx.output('id', result.id); diff --git a/yarn.lock b/yarn.lock index 7a0ca3c78a..3000d86b7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8658,12 +8658,13 @@ __metadata: version: 0.0.0-use.local resolution: "@backstage/plugin-scaffolder-backend-module-sentry@workspace:plugins/scaffolder-backend-module-sentry" dependencies: + "@backstage/backend-test-utils": "workspace:^" "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" - "@backstage/core-plugin-api": "workspace:^" "@backstage/errors": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" + msw: ^1.0.0 yaml: ^2.3.3 languageName: unknown linkType: soft From 88c4747ce6298c28d46b198363b75b9f47861474 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Fri, 24 Nov 2023 15:50:29 +0100 Subject: [PATCH 7/7] ok try msw2 then MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .../package.json | 2 +- .../src/actions/createProject.test.ts | 87 +++++++++---------- yarn.lock | 4 +- 3 files changed, 43 insertions(+), 50 deletions(-) diff --git a/plugins/scaffolder-backend-module-sentry/package.json b/plugins/scaffolder-backend-module-sentry/package.json index 999cc7114c..9429fd0252 100644 --- a/plugins/scaffolder-backend-module-sentry/package.json +++ b/plugins/scaffolder-backend-module-sentry/package.json @@ -37,7 +37,7 @@ "@backstage/backend-test-utils": "workspace:^", "@backstage/cli": "workspace:^", "@backstage/types": "workspace:^", - "msw": "^1.0.0" + "msw": "^2.0.0" }, "files": [ "dist" diff --git a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts index 0486012760..972b0f1e8e 100644 --- a/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts +++ b/plugins/scaffolder-backend-module-sentry/src/actions/createProject.test.ts @@ -13,14 +13,15 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; import { ConfigReader } from '@backstage/config'; import { InputError } from '@backstage/errors'; import { ActionContext } from '@backstage/plugin-scaffolder-node'; import { JsonObject } from '@backstage/types'; import { randomBytes } from 'crypto'; -import { rest } from 'msw'; import { setupServer } from 'msw/node'; +import { HttpResponse, http } from 'msw'; import { createSentryCreateProjectAction } from './createProject'; describe('sentry:project:create action', () => { @@ -62,21 +63,19 @@ describe('sentry:project:create action', () => { const actionContext = getActionContext(); worker.use( - rest.post( + http.post( `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - async (req, res, ctx) => { - expect(req.headers.get('Authorization')).toBe( + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( `Bearer ${actionContext.input.authToken}`, ); - expect(req.headers.get('Content-Type')).toBe(`application/json`); - await expect(req.json()).resolves.toEqual({ + expect(request.headers.get('Content-Type')).toBe(`application/json`); + await expect(request.json()).resolves.toEqual({ name: actionContext.input.name, }); - return res( - ctx.status(201), - ctx.json({ - detail: 'project creation mocked result', - }), + return HttpResponse.json( + { detail: 'project creation mocked result' }, + { status: 201 }, ); }, ), @@ -93,22 +92,20 @@ describe('sentry:project:create action', () => { actionContext.input = { ...actionContext.input, slug: 'project-slug' }; worker.use( - rest.post( + http.post( `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - async (req, res, ctx) => { - expect(req.headers.get('Authorization')).toBe( + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( `Bearer ${actionContext.input.authToken}`, ); - expect(req.headers.get('Content-Type')).toBe(`application/json`); - await expect(req.json()).resolves.toEqual({ + expect(request.headers.get('Content-Type')).toBe(`application/json`); + await expect(request.json()).resolves.toEqual({ name: actionContext.input.name, slug: actionContext.input.slug, }); - return res( - ctx.status(201), - ctx.json({ - detail: 'project creation mocked result', - }), + return HttpResponse.json( + { detail: 'project creation mocked result' }, + { status: 201 }, ); }, ), @@ -132,21 +129,19 @@ describe('sentry:project:create action', () => { actionContext.input.authToken = undefined; worker.use( - rest.post( + http.post( `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - async (req, res, ctx) => { - expect(req.headers.get('Authorization')).toBe( + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( `Bearer ${sentryScaffolderConfigToken}`, ); - expect(req.headers.get('Content-Type')).toBe(`application/json`); - await expect(req.json()).resolves.toEqual({ + expect(request.headers.get('Content-Type')).toBe(`application/json`); + await expect(request.json()).resolves.toEqual({ name: actionContext.input.name, }); - return res( - ctx.status(201), - ctx.json({ - detail: 'project creation mocked result', - }), + return HttpResponse.json( + { detail: 'project creation mocked result' }, + { status: 201 }, ); }, ), @@ -161,21 +156,19 @@ describe('sentry:project:create action', () => { actionContext.input.authToken = undefined; worker.use( - rest.post( + http.post( `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - async (req, res, ctx) => { - expect(req.headers.get('Authorization')).toBe( + async ({ request }) => { + expect(request.headers.get('Authorization')).toBe( `Bearer ${actionContext.input.authToken}`, ); - expect(req.headers.get('Content-Type')).toBe(`application/json`); - await expect(req.json()).resolves.toEqual({ + expect(request.headers.get('Content-Type')).toBe(`application/json`); + await expect(request.json()).resolves.toEqual({ name: actionContext.input.name, }); - return res( - ctx.status(201), - ctx.json({ - detail: 'project creation mocked result', - }), + return HttpResponse.json( + { detail: 'project creation mocked result' }, + { status: 201 }, ); }, ), @@ -191,10 +184,10 @@ describe('sentry:project:create action', () => { const actionContext = getActionContext(); worker.use( - rest.post( + http.post( `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - async (_, res, ctx) => { - return res(ctx.status(201), ctx.text('Bad response')); + async () => { + return HttpResponse.text('Bad response', { status: 201 }); }, ), ); @@ -209,10 +202,10 @@ describe('sentry:project:create action', () => { const actionContext = getActionContext(); worker.use( - rest.post( + http.post( `https://sentry.io/api/0/teams/${actionContext.input.organizationSlug}/${actionContext.input.teamSlug}/projects/`, - async (_, res, ctx) => { - return res(ctx.status(400), ctx.json({ detail: 'OUCH' })); + async () => { + return HttpResponse.json({ detail: 'OUCH' }, { status: 400 }); }, ), ); diff --git a/yarn.lock b/yarn.lock index 3000d86b7f..8690082630 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8664,7 +8664,7 @@ __metadata: "@backstage/errors": "workspace:^" "@backstage/plugin-scaffolder-node": "workspace:^" "@backstage/types": "workspace:^" - msw: ^1.0.0 + msw: ^2.0.0 yaml: ^2.3.3 languageName: unknown linkType: soft @@ -34768,7 +34768,7 @@ __metadata: languageName: node linkType: hard -"msw@npm:^2.0.8": +"msw@npm:^2.0.0, msw@npm:^2.0.8": version: 2.0.9 resolution: "msw@npm:2.0.9" dependencies: