Skip to content

fix: network error and incorrect backend response for operations #2489

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Jun 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 3 additions & 6 deletions src/containers/Operations/Operations.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import React from 'react';

import {AccessDenied} from '../../components/Errors/403';
import {ResponseError} from '../../components/Errors/ResponseError';
import {PageError} from '../../components/Errors/PageError/PageError';
import {ResizeableDataTable} from '../../components/ResizeableDataTable/ResizeableDataTable';
import {TableSkeleton} from '../../components/TableSkeleton/TableSkeleton';
import {TableWithControlsLayout} from '../../components/TableWithControlsLayout/TableWithControlsLayout';
import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants';
import {isAccessError} from '../../utils/response';

import {OperationsControls} from './OperationsControls';
import {getColumns} from './columns';
Expand Down Expand Up @@ -41,8 +39,8 @@ export function Operations({database, scrollContainerRef}: OperationsProps) {
};
}, []);

if (isAccessError(error)) {
return <AccessDenied position="left" />;
if (error) {
return <PageError error={error} position="left" />;
}

return (
Expand All @@ -58,7 +56,6 @@ export function Operations({database, scrollContainerRef}: OperationsProps) {
handleSearchChange={handleSearchChange}
/>
</TableWithControlsLayout.Controls>
{error ? <ResponseError error={error} /> : null}
<TableWithControlsLayout.Table loading={isLoading} className={b('table')}>
{operations.length > 0 || isLoading ? (
<ResizeableDataTable
Expand Down
18 changes: 17 additions & 1 deletion src/store/reducers/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,20 @@ import {api} from './api';

const DEFAULT_PAGE_SIZE = 20;

// Validate and normalize the response to ensure it has proper structure
function validateOperationListResponse(data: TOperationList): TOperationList {
// If operations array is missing, return empty operations and stop pagination
if (!Array.isArray(data.operations)) {
return {
...data,
operations: [],
// Stop pagination by setting next_page_token to '0' (no more pages)
next_page_token: '0',
};
}
return data;
}

export const operationsApi = api.injectEndpoints({
endpoints: (build) => ({
getOperationList: build.infiniteQuery<
Expand All @@ -33,7 +47,9 @@ export const operationsApi = api.injectEndpoints({
page_token: pageParam,
};
const data = await window.api.operation.getOperationList(params, {signal});
return {data};
// Validate and normalize the response
const validatedData = validateOperationListResponse(data);
return {data: validatedData};
} catch (error) {
return {error};
}
Expand Down
25 changes: 25 additions & 0 deletions tests/suites/tenant/diagnostics/tabs/OperationsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ export class OperationsTable extends BaseModel {
private scrollContainer: Locator;
private accessDeniedState: Locator;
private accessDeniedTitle: Locator;
private pageErrorState: Locator;
private pageErrorTitle: Locator;
private pageErrorDescription: Locator;

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

async waitForTableVisible() {
Expand Down Expand Up @@ -194,4 +201,22 @@ export class OperationsTable extends BaseModel {
// Now get the actual new count
return await this.getOperationsCount();
}

async isPageErrorVisible(): Promise<boolean> {
try {
await this.pageErrorState.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
// Check if it has error description to distinguish from access denied
return await this.pageErrorDescription.isVisible();
} catch {
return false;
}
}

async getPageErrorTitle(): Promise<string> {
return await this.pageErrorTitle.innerText();
}

async getPageErrorDescription(): Promise<string> {
return await this.pageErrorDescription.innerText();
}
}
126 changes: 126 additions & 0 deletions tests/suites/tenant/diagnostics/tabs/operations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import {Diagnostics, DiagnosticsTab} from '../Diagnostics';

import {
setupEmptyOperationsMock,
setupMalformedOperationsMock,
setupOperation403Mock,
setupOperationNetworkErrorMock,
setupOperationsMock,
setupPartialMalformedOperationsMock,
} from './operationsMocks';

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

test('shows error state when operations request returns network error', async ({page}) => {
// Setup network error mock (simulates CORS blocking)
await setupOperationNetworkErrorMock(page);

const pageQueryParams = {
schema: tenantName,
database: tenantName,
tenantPage: 'diagnostics',
};

const tenantPageInstance = new TenantPage(page);
await tenantPageInstance.goto(pageQueryParams);

const diagnostics = new Diagnostics(page);
await diagnostics.clickTab(DiagnosticsTab.Operations);
// Wait a bit for potential loading
await page.waitForTimeout(2000);

// Wait for error state to be visible
const isPageErrorVisible = await diagnostics.operations.isPageErrorVisible();
expect(isPageErrorVisible).toBe(true);

// Verify the error title
const errorTitle = await diagnostics.operations.getPageErrorTitle();
expect(errorTitle).toBe('Error');

// Verify the error description shows network error
const errorDescription = await diagnostics.operations.getPageErrorDescription();
expect(errorDescription.toLowerCase()).toContain('network');
});

test('handles malformed response without operations array', async ({page}) => {
// Setup malformed response mock (returns status SUCCESS but no operations array)
await setupMalformedOperationsMock(page);

const pageQueryParams = {
schema: tenantName,
database: tenantName,
tenantPage: 'diagnostics',
};

const tenantPageInstance = new TenantPage(page);
await tenantPageInstance.goto(pageQueryParams);

const diagnostics = new Diagnostics(page);
await diagnostics.clickTab(DiagnosticsTab.Operations);

// Wait for table to be visible
await diagnostics.operations.waitForTableVisible();
await diagnostics.operations.waitForDataLoad();

// Verify empty state is shown
const isEmptyVisible = await diagnostics.operations.isEmptyStateVisible();
expect(isEmptyVisible).toBe(true);

// Verify no data rows
const rowCount = await diagnostics.operations.getRowCount();
expect(rowCount).toBeLessThanOrEqual(1);

// Verify operations count is 0
const operationsCount = await diagnostics.operations.getOperationsCount();
expect(operationsCount).toBe(0);

// Wait to ensure no infinite refetching occurs
await page.waitForTimeout(3000);

// Verify the count is still 0 (no infinite refetching)
const finalOperationsCount = await diagnostics.operations.getOperationsCount();
expect(finalOperationsCount).toBe(0);
});

test('stops pagination when receiving malformed response after valid data', async ({page}) => {
// Setup mock that returns valid data first, then malformed response
await setupPartialMalformedOperationsMock(page);

const pageQueryParams = {
schema: tenantName,
database: tenantName,
tenantPage: 'diagnostics',
};

const tenantPageInstance = new TenantPage(page);
await tenantPageInstance.goto(pageQueryParams);

const diagnostics = new Diagnostics(page);
await diagnostics.clickTab(DiagnosticsTab.Operations);

// Wait for initial data to load
await diagnostics.operations.waitForTableVisible();
await diagnostics.operations.waitForDataLoad();

// Verify initial page loaded (should have 20 operations)
const initialOperationsCount = await diagnostics.operations.getOperationsCount();
expect(initialOperationsCount).toBe(20);

// Verify first row data
const firstRowData = await diagnostics.operations.getRowData(0);
expect(firstRowData['Operation ID']).toBeTruthy();

// Scroll to bottom to trigger next page load
await diagnostics.operations.scrollToBottom();

// Wait a bit for potential loading
await page.waitForTimeout(2000);

// Check if loading more appears and disappears
const isLoadingVisible = await diagnostics.operations.isLoadingMoreVisible();
if (isLoadingVisible) {
await diagnostics.operations.waitForLoadingMoreToDisappear();
}

// Verify the count remains at 20 (malformed response didn't add more)
const finalOperationsCount = await diagnostics.operations.getOperationsCount();
expect(finalOperationsCount).toBe(20);

// Wait to ensure no infinite refetching occurs
await page.waitForTimeout(3000);

// Verify the count is still 20
const stillFinalCount = await diagnostics.operations.getOperationsCount();
expect(stillFinalCount).toBe(20);
});

test('loads all operations when scrolling to the bottom multiple times', async ({page}) => {
// Setup mocks with 80 operations (4 pages of 20)
await setupOperationsMock(page, {totalOperations: 80});
Expand Down
68 changes: 68 additions & 0 deletions tests/suites/tenant/diagnostics/tabs/operationsMocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,3 +245,71 @@ export const setupAllOperationMocks = async (page: Page, options?: {totalOperati
await setupOperationsMock(page, options);
await setupOperationMutationMocks(page);
};

export const setupOperationNetworkErrorMock = async (page: Page) => {
await page.route(`${backend}/operation/list*`, async (route) => {
// Simulate a network error by aborting the request
// This mimics what happens when CORS blocks a request
await route.abort('failed');
});
};

export const setupMalformedOperationsMock = async (page: Page) => {
await page.route(`${backend}/operation/list*`, async (route) => {
// Return a response with status SUCCESS but missing operations array
const response = {
status: 'SUCCESS',
next_page_token: '1',
// operations array is missing
};

await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
});
};

export const setupPartialMalformedOperationsMock = async (page: Page) => {
let requestCount = 0;

await page.route(`${backend}/operation/list*`, async (route) => {
requestCount++;

// First request returns valid data
if (requestCount === 1) {
const operations = generateBuildIndexOperations(0, 20);
const response = {
next_page_token: '1',
status: 'SUCCESS',
operations,
};

await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
} else {
// Subsequent requests return malformed response
const response = {
status: 'SUCCESS',
next_page_token: '2',
// operations array is missing
};

await new Promise((resolve) => setTimeout(resolve, MOCK_DELAY));

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
});
}
});
};
Loading