chore: added some tests

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
benjdlambert
2026-02-17 10:57:37 +01:00
parent 58edede716
commit 723b94aa16
2 changed files with 419 additions and 0 deletions
@@ -0,0 +1,220 @@
/*
* Copyright 2026 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 { TestDatabases } from '@backstage/backend-test-utils';
import { applyEntityFilterToQuery } from './applyEntityFilterToQuery';
import {
DbFinalEntitiesRow,
DbRefreshStateRow,
DbSearchRow,
} from '../../database/tables';
import { Knex } from 'knex';
import { applyDatabaseMigrations } from '../../database/migrations';
import { FilterPredicate } from '@backstage/filter-predicates';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { v4 as uuid } from 'uuid';
import { buildEntitySearch } from '../../database/operations/stitcher/buildEntitySearch';
jest.setTimeout(60_000);
const databases = TestDatabases.create();
describe.each(databases.eachSupportedId())(
'applyEntityFilterToQuery with predicate queries, %p',
databaseId => {
let knex: Knex;
beforeAll(async () => {
knex = await databases.init(databaseId);
await applyDatabaseMigrations(knex);
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'service-a', namespace: 'default' },
spec: { type: 'service', lifecycle: 'production', owner: 'team-a' },
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'service-b', namespace: 'default' },
spec: { type: 'service', lifecycle: 'experimental', owner: 'team-b' },
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'website-c', namespace: 'default' },
spec: { type: 'website', lifecycle: 'production', owner: 'team-a' },
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'API',
metadata: { name: 'api-d', namespace: 'default' },
spec: { type: 'openapi', lifecycle: 'production' },
});
await addEntity({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: { name: 'bare-e', namespace: 'default' },
spec: {},
});
});
afterAll(async () => {
knex.destroy();
});
async function addEntity(entity: Entity) {
const id = uuid();
const entityRef = stringifyEntityRef(entity);
const entityJson = JSON.stringify(entity);
await knex<DbRefreshStateRow>('refresh_state').insert({
entity_id: id,
entity_ref: entityRef,
unprocessed_entity: entityJson,
errors: '[]',
next_update_at: '2031-01-01 23:00:00',
last_discovery_at: '2021-04-01 13:37:00',
});
await knex<DbFinalEntitiesRow>('final_entities').insert({
entity_id: id,
entity_ref: entityRef,
final_entity: entityJson,
hash: 'h',
stitch_ticket: '',
});
const search = await buildEntitySearch(id, entity);
await knex<DbSearchRow>('search').insert(search);
}
async function query(predicate: FilterPredicate): Promise<string[]> {
const q =
knex<DbFinalEntitiesRow>('final_entities').whereNotNull('final_entity');
applyEntityFilterToQuery({
query: predicate,
targetQuery: q,
onEntityIdField: 'final_entities.entity_id',
knex,
});
return await q.then(rows =>
rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(),
);
}
it('filters by direct field value', async () => {
await expect(query({ kind: 'component' })).resolves.toEqual([
'bare-e',
'service-a',
'service-b',
'website-c',
]);
});
it('filters by exact spec field value', async () => {
await expect(query({ 'spec.type': 'service' })).resolves.toEqual([
'service-a',
'service-b',
]);
});
it('filters with $all', async () => {
await expect(
query({
$all: [{ kind: 'component' }, { 'spec.type': 'service' }],
}),
).resolves.toEqual(['service-a', 'service-b']);
});
it('filters with $any', async () => {
await expect(
query({
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
}),
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
});
it('filters with $not', async () => {
await expect(
query({
$all: [
{ kind: 'component' },
{ $not: { 'spec.lifecycle': 'experimental' } },
],
}),
).resolves.toEqual(['bare-e', 'service-a', 'website-c']);
});
it('filters with $in', async () => {
await expect(
query({ 'spec.type': { $in: ['service', 'openapi'] } }),
).resolves.toEqual(['api-d', 'service-a', 'service-b']);
});
it('filters with $exists true', async () => {
await expect(
query({
$all: [{ kind: 'component' }, { 'spec.owner': { $exists: true } }],
}),
).resolves.toEqual(['service-a', 'service-b', 'website-c']);
});
it('filters with $exists false', async () => {
await expect(
query({
$all: [{ kind: 'component' }, { 'spec.owner': { $exists: false } }],
}),
).resolves.toEqual(['bare-e']);
});
it('handles nested logical operators', async () => {
await expect(
query({
$all: [
{ kind: 'component' },
{
$any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }],
},
{ $not: { 'spec.lifecycle': 'experimental' } },
],
}),
).resolves.toEqual(['service-a', 'website-c']);
});
it('throws on top-level primitive', async () => {
await expect(query('bad-value' as any)).rejects.toThrow(
/top-level primitive values are not supported/,
);
});
it('combines filter and query independently', async () => {
const q =
knex<DbFinalEntitiesRow>('final_entities').whereNotNull('final_entity');
applyEntityFilterToQuery({
filter: { key: 'kind', values: ['component'] },
query: { 'spec.type': 'service' },
targetQuery: q,
onEntityIdField: 'final_entities.entity_id',
knex,
});
const result = await q.then(rows =>
rows.map(row => JSON.parse(row.final_entity!).metadata.name).toSorted(),
);
expect(result).toEqual(['service-a', 'service-b']);
});
},
);
@@ -0,0 +1,199 @@
/*
* Copyright 2026 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 { parseEntityQuery } from './parseEntityQuery';
import { encodeCursor } from '../util';
import { Cursor } from '../../catalog/types';
describe('parseEntityQuery', () => {
describe('initial request', () => {
it('returns empty result for empty request', () => {
const result = parseEntityQuery({});
expect(result).toEqual({
query: undefined,
orderFields: undefined,
fullTextFilter: undefined,
fields: undefined,
limit: undefined,
});
});
it('parses a simple query predicate', () => {
const query = { kind: 'component' };
const result = parseEntityQuery({ query });
expect(result).toEqual(
expect.objectContaining({ query: { kind: 'component' } }),
);
});
it('parses a complex query with $all, $any, $not', () => {
const query = {
$all: [
{ kind: 'component' },
{ $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }] },
{ $not: { 'spec.lifecycle': 'experimental' } },
],
};
const result = parseEntityQuery({ query });
expect(result).toEqual(expect.objectContaining({ query }));
});
it('parses query with $exists operator', () => {
const query = { 'metadata.labels.team': { $exists: true } };
const result = parseEntityQuery({ query });
expect(result).toEqual(expect.objectContaining({ query }));
});
it('parses query with $in operator', () => {
const query = { kind: { $in: ['component', 'api'] } };
const result = parseEntityQuery({ query });
expect(result).toEqual(expect.objectContaining({ query }));
});
it('passes through limit', () => {
const result = parseEntityQuery({ limit: 50 });
expect(result).toEqual(expect.objectContaining({ limit: 50 }));
});
it('passes through fields', () => {
const result = parseEntityQuery({
fields: ['metadata.name', 'kind'],
});
expect(result).toEqual(
expect.objectContaining({ fields: ['metadata.name', 'kind'] }),
);
});
it('parses orderField into orderFields', () => {
const result = parseEntityQuery({
orderField: ['metadata.name,asc', 'metadata.namespace,desc'],
});
expect(result).toEqual(
expect.objectContaining({
orderFields: [
{ field: 'metadata.name', order: 'asc' },
{ field: 'metadata.namespace', order: 'desc' },
],
}),
);
});
it('parses fullTextFilter', () => {
const result = parseEntityQuery({
fullTextFilter: { term: 'search term', fields: ['metadata.name'] },
});
expect(result).toEqual(
expect.objectContaining({
fullTextFilter: {
term: 'search term',
fields: ['metadata.name'],
},
}),
);
});
it('defaults fullTextFilter term to empty string when missing', () => {
const result = parseEntityQuery({
fullTextFilter: {} as any,
});
expect(result).toEqual(
expect.objectContaining({
fullTextFilter: { term: '', fields: undefined },
}),
);
});
it('throws on invalid query predicate', () => {
expect(() =>
parseEntityQuery({ query: { $invalid: true } as any }),
).toThrow(/Invalid query/);
});
it('throws when query root is not an object', () => {
expect(() => parseEntityQuery({ query: 'bad' as any })).toThrow(
/Query must be an object/,
);
});
it('throws on invalid orderField order value', () => {
expect(() =>
parseEntityQuery({ orderField: ['metadata.name,sideways'] }),
).toThrow(/Invalid order field order/);
});
});
describe('cursor request', () => {
function makeCursor(partial: Partial<Cursor>): string {
const full: Cursor = {
orderFields: [],
orderFieldValues: [],
isPrevious: false,
...partial,
};
return encodeCursor(full);
}
it('decodes a valid cursor', () => {
const cursor = makeCursor({
orderFields: [{ field: 'metadata.name', order: 'asc' }],
orderFieldValues: ['test'],
isPrevious: false,
});
const result = parseEntityQuery({ cursor });
expect(result).toEqual(
expect.objectContaining({
cursor: expect.objectContaining({
orderFields: [{ field: 'metadata.name', order: 'asc' }],
orderFieldValues: ['test'],
isPrevious: false,
}),
}),
);
});
it('passes through limit and fields with cursor', () => {
const cursor = makeCursor({});
const result = parseEntityQuery({
cursor,
limit: 25,
fields: ['metadata.name'],
});
expect(result).toEqual(
expect.objectContaining({
limit: 25,
fields: ['metadata.name'],
}),
);
});
it('throws on empty cursor string', () => {
expect(() => parseEntityQuery({ cursor: '' })).toThrow(
/Cursor cannot be empty/,
);
});
it('throws on invalid base64 cursor', () => {
expect(() => parseEntityQuery({ cursor: '!!not-valid!!' })).toThrow(
/Malformed cursor/,
);
});
it('throws on invalid JSON in cursor', () => {
const cursor = Buffer.from('not json', 'utf8').toString('base64');
expect(() => parseEntityQuery({ cursor })).toThrow(/Malformed cursor/);
});
});
});