catalog: Add NextRouter and /refresh tests

Signed-off-by: Johan Haals <johan.haals@gmail.com>
This commit is contained in:
Johan Haals
2021-09-17 15:44:08 +02:00
parent 1d7a755380
commit e638fe0a96
2 changed files with 444 additions and 0 deletions
@@ -0,0 +1,428 @@
/*
* Copyright 2020 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 { getVoidLogger } from '@backstage/backend-common';
import { ConfigReader } from '@backstage/config';
import { NotFoundError } from '@backstage/errors';
import type { Entity, LocationSpec, Location } from '@backstage/catalog-model';
import express from 'express';
import request from 'supertest';
import { EntitiesCatalog } from '../catalog';
import { createNextRouter, LocationService, RefreshService } from '../next';
import { basicEntityFilter } from '../service/request';
describe('createNextRouter readonly disabled', () => {
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
let locationService: jest.Mocked<LocationService>;
let app: express.Express;
let refreshService: RefreshService;
beforeAll(async () => {
entitiesCatalog = {
entities: jest.fn(),
removeEntityByUid: jest.fn(),
batchAddOrUpdateEntities: jest.fn(),
};
locationService = {
getLocation: jest.fn(),
createLocation: jest.fn(),
listLocations: jest.fn(),
deleteLocation: jest.fn(),
};
refreshService = { refresh: jest.fn() };
const router = await createNextRouter({
entitiesCatalog,
locationService,
logger: getVoidLogger(),
refreshService,
config: new ConfigReader(undefined),
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('POST /refresh', () => {
it('refreshes an entity using the refresh service', async () => {
const response = await request(app)
.post('/refresh')
.set('Content-Type', 'application/json')
.send({ entityRef: 'Component/default:foo' });
expect(response.status).toBe(200);
expect(refreshService.refresh).toHaveBeenCalledWith({
entityRef: 'Component/default:foo',
});
});
});
describe('GET /entities', () => {
it('happy path: lists entities', async () => {
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
];
entitiesCatalog.entities.mockResolvedValueOnce({
entities: [entities[0]],
pageInfo: { hasNextPage: false },
});
const response = await request(app).get('/entities');
expect(response.status).toEqual(200);
expect(response.body).toEqual(entities);
});
it('parses single and multiple request parameters and passes them down', async () => {
entitiesCatalog.entities.mockResolvedValueOnce({
entities: [],
pageInfo: { hasNextPage: false },
});
const response = await request(app).get(
'/entities?filter=a=1,a=2,b=3&filter=c=4',
);
expect(response.status).toEqual(200);
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenCalledWith({
filter: {
anyOf: [
{
allOf: [
{ key: 'a', matchValueIn: ['1', '2'] },
{ key: 'b', matchValueIn: ['3'] },
],
},
{ allOf: [{ key: 'c', matchValueIn: ['4'] }] },
],
},
});
});
});
describe('GET /entities/by-uid/:uid', () => {
it('can fetch entity by uid', async () => {
const entity: Entity = {
apiVersion: 'a',
kind: 'b',
metadata: {
name: 'c',
},
};
entitiesCatalog.entities.mockResolvedValue({
entities: [entity],
pageInfo: { hasNextPage: false },
});
const response = await request(app).get('/entities/by-uid/zzz');
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenCalledWith({
filter: basicEntityFilter({ 'metadata.uid': 'zzz' }),
});
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
entitiesCatalog.entities.mockResolvedValue({
entities: [],
pageInfo: { hasNextPage: false },
});
const response = await request(app).get('/entities/by-uid/zzz');
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenCalledWith({
filter: basicEntityFilter({ 'metadata.uid': 'zzz' }),
});
expect(response.status).toEqual(404);
expect(response.text).toMatch(/uid/);
});
});
describe('GET /entities/by-name/:kind/:namespace/:name', () => {
it('can fetch entity by name', async () => {
const entity: Entity = {
apiVersion: 'a',
kind: 'k',
metadata: {
name: 'n',
namespace: 'ns',
},
};
entitiesCatalog.entities.mockResolvedValue({
entities: [entity],
pageInfo: { hasNextPage: false },
});
const response = await request(app).get('/entities/by-name/k/ns/n');
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenCalledWith({
filter: basicEntityFilter({
kind: 'k',
'metadata.namespace': 'ns',
'metadata.name': 'n',
}),
});
expect(response.status).toEqual(200);
expect(response.body).toEqual(expect.objectContaining(entity));
});
it('responds with a 404 for missing entities', async () => {
entitiesCatalog.entities.mockResolvedValue({
entities: [],
pageInfo: { hasNextPage: false },
});
const response = await request(app).get('/entities/by-name/b/d/c');
expect(entitiesCatalog.entities).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.entities).toHaveBeenCalledWith({
filter: basicEntityFilter({
kind: 'b',
'metadata.namespace': 'd',
'metadata.name': 'c',
}),
});
expect(response.status).toEqual(404);
expect(response.text).toMatch(/name/);
});
});
describe('DELETE /entities/by-uid/:uid', () => {
it('can remove', async () => {
entitiesCatalog.removeEntityByUid.mockResolvedValue(undefined);
const response = await request(app).delete('/entities/by-uid/apa');
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
expect(response.status).toEqual(204);
});
it('responds with a 404 for missing entities', async () => {
entitiesCatalog.removeEntityByUid.mockRejectedValue(
new NotFoundError('nope'),
);
const response = await request(app).delete('/entities/by-uid/apa');
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
expect(response.status).toEqual(404);
});
});
describe('GET /locations', () => {
it('happy path: lists locations', async () => {
const locations: Location[] = [
{ id: 'foo', type: 'url', target: 'example.com' },
];
locationService.listLocations.mockResolvedValueOnce(locations);
const response = await request(app).get('/locations');
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{ data: { id: 'foo', target: 'example.com', type: 'url' } },
]);
});
});
describe('POST /locations', () => {
it('rejects malformed locations', async () => {
const spec = {
typez: 'b',
target: 'c',
} as unknown as LocationSpec;
const response = await request(app).post('/locations').send(spec);
expect(locationService.createLocation).not.toHaveBeenCalled();
expect(response.status).toEqual(400);
});
it('passes the body down', async () => {
const spec: LocationSpec = {
type: 'b',
target: 'c',
};
locationService.createLocation.mockResolvedValue({
location: { id: 'a', ...spec },
entities: [],
});
const response = await request(app).post('/locations').send(spec);
expect(locationService.createLocation).toHaveBeenCalledTimes(1);
expect(locationService.createLocation).toHaveBeenCalledWith(spec, false);
expect(response.status).toEqual(201);
expect(response.body).toEqual(
expect.objectContaining({
location: { id: 'a', ...spec },
}),
);
});
it('supports dry run', async () => {
const spec: LocationSpec = {
type: 'b',
target: 'c',
};
locationService.createLocation.mockResolvedValue({
location: { id: 'a', ...spec },
entities: [],
});
const response = await request(app)
.post('/locations?dryRun=true')
.send(spec);
expect(locationService.createLocation).toHaveBeenCalledTimes(1);
expect(locationService.createLocation).toHaveBeenCalledWith(spec, true);
expect(response.status).toEqual(201);
expect(response.body).toEqual(
expect.objectContaining({
location: { id: 'a', ...spec },
}),
);
});
});
});
describe('createNextRouter readonly enabled', () => {
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
let app: express.Express;
let locationService: jest.Mocked<LocationService>;
beforeAll(async () => {
entitiesCatalog = {
entities: jest.fn(),
removeEntityByUid: jest.fn(),
batchAddOrUpdateEntities: jest.fn(),
};
locationService = {
getLocation: jest.fn(),
createLocation: jest.fn(),
listLocations: jest.fn(),
deleteLocation: jest.fn(),
};
const router = await createNextRouter({
entitiesCatalog,
locationService,
logger: getVoidLogger(),
config: new ConfigReader({
catalog: {
readonly: true,
},
}),
});
app = express().use(router);
});
beforeEach(() => {
jest.resetAllMocks();
});
describe('GET /entities', () => {
it('happy path: lists entities', async () => {
const entities: Entity[] = [
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
];
entitiesCatalog.entities.mockResolvedValueOnce({
entities: [entities[0]],
pageInfo: { hasNextPage: false },
});
const response = await request(app).get('/entities');
expect(response.status).toEqual(200);
expect(response.body).toEqual(entities);
});
});
describe('DELETE /entities/by-uid/:uid', () => {
// this delete is allowed as there is no other way to remove entities
it('is allowed', async () => {
const response = await request(app).delete('/entities/by-uid/apa');
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledTimes(1);
expect(entitiesCatalog.removeEntityByUid).toHaveBeenCalledWith('apa');
expect(response.status).toEqual(204);
});
});
describe('GET /locations', () => {
it('happy path: lists locations', async () => {
const locations: Location[] = [
{ id: 'foo', type: 'url', target: 'example.com' },
];
locationService.listLocations.mockResolvedValueOnce(locations);
const response = await request(app).get('/locations');
expect(response.status).toEqual(200);
expect(response.body).toEqual([
{ data: { id: 'foo', target: 'example.com', type: 'url' } },
]);
});
});
describe('POST /locations', () => {
it('is not allowed', async () => {
const spec: LocationSpec = {
type: 'b',
target: 'c',
};
const response = await request(app).post('/locations').send(spec);
expect(locationService.createLocation).not.toHaveBeenCalled();
expect(response.status).toEqual(403);
expect(response.text).toMatch(/not allowed in readonly/);
});
it('supports dry run', async () => {
const spec: LocationSpec = {
type: 'b',
target: 'c',
};
locationService.createLocation.mockResolvedValue({
location: { id: 'a', ...spec },
entities: [],
});
const response = await request(app)
.post('/locations?dryRun=true')
.send(spec);
expect(locationService.createLocation).toHaveBeenCalledTimes(1);
expect(locationService.createLocation).toHaveBeenCalledWith(spec, true);
expect(response.status).toEqual(201);
expect(response.body).toEqual(
expect.objectContaining({
location: { id: 'a', ...spec },
}),
);
});
});
});
@@ -25,12 +25,14 @@ import { LocationResponse } from '../catalog/types';
import { HigherOrderOperation } from '../ingestion/types';
import { createRouter } from './router';
import { basicEntityFilter } from './request';
import { RefreshService } from '../next';
describe('createRouter readonly disabled', () => {
let entitiesCatalog: jest.Mocked<EntitiesCatalog>;
let locationsCatalog: jest.Mocked<LocationsCatalog>;
let higherOrderOperation: jest.Mocked<HigherOrderOperation>;
let app: express.Express;
let refreshService: RefreshService;
beforeAll(async () => {
entitiesCatalog = {
@@ -51,11 +53,13 @@ describe('createRouter readonly disabled', () => {
addLocation: jest.fn(),
refreshAllLocations: jest.fn(),
};
refreshService = { refresh: jest.fn() };
const router = await createRouter({
entitiesCatalog,
locationsCatalog,
higherOrderOperation,
logger: getVoidLogger(),
refreshService,
config: new ConfigReader(undefined),
});
app = express().use(router);
@@ -65,6 +69,18 @@ describe('createRouter readonly disabled', () => {
jest.resetAllMocks();
});
describe('POST /refresh', () => {
it('refreshes an entity using the refresh service', async () => {
const response = await request(app)
.post('/refresh')
.set('Content-Type', 'application/json')
.send({ entityRef: 'Component/default:foo' });
expect(response.status).toBe(200);
expect(refreshService.refresh).toHaveBeenCalledWith({
entityRef: 'Component/default:foo',
});
});
});
describe('GET /entities', () => {
it('happy path: lists entities', async () => {
const entities: Entity[] = [