Skip to content

Commit df6342b

Browse files
authored
fix: network error and incorrect backend response for operations (#2489)
1 parent 46e0c9e commit df6342b

File tree

5 files changed

+239
-7
lines changed

5 files changed

+239
-7
lines changed

src/containers/Operations/Operations.tsx

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
11
import React from 'react';
22

3-
import {AccessDenied} from '../../components/Errors/403';
4-
import {ResponseError} from '../../components/Errors/ResponseError';
3+
import {PageError} from '../../components/Errors/PageError/PageError';
54
import {ResizeableDataTable} from '../../components/ResizeableDataTable/ResizeableDataTable';
65
import {TableSkeleton} from '../../components/TableSkeleton/TableSkeleton';
76
import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout';
87
import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants';
9-
import {isAccessError} from '../../utils/response';
108

119
import {OperationsControls} from './OperationsControls';
1210
import {getColumns} from './columns';
@@ -41,8 +39,8 @@ export function Operations({database, scrollContainerRef}: OperationsProps) {
4139
};
4240
}, []);
4341

44-
if (isAccessError(error)) {
45-
return <AccessDenied position="left" />;
42+
if (error) {
43+
return <PageError error={error} position="left" />;
4644
}
4745

4846
return (
@@ -58,7 +56,6 @@ export function Operations({database, scrollContainerRef}: OperationsProps) {
5856
handleSearchChange={handleSearchChange}
5957
/>
6058
</TableWithControlsLayout.Controls>
61-
{error ? <ResponseError error={error} /> : null}
6259
<TableWithControlsLayout.Table loading={isLoading} className={b('table')}>
6360
{operations.length > 0 || isLoading ? (
6461
<ResizeableDataTable

src/store/reducers/operations.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,20 @@ import {api} from './api';
1010

1111
const DEFAULT_PAGE_SIZE = 20;
1212

13+
// Validate and normalize the response to ensure it has proper structure
14+
function validateOperationListResponse(data: TOperationList): TOperationList {
15+
// If operations array is missing, return empty operations and stop pagination
16+
if (!Array.isArray(data.operations)) {
17+
return {
18+
...data,
19+
operations: [],
20+
// Stop pagination by setting next_page_token to '0' (no more pages)
21+
next_page_token: '0',
22+
};
23+
}
24+
return data;
25+
}
26+
1327
export const operationsApi = api.injectEndpoints({
1428
endpoints: (build) => ({
1529
getOperationList: build.infiniteQuery<
@@ -33,7 +47,9 @@ export const operationsApi = api.injectEndpoints({
3347
page_token: pageParam,
3448
};
3549
const data = await window.api.operation.getOperationList(params, {signal});
36-
return {data};
50+
// Validate and normalize the response
51+
const validatedData = validateOperationListResponse(data);
52+
return {data: validatedData};
3753
} catch (error) {
3854
return {error};
3955
}

tests/suites/tenant/diagnostics/tabs/OperationsModel.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ export class OperationsTable extends BaseModel {
1515
private scrollContainer: Locator;
1616
private accessDeniedState: Locator;
1717
private accessDeniedTitle: Locator;
18+
private pageErrorState: Locator;
19+
private pageErrorTitle: Locator;
20+
private pageErrorDescription: Locator;
1821

1922
constructor(page: Page) {
2023
super(page, page.locator('.kv-tenant-diagnostics'));
@@ -27,6 +30,10 @@ export class OperationsTable extends BaseModel {
2730
// AccessDenied component is rendered at the root level of Operations component
2831
this.accessDeniedState = page.locator('.kv-tenant-diagnostics .empty-state');
2932
this.accessDeniedTitle = this.accessDeniedState.locator('.empty-state__title');
33+
// PageError component also uses empty-state but with error illustration
34+
this.pageErrorState = page.locator('.kv-tenant-diagnostics .empty-state');
35+
this.pageErrorTitle = this.pageErrorState.locator('.empty-state__title');
36+
this.pageErrorDescription = this.pageErrorState.locator('.empty-state__description .error');
3037
}
3138

3239
async waitForTableVisible() {
@@ -194,4 +201,22 @@ export class OperationsTable extends BaseModel {
194201
// Now get the actual new count
195202
return await this.getOperationsCount();
196203
}
204+
205+
async isPageErrorVisible(): Promise<boolean> {
206+
try {
207+
await this.pageErrorState.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
208+
// Check if it has error description to distinguish from access denied
209+
return await this.pageErrorDescription.isVisible();
210+
} catch {
211+
return false;
212+
}
213+
}
214+
215+
async getPageErrorTitle(): Promise<string> {
216+
return await this.pageErrorTitle.innerText();
217+
}
218+
219+
async getPageErrorDescription(): Promise<string> {
220+
return await this.pageErrorDescription.innerText();
221+
}
197222
}

tests/suites/tenant/diagnostics/tabs/operations.test.ts

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,11 @@ import {Diagnostics, DiagnosticsTab} from '../Diagnostics';
66

77
import {
88
setupEmptyOperationsMock,
9+
setupMalformedOperationsMock,
910
setupOperation403Mock,
11+
setupOperationNetworkErrorMock,
1012
setupOperationsMock,
13+
setupPartialMalformedOperationsMock,
1114
} from './operationsMocks';
1215

1316
test.describe('Operations Tab - Infinite Query', () => {
@@ -160,6 +163,129 @@ test.describe('Operations Tab - Infinite Query', () => {
160163
expect(accessDeniedTitle).toBe('Access denied');
161164
});
162165

166+
test('shows error state when operations request returns network error', async ({page}) => {
167+
// Setup network error mock (simulates CORS blocking)
168+
await setupOperationNetworkErrorMock(page);
169+
170+
const pageQueryParams = {
171+
schema: tenantName,
172+
database: tenantName,
173+
tenantPage: 'diagnostics',
174+
};
175+
176+
const tenantPageInstance = new TenantPage(page);
177+
await tenantPageInstance.goto(pageQueryParams);
178+
179+
const diagnostics = new Diagnostics(page);
180+
await diagnostics.clickTab(DiagnosticsTab.Operations);
181+
// Wait a bit for potential loading
182+
await page.waitForTimeout(2000);
183+
184+
// Wait for error state to be visible
185+
const isPageErrorVisible = await diagnostics.operations.isPageErrorVisible();
186+
expect(isPageErrorVisible).toBe(true);
187+
188+
// Verify the error title
189+
const errorTitle = await diagnostics.operations.getPageErrorTitle();
190+
expect(errorTitle).toBe('Error');
191+
192+
// Verify the error description shows network error
193+
const errorDescription = await diagnostics.operations.getPageErrorDescription();
194+
expect(errorDescription.toLowerCase()).toContain('network');
195+
});
196+
197+
test('handles malformed response without operations array', async ({page}) => {
198+
// Setup malformed response mock (returns status SUCCESS but no operations array)
199+
await setupMalformedOperationsMock(page);
200+
201+
const pageQueryParams = {
202+
schema: tenantName,
203+
database: tenantName,
204+
tenantPage: 'diagnostics',
205+
};
206+
207+
const tenantPageInstance = new TenantPage(page);
208+
await tenantPageInstance.goto(pageQueryParams);
209+
210+
const diagnostics = new Diagnostics(page);
211+
await diagnostics.clickTab(DiagnosticsTab.Operations);
212+
213+
// Wait for table to be visible
214+
await diagnostics.operations.waitForTableVisible();
215+
await diagnostics.operations.waitForDataLoad();
216+
217+
// Verify empty state is shown
218+
const isEmptyVisible = await diagnostics.operations.isEmptyStateVisible();
219+
expect(isEmptyVisible).toBe(true);
220+
221+
// Verify no data rows
222+
const rowCount = await diagnostics.operations.getRowCount();
223+
expect(rowCount).toBeLessThanOrEqual(1);
224+
225+
// Verify operations count is 0
226+
const operationsCount = await diagnostics.operations.getOperationsCount();
227+
expect(operationsCount).toBe(0);
228+
229+
// Wait to ensure no infinite refetching occurs
230+
await page.waitForTimeout(3000);
231+
232+
// Verify the count is still 0 (no infinite refetching)
233+
const finalOperationsCount = await diagnostics.operations.getOperationsCount();
234+
expect(finalOperationsCount).toBe(0);
235+
});
236+
237+
test('stops pagination when receiving malformed response after valid data', async ({page}) => {
238+
// Setup mock that returns valid data first, then malformed response
239+
await setupPartialMalformedOperationsMock(page);
240+
241+
const pageQueryParams = {
242+
schema: tenantName,
243+
database: tenantName,
244+
tenantPage: 'diagnostics',
245+
};
246+
247+
const tenantPageInstance = new TenantPage(page);
248+
await tenantPageInstance.goto(pageQueryParams);
249+
250+
const diagnostics = new Diagnostics(page);
251+
await diagnostics.clickTab(DiagnosticsTab.Operations);
252+
253+
// Wait for initial data to load
254+
await diagnostics.operations.waitForTableVisible();
255+
await diagnostics.operations.waitForDataLoad();
256+
257+
// Verify initial page loaded (should have 20 operations)
258+
const initialOperationsCount = await diagnostics.operations.getOperationsCount();
259+
expect(initialOperationsCount).toBe(20);
260+
261+
// Verify first row data
262+
const firstRowData = await diagnostics.operations.getRowData(0);
263+
expect(firstRowData['Operation ID']).toBeTruthy();
264+
265+
// Scroll to bottom to trigger next page load
266+
await diagnostics.operations.scrollToBottom();
267+
268+
// Wait a bit for potential loading
269+
await page.waitForTimeout(2000);
270+
271+
// Check if loading more appears and disappears
272+
const isLoadingVisible = await diagnostics.operations.isLoadingMoreVisible();
273+
if (isLoadingVisible) {
274+
await diagnostics.operations.waitForLoadingMoreToDisappear();
275+
}
276+
277+
// Verify the count remains at 20 (malformed response didn't add more)
278+
const finalOperationsCount = await diagnostics.operations.getOperationsCount();
279+
expect(finalOperationsCount).toBe(20);
280+
281+
// Wait to ensure no infinite refetching occurs
282+
await page.waitForTimeout(3000);
283+
284+
// Verify the count is still 20
285+
const stillFinalCount = await diagnostics.operations.getOperationsCount();
286+
expect(stillFinalCount).toBe(20);
287+
});
288+
163289
test('loads all operations when scrolling to the bottom multiple times', async ({page}) => {
164290
// Setup mocks with 80 operations (4 pages of 20)
165291
await setupOperationsMock(page, {totalOperations: 80});

tests/suites/tenant/diagnostics/tabs/operationsMocks.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,3 +245,71 @@ export const setupAllOperationMocks = async (page: Page, options?: {totalOperati
245245
await setupOperationsMock(page, options);
246246
await setupOperationMutationMocks(page);
247247
};
248+
249+
export const setupOperationNetworkErrorMock = async (page: Page) => {
250+
await page.route(`${backend}/operation/list*`, async (route) => {
251+
// Simulate a network error by aborting the request
252+
// This mimics what happens when CORS blocks a request
253+
await route.abort('failed');
254+
});
255+
};
256+
257+
export const setupMalformedOperationsMock = async (page: Page) => {
258+
await page.route(`${backend}/operation/list*`, async (route) => {
259+
// Return a response with status SUCCESS but missing operations array
260+
const response = {
261+
status: 'SUCCESS',
262+
next_page_token: '1',
263+
// operations array is missing
264+
};
265+
266+
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
267+
268+
await route.fulfill({
269+
status: 200,
270+
contentType: 'application/json',
271+
body: JSON.stringify(response),
272+
});
273+
});
274+
};
275+
276+
export const setupPartialMalformedOperationsMock = async (page: Page) => {
277+
let requestCount = 0;
278+
279+
await page.route(`${backend}/operation/list*`, async (route) => {
280+
requestCount++;
281+
282+
// First request returns valid data
283+
if (requestCount === 1) {
284+
const operations = generateBuildIndexOperations(0, 20);
285+
const response = {
286+
next_page_token: '1',
287+
status: 'SUCCESS',
288+
operations,
289+
};
290+
291+
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
292+
293+
await route.fulfill({
294+
status: 200,
295+
contentType: 'application/json',
296+
body: JSON.stringify(response),
297+
});
298+
} else {
299+
// Subsequent requests return malformed response
300+
const response = {
301+
status: 'SUCCESS',
302+
next_page_token: '2',
303+
// operations array is missing
304+
};
305+
306+
await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));
307+
308+
await route.fulfill({
309+
status: 200,
310+
contentType: 'application/json',
311+
body: JSON.stringify(response),
312+
});
313+
}
314+
});
315+
};

0 commit comments

Comments
 (0)