-
Notifications
You must be signed in to change notification settings - Fork 108
scorecard example for backend tests #3414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,331 @@ | ||
| /* | ||
| * 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'; | ||
| import type { Entity } from '@backstage/catalog-model'; | ||
|
|
||
| /** | ||
| * 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', | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] test-integrity The afterAll hook calls server.close() without awaiting or handling its callback. http.Server.close() does not return a promise, so connections may still be draining when Jest proceeds. This can cause open handle warnings or flaky teardown. The same pattern appears at line 155. |
||
| connection: ':memory:', | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| 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 startScorecardBackend({ entities: TEST_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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] naming-convention The test file uses res for supertest responses whereas existing router.test.ts uses response or result. Suggested fix: Rename res to response or result. |
||
| expect(res.body.metrics).toHaveLength(3); | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] api-shape-pattern Uses inline type annotations (m: { id: string }) => m.id instead of importing the domain Metric type as done in router.test.ts. Suggested fix: Import Metric from @red-hat-developer-hub/backstage-plugin-scorecard-common and use (m: Metric) => m.id. |
||
| 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] test-adequacy The KPI config test defines type statusGrouped and asserts aggregationType statusGrouped, but this is also the default value. The assertion passes regardless of whether the config-driven path or the fallback is taken. Testing with a non-default type would provide stronger assurance. |
||
| 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', | ||
| ); | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] test-inadequate The entity-level metric tests only assert that the response is an empty array. These tests exercise route plumbing but never verify that actual metric results are returned correctly. |
||
| expect(res.status).toBe(404); | ||
| }); | ||
| }); | ||
|
|
||
| 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', | ||
| ); | ||
|
|
||
| expect(res.status).toBe(404); | ||
| }); | ||
| }); | ||
|
|
||
|
teknaS47 marked this conversation as resolved.
|
||
| 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') | ||
| .set('Authorization', mockCredentials.none.header()); | ||
|
|
||
| expect(res.status).toBe(401); | ||
| }); | ||
|
|
||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [info] test-inadequate The KPI-config describe block does not pass any entities to startScorecardBackend. Currently only metadata tests exist so nothing breaks. |
||
| it('returns 404 for non-existent aggregation', async () => { | ||
| const res = await request(server).get( | ||
| '/api/scorecard/aggregations/non.existent', | ||
| ); | ||
|
|
||
| expect(res.status).toBe(404); | ||
| }); | ||
| }); | ||
| }); | ||
|
|
||
| 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: 'average', | ||
| metricId: 'github.open_prs', | ||
| options: { | ||
| statusScores: { | ||
| error: 0, | ||
| warning: 50, | ||
| success: 100, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }; | ||
|
|
||
| beforeAll(async () => { | ||
| ({ server } = await startScorecardBackend({ config: KPI_CONFIG })); | ||
| }); | ||
|
|
||
| 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: 'average', | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| 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'); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[info] code-organization
Blank line between the license header and first import. Other test files have no blank line there.