diff --git a/plugins/todo-backend/src/service/TodoReaderService.test.ts b/plugins/todo-backend/src/service/TodoReaderService.test.ts new file mode 100644 index 0000000000..dacc4118d9 --- /dev/null +++ b/plugins/todo-backend/src/service/TodoReaderService.test.ts @@ -0,0 +1,362 @@ +/* + * Copyright 2021 Spotify AB + * + * 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 { CatalogApi } from '@backstage/catalog-client'; +import { Entity } from '@backstage/catalog-model'; +import { TodoItem, TodoReader } from '../lib'; +import { TodoReaderService } from './TodoReaderService'; + +const entityName = { + kind: 'Component', + namespace: 'default', + name: 'my-component', +}; + +const mockEntity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'my-component', + annotations: { + 'backstage.io/managed-by-location': + 'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }, + }, + spec: { + type: 'library', + }, +}; + +function mockCatalogClient(entity?: Entity): jest.Mocked { + const mock = { + addLocation: jest.fn(), + getEntities: jest.fn(), + getEntityByName: jest.fn(), + getLocationByEntity: jest.fn(), + getLocationById: jest.fn(), + removeEntityByUid: jest.fn(), + }; + if (entity) { + mock.getEntityByName.mockReturnValue(entity); + } + return mock; +} + +function mockTodoReader(items?: TodoItem[]): jest.Mocked { + const mock = { + readTodos: jest.fn(), + }; + if (items) { + mock.readTodos.mockReturnValue({ items }); + } + return mock; +} + +function makeTodo(text: string, extra?: Partial): TodoItem { + return { text, tag: 'TODO', ...extra }; +} + +describe('TodoReaderService', () => { + it('should be created', () => { + const service = new TodoReaderService({ + todoReader: mockTodoReader(), + catalogClient: mockCatalogClient(), + }); + expect(service.listTodos).toEqual(expect.any(Function)); + }); + + it('should list 0 todos', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient(mockEntity); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [], + totalCount: 0, + offset: 0, + limit: 10, + }); + expect(catalogClient.getEntityByName).toHaveBeenCalledWith(entityName); + }); + + it('should list, order, and filter todos', async () => { + const todo1 = makeTodo('todo1'); + const todo2 = makeTodo('todo2', { author: 'foo' }); + const todo3 = makeTodo('todo3', { + author: 'bar', + repoFilePath: 'a/b/c.js', + }); + const todo4 = makeTodo('todo4', { author: 'baz', viewUrl: 'example.com' }); + const todo5 = makeTodo('todo5', { tag: 'FIXME' }); + + const todoReader = mockTodoReader([todo1, todo2, todo3, todo4, todo5]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + ['backstage.io/source-location']: + 'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml', + }, + }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [todo1, todo2, todo3, todo4, todo5], + totalCount: 5, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ entity: entityName, limit: 3 }), + ).resolves.toEqual({ + items: [todo1, todo2, todo3], + totalCount: 5, + offset: 0, + limit: 3, + }); + + await expect( + service.listTodos({ entity: entityName, offset: 2 }), + ).resolves.toEqual({ + items: [todo3, todo4, todo5], + totalCount: 5, + offset: 2, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + orderBy: { field: 'author', direction: 'asc' }, + }), + ).resolves.toEqual({ + items: [todo3, todo4, todo2, todo1, todo5], + totalCount: 5, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + orderBy: { field: 'author', direction: 'desc' }, + offset: -2, + }), + ).resolves.toEqual({ + items: [todo2, todo4, todo3, todo1, todo5], + totalCount: 5, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + orderBy: { field: 'text', direction: 'desc' }, + offset: 2, + limit: 2, + }), + ).resolves.toEqual({ + items: [todo3, todo2], + totalCount: 5, + offset: 2, + limit: 2, + }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'text', value: '*4' }], + }), + ).resolves.toEqual({ items: [todo4], totalCount: 1, offset: 0, limit: 10 }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'author', value: 'ba*' }], + }), + ).resolves.toEqual({ + items: [todo3, todo4], + totalCount: 2, + offset: 0, + limit: 10, + }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'author', value: 'ba*' }], + limit: 1, + }), + ).resolves.toEqual({ items: [todo3], totalCount: 2, offset: 0, limit: 1 }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [ + { field: 'author', value: 'ba*' }, + { field: 'tag', value: 'FIXME' }, + ], + }), + ).resolves.toEqual({ items: [], totalCount: 0, offset: 0, limit: 10 }); + + await expect( + service.listTodos({ + entity: entityName, + filters: [{ field: 'tag', value: 'FIXME' }], + limit: 500, + }), + ).resolves.toEqual({ items: [todo5], totalCount: 1, offset: 0, limit: 50 }); + + await expect( + service.listTodos({ entity: entityName, limit: -5 }), + ).resolves.toEqual({ items: [], totalCount: 5, offset: 0, limit: 0 }); + }); + + it('should have configurable pagination', async () => { + const todo1 = makeTodo('todo1'); + const todo2 = makeTodo('todo2'); + const todo3 = makeTodo('todo3'); + const todo4 = makeTodo('todo4'); + const todo5 = makeTodo('todo5'); + + const todoReader = mockTodoReader([todo1, todo2, todo3, todo4, todo5]); + const catalogClient = mockCatalogClient(mockEntity); + const service = new TodoReaderService({ + todoReader, + catalogClient, + defaultPageSize: 2, + maxPageSize: 3, + }); + + await expect(service.listTodos({ entity: entityName })).resolves.toEqual({ + items: [todo1, todo2], + totalCount: 5, + offset: 0, + limit: 2, + }); + await expect( + service.listTodos({ entity: entityName, limit: 1 }), + ).resolves.toEqual({ + items: [todo1], + totalCount: 5, + offset: 0, + limit: 1, + }); + await expect( + service.listTodos({ entity: entityName, limit: 3 }), + ).resolves.toEqual({ + items: [todo1, todo2, todo3], + totalCount: 5, + offset: 0, + limit: 3, + }); + await expect( + service.listTodos({ entity: entityName, limit: 4 }), + ).resolves.toEqual({ + items: [todo1, todo2, todo3], + totalCount: 5, + offset: 0, + limit: 3, + }); + }); + + it('should require an entity', async () => { + const service = new TodoReaderService({ + todoReader: mockTodoReader(), + catalogClient: mockCatalogClient(), + }); + await expect(service.listTodos({})).rejects.toThrow( + 'Entity filter is required to list TODOs', + ); + }); + + it('should throw if entity is not found', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient(); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'NotFoundError', + message: 'Entity not found, Component:default/my-component', + }), + ); + expect(catalogClient.getEntityByName).toHaveBeenCalledWith(entityName); + }); + + it('should throw if entity does not have a location', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { ...mockEntity.metadata, annotations: undefined }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: + 'No entity location annotation found for Component:my-component', + }), + ); + }); + + it('should throw if entity has an invalid location', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + ['backstage.io/managed-by-location']: 'file:../info.yaml', + }, + }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: `Invalid entity location type for Component:my-component, got 'file'`, + }), + ); + }); + + it('should throw if entity has an invalid source location', async () => { + const todoReader = mockTodoReader([]); + const catalogClient = mockCatalogClient({ + ...mockEntity, + metadata: { + ...mockEntity.metadata, + annotations: { + ['backstage.io/source-location']: 'file:../info.yaml', + }, + }, + }); + const service = new TodoReaderService({ todoReader, catalogClient }); + + await expect(service.listTodos({ entity: entityName })).rejects.toEqual( + expect.objectContaining({ + name: 'InputError', + message: `Invalid entity source location type for Component:my-component, got 'file'`, + }), + ); + }); +}); diff --git a/plugins/todo-backend/src/service/TodoReaderService.ts b/plugins/todo-backend/src/service/TodoReaderService.ts index 994866db93..2f20aab0f1 100644 --- a/plugins/todo-backend/src/service/TodoReaderService.ts +++ b/plugins/todo-backend/src/service/TodoReaderService.ts @@ -15,7 +15,7 @@ */ import { InputError, NotFoundError } from '@backstage/errors'; -import { CatalogClient } from '@backstage/catalog-client'; +import { CatalogApi } from '@backstage/catalog-client'; import { LOCATION_ANNOTATION, SOURCE_LOCATION_ANNOTATION, @@ -31,7 +31,7 @@ const DEFAULT_MAX_PAGE_SIZE = 50; type Options = { todoReader: TodoReader; - catalogClient: CatalogClient; + catalogClient: CatalogApi; maxPageSize?: number; defaultPageSize?: number; }; @@ -45,7 +45,7 @@ function wildcardRegex(str: string): RegExp { export class TodoReaderService implements TodoService { private readonly todoReader: TodoReader; - private readonly catalogClient: CatalogClient; + private readonly catalogClient: CatalogApi; private readonly maxPageSize: number; private readonly defaultPageSize: number; @@ -100,7 +100,7 @@ export class TodoReaderService implements TodoService { const field2 = item2[field]; if (field1 && field2) { - return dir * field1?.localeCompare(field2, 'en-US'); + return dir * field1.localeCompare(field2, 'en-US'); } else if (field1 && !field2) { return -1; } else if (!field1 && field2) { @@ -127,7 +127,7 @@ export class TodoReaderService implements TodoService { throw new InputError( `Invalid entity source location type for ${serializeEntityRef( entity, - )}, got ${parsed.type}`, + )}, got '${parsed.type}'`, ); } return parsed.target; @@ -138,9 +138,9 @@ export class TodoReaderService implements TodoService { const parsed = parseLocationReference(location); if (parsed.type !== 'url') { throw new InputError( - `Invalid entity source location type for ${serializeEntityRef( + `Invalid entity location type for ${serializeEntityRef( entity, - )}, got ${parsed.type}`, + )}, got '${parsed.type}'`, ); } return parsed.target;