diff --git a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx index de85fa1042..cd92605875 100644 --- a/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx +++ b/plugins/catalog-react/src/hooks/useEntityListProvider.test.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; +import type { GetEntitiesResponse } from '@backstage/catalog-client'; import { Entity } from '@backstage/catalog-model'; import { alertApiRef, @@ -23,7 +23,10 @@ import { identityApiRef, storageApiRef, } from '@backstage/core-plugin-api'; +import { translationApiRef } from '@backstage/core-plugin-api/alpha'; +import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils'; import { mockApis, TestApiProvider } from '@backstage/test-utils'; +import { useMountEffect } from '@react-hookz/web'; import { act, renderHook, waitFor } from '@testing-library/react'; import qs from 'qs'; import { PropsWithChildren } from 'react'; @@ -37,10 +40,9 @@ import { EntityTypeFilter, EntityUserFilter, } from '../filters'; -import { EntityListProvider, useEntityList } from './useEntityListProvider'; -import { useMountEffect } from '@react-hookz/web'; -import { translationApiRef } from '@backstage/core-plugin-api/alpha'; +import { MockPromise } from '../testUtils/MockPromise'; import { EntityListPagination } from '../types'; +import { EntityListProvider, useEntityList } from './useEntityListProvider'; const entities: Entity[] = [ { @@ -341,6 +343,62 @@ describe('', () => { filter: { kind: 'group' }, }); }); + + it('uses the last applied filter even if an earlier request finishes later', async () => { + const { result } = renderHook(() => useEntityList(), { + wrapper: createWrapper({ pagination }), + }); + + const firstResult = new MockPromise(); + const secondResult = new MockPromise(); + + await waitFor(() => { + expect(result.current.backendEntities.length).toBeGreaterThan(0); + }); + expect(result.current.totalItems).toBe(2); + expect(result.current.backendEntities.length).toBe(2); + expect(mockCatalogApi.getEntities).toHaveBeenCalledTimes(1); + + mockCatalogApi.getEntities!.mockReturnValueOnce(firstResult); + + await act(async () => { + result.current.updateFilters({ + kind: new EntityKindFilter('api', 'API'), + }); + }); + + await waitFor(() => { + expect(mockCatalogApi.getEntities).toHaveBeenNthCalledWith(2, { + filter: { kind: 'api' }, + }); + }); + + mockCatalogApi.getEntities!.mockReturnValueOnce(secondResult); + + await act(async () => { + result.current.updateFilters({ + kind: new EntityKindFilter('system', 'System'), + }); + }); + + await waitFor(() => { + expect(mockCatalogApi.getEntities).toHaveBeenNthCalledWith(3, { + filter: { kind: 'system' }, + }); + }); + + await act(async () => { + secondResult.resolve({ + items: [], + }); + firstResult.resolve({ + items: entities, + }); + }); + + expect(result.current.filters.kind!.value).toBe('system'); + expect(result.current.backendEntities.length).toBe(0); + }); }); describe('', () => { diff --git a/plugins/catalog-react/src/testUtils/MockPromise.test.ts b/plugins/catalog-react/src/testUtils/MockPromise.test.ts new file mode 100644 index 0000000000..6d6789f7a2 --- /dev/null +++ b/plugins/catalog-react/src/testUtils/MockPromise.test.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2025 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 { MockPromise } from './MockPromise.ts'; + +describe('MockPromise', () => { + it('is resolved when its resolve function is called', async () => { + const p = new MockPromise(); + p.resolve(12); + return expect(p).resolves.toBe(12); + }); + + it('is rejected when its reject function is called', async () => { + const p = new MockPromise(); + p.reject('Test rejection'); + return expect(p).rejects.toMatch('Test rejection'); + }); +}); diff --git a/plugins/catalog-react/src/testUtils/MockPromise.ts b/plugins/catalog-react/src/testUtils/MockPromise.ts new file mode 100644 index 0000000000..cc46eaafda --- /dev/null +++ b/plugins/catalog-react/src/testUtils/MockPromise.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2025 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. + */ +type ResolveFn = (value: T | PromiseLike) => void; +type RejectFn = (reason?: any) => void; +type Executor = (resolve: ResolveFn, reject: RejectFn) => void; + +export class MockPromise extends Promise { + private _resolve!: ResolveFn; + private _reject!: RejectFn; + + constructor(executor?: Executor) { + if (executor !== undefined) { + super(executor); + return; + } + + let res: ResolveFn; + let rej: RejectFn; + super((resolve, reject) => { + res = resolve; + rej = reject; + }); + this._resolve = res!; + this._reject = rej!; + } + + get resolve() { + return this._resolve; + } + + get reject() { + return this._reject; + } +}