diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts index d867ab1d28..aa3b624050 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.test.ts @@ -683,6 +683,83 @@ describe('InMemoryCatalogClient', () => { ]); }); + it('filters by predicate query', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { kind: 'CustomKind' }, + }); + expect(result.items).toEqual([entity1, entity3]); + expect(result.totalItems).toBe(2); + }); + + it('filters by predicate query with $all', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [{ kind: 'CustomKind' }, { 'spec.type': 'service' }], + }, + }); + expect(result.items).toEqual([entity1, entity3]); + }); + + it('filters by predicate query with $any', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $any: [{ 'spec.type': 'service' }, { 'spec.type': 'website' }], + }, + }); + expect(result.items).toEqual([entity1, entity3, entity4]); + }); + + it('filters by predicate query with $not', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { + $all: [ + { kind: 'CustomKind' }, + { $not: { 'spec.lifecycle': 'production' } }, + ], + }, + }); + expect(result.items).toEqual([]); + }); + + it('filters by predicate query with $in', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.type': { $in: ['service', 'library'] } }, + }); + expect(result.items).toEqual([entity1, entity2, entity3]); + }); + + it('filters by predicate query with $exists', async () => { + const client = new InMemoryCatalogClient({ entities }); + const result = await client.queryEntities({ + query: { 'spec.lifecycle': { $exists: false } }, + }); + expect(result.items).toEqual([entity4]); + }); + + it('preserves query predicate through cursor pagination', async () => { + const client = new InMemoryCatalogClient({ entities }); + const page1 = await client.queryEntities({ + query: { kind: 'CustomKind' }, + orderFields: { field: 'metadata.name', order: 'asc' }, + limit: 1, + }); + expect(page1.items.map(e => e.metadata.name)).toEqual(['e1']); + expect(page1.totalItems).toBe(2); + expect(page1.pageInfo.nextCursor).toBeDefined(); + + const page2 = await client.queryEntities({ + cursor: page1.pageInfo.nextCursor!, + limit: 1, + }); + expect(page2.items.map(e => e.metadata.name)).toEqual(['e3']); + expect(page2.pageInfo.nextCursor).toBeUndefined(); + }); + it('throws InputError for invalid cursor', async () => { const client = new InMemoryCatalogClient({ entities }); await expect( diff --git a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts index e64706ff8d..5ffaafa16f 100644 --- a/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts +++ b/packages/catalog-client/src/testUtils/InMemoryCatalogClient.ts @@ -51,6 +51,7 @@ import { NotFoundError, NotImplementedError, } from '@backstage/errors'; +import { filterPredicateToFilterFunction } from '@backstage/filter-predicates'; import lodash from 'lodash'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { traverse } from '../../../../plugins/catalog-backend/src/database/operations/stitcher/buildEntitySearch'; @@ -373,6 +374,7 @@ export class InMemoryCatalogClient implements CatalogApi { ): Promise { // Decode query parameters from cursor or from the request directly let filter: EntityFilterQuery | undefined; + let query: Record | undefined; let orderFields: EntityOrderQuery | undefined; let fullTextFilter: { term: string; fields?: string[] } | undefined; let offset: number; @@ -386,12 +388,17 @@ export class InMemoryCatalogClient implements CatalogApi { throw new InputError('Invalid cursor'); } filter = deserializeFilter(c.filter as any[]); + query = c.query as Record | undefined; orderFields = c.orderFields as EntityOrderQuery | undefined; fullTextFilter = c.fullTextFilter as typeof fullTextFilter; offset = c.offset as number; limit = request.limit; } else { filter = request?.filter; + query = + request?.query && typeof request.query === 'object' + ? (request.query as Record) + : undefined; orderFields = request?.orderFields; fullTextFilter = request?.fullTextFilter; offset = request?.offset ?? 0; @@ -401,6 +408,11 @@ export class InMemoryCatalogClient implements CatalogApi { // Apply filter let items = this.#entities.filter(createFilter(filter)); + // Apply predicate-based query filter + if (query) { + items = items.filter(filterPredicateToFilterFunction(query)); + } + // Apply full-text filter, defaulting to the sort field or metadata.uid if (fullTextFilter) { const orderFieldsList = orderFields ? [orderFields].flat() : []; @@ -432,6 +444,7 @@ export class InMemoryCatalogClient implements CatalogApi { const cursorBase = { filter: serializeFilter(filter), + query, orderFields, fullTextFilter, totalItems,