From ee951e033aeb29964c868ecd5f0b7e3dcee6975d Mon Sep 17 00:00:00 2001 From: Sanket Saikia Date: Tue, 16 Jun 2026 15:50:31 +0530 Subject: [PATCH 1/2] scorecard example for backend tests --- .../plugins/scorecard-backend/package.json | 2 + .../src/plugin.integration.test.ts | 251 ++++++++++++++++++ 2 files changed, 253 insertions(+) create mode 100644 workspaces/scorecard/plugins/scorecard-backend/src/plugin.integration.test.ts diff --git a/workspaces/scorecard/plugins/scorecard-backend/package.json b/workspaces/scorecard/plugins/scorecard-backend/package.json index 0970926055..40f71c7957 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/package.json +++ b/workspaces/scorecard/plugins/scorecard-backend/package.json @@ -30,6 +30,8 @@ "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", + "test:unit": "backstage-cli package test --testPathIgnorePatterns=integration\\.test", + "test:integration": "backstage-cli package test --testPathPatterns=integration\\.test --watch=false", "clean": "backstage-cli package clean", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack" diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/plugin.integration.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.integration.test.ts new file mode 100644 index 0000000000..023286fd31 --- /dev/null +++ b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.integration.test.ts @@ -0,0 +1,251 @@ +/* + * Copyright Red Hat, Inc. + * + * 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 { + mockCredentials, + mockServices, + startTestBackend, +} from '@backstage/backend-test-utils'; +import { createBackendModule } from '@backstage/backend-plugin-api'; +import { catalogServiceMock } from '@backstage/plugin-catalog-node/testUtils'; +import { scorecardMetricsExtensionPoint } from '@red-hat-developer-hub/backstage-plugin-scorecard-node'; +import { scorecardPlugin } from './plugin'; +import { + MockNumberProvider, + MockBooleanProvider, +} from '../__fixtures__/mockProviders'; +import request from 'supertest'; +import type { Server } from 'http'; + +/** + * Backend module that registers mock metric providers via the extension point, + * mirroring how real modules (e.g. scorecard-backend-module-github) work. + */ +const testMetricsModule = createBackendModule({ + pluginId: 'scorecard', + moduleId: 'test-metrics', + register(reg) { + reg.registerInit({ + deps: { metrics: scorecardMetricsExtensionPoint }, + async init({ metrics }) { + metrics.addMetricProvider( + new MockNumberProvider( + 'github.open_prs', + 'github', + 'GitHub Open PRs', + ), + new MockNumberProvider( + 'github.open_issues', + 'github', + 'GitHub Open Issues', + ), + new MockBooleanProvider('sonar.quality', 'sonar', 'Code Quality'), + ); + }, + }); + }, +}); + +const BASE_CONFIG = { + backend: { + database: { + client: 'better-sqlite3', + connection: ':memory:', + }, + }, +}; + +describe('scorecard plugin (startTestBackend)', () => { + let server: Server; + + beforeAll(async () => { + ({ server } = await startTestBackend({ + features: [ + scorecardPlugin, + testMetricsModule, + mockServices.rootConfig.factory({ data: BASE_CONFIG }), + mockServices.auth.factory(), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + catalogServiceMock.factory({ entities: [] }), + ], + })); + }); + + afterAll(() => { + server.close(); + }); + + describe('GET /api/scorecard/metrics', () => { + it('returns all registered metrics', async () => { + const res = await request(server).get('/api/scorecard/metrics'); + + expect(res.status).toBe(200); + expect(res.body.metrics).toHaveLength(3); + + const ids = res.body.metrics.map((m: { id: string }) => m.id); + expect(ids).toEqual( + expect.arrayContaining([ + 'github.open_prs', + 'github.open_issues', + 'sonar.quality', + ]), + ); + }); + + it('filters metrics by metricIds', async () => { + const res = await request(server).get( + '/api/scorecard/metrics?metricIds=github.open_prs', + ); + + expect(res.status).toBe(200); + expect(res.body.metrics).toHaveLength(1); + expect(res.body.metrics[0].id).toBe('github.open_prs'); + }); + + it('filters metrics by datasource', async () => { + const res = await request(server).get( + '/api/scorecard/metrics?datasource=github', + ); + + expect(res.status).toBe(200); + expect(res.body.metrics).toHaveLength(2); + + const ids = res.body.metrics.map((m: { id: string }) => m.id); + expect(ids).toEqual( + expect.arrayContaining(['github.open_prs', 'github.open_issues']), + ); + }); + + it('rejects request with both metricIds and datasource', async () => { + const res = await request(server).get( + '/api/scorecard/metrics?metricIds=sonar.quality&datasource=github', + ); + + expect(res.status).toBe(400); + expect(res.body.error.message).toBe( + 'Cannot filter by both metricIds and datasource', + ); + }); + }); + + describe('GET /api/scorecard/aggregations/:aggregationId/metadata', () => { + it('returns metadata for a registered metric', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/github.open_prs/metadata', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + title: 'GitHub Open PRs', + type: 'number', + aggregationType: 'statusGrouped', + }), + ); + }); + + it('returns 404 for non-existent aggregation', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/non.existent/metadata', + ); + + expect(res.status).toBe(404); + }); + }); + + describe('GET /api/scorecard/metrics/catalog/:kind/:namespace/:name', () => { + it('returns 404 when entity does not exist in the catalog', async () => { + const res = await request(server).get( + '/api/scorecard/metrics/catalog/component/default/non-existent', + ); + + expect(res.status).toBe(404); + }); + }); + + describe('GET /api/scorecard/aggregations/:aggregationId (auth edge cases)', () => { + it('returns 401 when request has no user credentials', async () => { + const res = await request(server) + .get('/api/scorecard/aggregations/github.open_prs') + .set('Authorization', mockCredentials.none.header()); + + expect(res.status).toBe(401); + }); + }); +}); + +describe('scorecard plugin with aggregationKPIs config', () => { + let server: Server; + + const KPI_CONFIG = { + ...BASE_CONFIG, + scorecard: { + aggregationKPIs: { + myCustomKpi: { + title: 'Custom KPI', + description: 'A custom KPI based on open PRs', + type: 'statusGrouped', + metricId: 'github.open_prs', + }, + }, + }, + }; + + beforeAll(async () => { + ({ server } = await startTestBackend({ + features: [ + scorecardPlugin, + testMetricsModule, + mockServices.rootConfig.factory({ data: KPI_CONFIG }), + mockServices.auth.factory(), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + catalogServiceMock.factory({ entities: [] }), + ], + })); + }); + + afterAll(() => { + server.close(); + }); + + it('resolves KPI metadata from config', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/myCustomKpi/metadata', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + title: 'Custom KPI', + description: 'A custom KPI based on open PRs', + aggregationType: 'statusGrouped', + }), + ); + }); + + it('still serves metric-based aggregation metadata', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/github.open_prs/metadata', + ); + + expect(res.status).toBe(200); + expect(res.body.title).toBe('GitHub Open PRs'); + }); +}); From 90e456dd0f29a36fccc6ef35cf075c6860f7711e Mon Sep 17 00:00:00 2001 From: Sanket Saikia Date: Mon, 22 Jun 2026 19:55:12 +0530 Subject: [PATCH 2/2] suggested changes and additional tests Signed-off-by: Sanket Saikia --- .../plugins/scorecard-backend/package.json | 2 - ...integration.test.ts => plugin.api.test.ts} | 134 ++++++++++++++---- 2 files changed, 107 insertions(+), 29 deletions(-) rename workspaces/scorecard/plugins/scorecard-backend/src/{plugin.integration.test.ts => plugin.api.test.ts} (66%) diff --git a/workspaces/scorecard/plugins/scorecard-backend/package.json b/workspaces/scorecard/plugins/scorecard-backend/package.json index 40f71c7957..0970926055 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/package.json +++ b/workspaces/scorecard/plugins/scorecard-backend/package.json @@ -30,8 +30,6 @@ "build": "backstage-cli package build", "lint": "backstage-cli package lint", "test": "backstage-cli package test", - "test:unit": "backstage-cli package test --testPathIgnorePatterns=integration\\.test", - "test:integration": "backstage-cli package test --testPathPatterns=integration\\.test --watch=false", "clean": "backstage-cli package clean", "prepack": "backstage-cli package prepack", "postpack": "backstage-cli package postpack" diff --git a/workspaces/scorecard/plugins/scorecard-backend/src/plugin.integration.test.ts b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts similarity index 66% rename from workspaces/scorecard/plugins/scorecard-backend/src/plugin.integration.test.ts rename to workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts index 023286fd31..340413551d 100644 --- a/workspaces/scorecard/plugins/scorecard-backend/src/plugin.integration.test.ts +++ b/workspaces/scorecard/plugins/scorecard-backend/src/plugin.api.test.ts @@ -29,6 +29,7 @@ import { } from '../__fixtures__/mockProviders'; import request from 'supertest'; import type { Server } from 'http'; +import type { Entity } from '@backstage/catalog-model'; /** * Backend module that registers mock metric providers via the extension point, @@ -68,22 +69,50 @@ const BASE_CONFIG = { }, }; +function startScorecardBackend(options?: { + config?: object; + entities?: Entity[]; +}) { + return startTestBackend({ + features: [ + scorecardPlugin, + testMetricsModule, + mockServices.rootConfig.factory({ data: options?.config ?? BASE_CONFIG }), + mockServices.auth.factory(), + mockServices.httpAuth.factory({ + defaultCredentials: mockCredentials.user('user:default/test'), + }), + catalogServiceMock.factory({ entities: options?.entities ?? [] }), + ], + }); +} + +const TEST_ENTITIES: Entity[] = [ + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'User', + metadata: { name: 'test', namespace: 'default' }, + spec: { profile: {}, memberOf: [] }, + relations: [], + }, + { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-service', + namespace: 'default', + annotations: { 'mock/key': 'true' }, + }, + spec: { type: 'service', owner: 'user:default/test' }, + relations: [{ type: 'ownedBy', targetRef: 'user:default/test' }], + }, +]; + describe('scorecard plugin (startTestBackend)', () => { let server: Server; beforeAll(async () => { - ({ server } = await startTestBackend({ - features: [ - scorecardPlugin, - testMetricsModule, - mockServices.rootConfig.factory({ data: BASE_CONFIG }), - mockServices.auth.factory(), - mockServices.httpAuth.factory({ - defaultCredentials: mockCredentials.user('user:default/test'), - }), - catalogServiceMock.factory({ entities: [] }), - ], - })); + ({ server } = await startScorecardBackend({ entities: TEST_ENTITIES })); }); afterAll(() => { @@ -169,6 +198,24 @@ describe('scorecard plugin (startTestBackend)', () => { }); describe('GET /api/scorecard/metrics/catalog/:kind/:namespace/:name', () => { + it('returns metrics for an existing entity', async () => { + const res = await request(server).get( + '/api/scorecard/metrics/catalog/component/default/my-service', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + + it('filters entity metrics by metricIds', async () => { + const res = await request(server).get( + '/api/scorecard/metrics/catalog/component/default/my-service?metricIds=github.open_prs', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual([]); + }); + it('returns 404 when entity does not exist in the catalog', async () => { const res = await request(server).get( '/api/scorecard/metrics/catalog/component/default/non-existent', @@ -178,7 +225,36 @@ describe('scorecard plugin (startTestBackend)', () => { }); }); - describe('GET /api/scorecard/aggregations/:aggregationId (auth edge cases)', () => { + describe('GET /api/scorecard/aggregations/:aggregationId', () => { + it('returns aggregated metrics for an authenticated user with owned entities', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/github.open_prs', + ); + + expect(res.status).toBe(200); + expect(res.body).toEqual( + expect.objectContaining({ + id: 'github.open_prs', + status: 'success', + metadata: expect.objectContaining({ + aggregationType: 'statusGrouped', + title: 'GitHub Open PRs', + type: 'number', + }), + result: expect.objectContaining({ + total: 0, + entitiesConsidered: 0, + calculationErrorCount: 0, + values: [ + { count: 0, name: 'error' }, + { count: 0, name: 'warning' }, + { count: 0, name: 'success' }, + ], + }), + }), + ); + }); + it('returns 401 when request has no user credentials', async () => { const res = await request(server) .get('/api/scorecard/aggregations/github.open_prs') @@ -186,6 +262,14 @@ describe('scorecard plugin (startTestBackend)', () => { expect(res.status).toBe(401); }); + + it('returns 404 for non-existent aggregation', async () => { + const res = await request(server).get( + '/api/scorecard/aggregations/non.existent', + ); + + expect(res.status).toBe(404); + }); }); }); @@ -199,26 +283,22 @@ describe('scorecard plugin with aggregationKPIs config', () => { myCustomKpi: { title: 'Custom KPI', description: 'A custom KPI based on open PRs', - type: 'statusGrouped', + type: 'average', metricId: 'github.open_prs', + options: { + statusScores: { + error: 0, + warning: 50, + success: 100, + }, + }, }, }, }, }; beforeAll(async () => { - ({ server } = await startTestBackend({ - features: [ - scorecardPlugin, - testMetricsModule, - mockServices.rootConfig.factory({ data: KPI_CONFIG }), - mockServices.auth.factory(), - mockServices.httpAuth.factory({ - defaultCredentials: mockCredentials.user('user:default/test'), - }), - catalogServiceMock.factory({ entities: [] }), - ], - })); + ({ server } = await startScorecardBackend({ config: KPI_CONFIG })); }); afterAll(() => { @@ -235,7 +315,7 @@ describe('scorecard plugin with aggregationKPIs config', () => { expect.objectContaining({ title: 'Custom KPI', description: 'A custom KPI based on open PRs', - aggregationType: 'statusGrouped', + aggregationType: 'average', }), ); });