Add tests to demonstrate a race condition in EntityListProvider

Signed-off-by: Jacob Raihle kdm951 <jacob.raihle@teliacompany.com>
This commit is contained in:
Jacob Raihle kdm951
2025-07-15 16:28:52 +03:00
parent abe4d879f8
commit 097a334228
3 changed files with 140 additions and 4 deletions
@@ -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('<EntityListProvider />', () => {
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<GetEntitiesResponse>();
const secondResult = new MockPromise<GetEntitiesResponse>();
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('<EntityListProvider pagination />', () => {
@@ -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<number>();
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');
});
});
@@ -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<T> = (value: T | PromiseLike<T>) => void;
type RejectFn = (reason?: any) => void;
type Executor<T> = (resolve: ResolveFn<T>, reject: RejectFn) => void;
export class MockPromise<T> extends Promise<T> {
private _resolve!: ResolveFn<T>;
private _reject!: RejectFn;
constructor(executor?: Executor<T>) {
if (executor !== undefined) {
super(executor);
return;
}
let res: ResolveFn<T>;
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;
}
}