From 8ff173e7f171eb017ec46e23767c5da6cd4b1a92 Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Thu, 26 Jun 2025 13:59:28 +0300 Subject: [PATCH 1/2] fix: fix: handle incorrect response for operations --- src/containers/Operations/Operations.tsx | 9 ++---- .../diagnostics/tabs/OperationsModel.ts | 25 +++++++++++++++ .../diagnostics/tabs/operations.test.ts | 32 +++++++++++++++++++ .../diagnostics/tabs/operationsMocks.ts | 8 +++++ 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/src/containers/Operations/Operations.tsx b/src/containers/Operations/Operations.tsx index 3285ca681..5749621bf 100644 --- a/src/containers/Operations/Operations.tsx +++ b/src/containers/Operations/Operations.tsx @@ -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'; @@ -41,8 +39,8 @@ export function Operations({database, scrollContainerRef}: OperationsProps) { }; }, []); - if (isAccessError(error)) { - return ; + if (error) { + return ; } return ( @@ -58,7 +56,6 @@ export function Operations({database, scrollContainerRef}: OperationsProps) { handleSearchChange={handleSearchChange} /> - {error ? : null} {operations.length > 0 || isLoading ? ( { + 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 { + return await this.pageErrorTitle.innerText(); + } + + async getPageErrorDescription(): Promise { + return await this.pageErrorDescription.innerText(); + } } diff --git a/tests/suites/tenant/diagnostics/tabs/operations.test.ts b/tests/suites/tenant/diagnostics/tabs/operations.test.ts index e719454ec..551b5f3cb 100644 --- a/tests/suites/tenant/diagnostics/tabs/operations.test.ts +++ b/tests/suites/tenant/diagnostics/tabs/operations.test.ts @@ -7,6 +7,7 @@ import {Diagnostics, DiagnosticsTab} from '../Diagnostics'; import { setupEmptyOperationsMock, setupOperation403Mock, + setupOperationNetworkErrorMock, setupOperationsMock, } from './operationsMocks'; @@ -160,6 +161,37 @@ 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('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}); diff --git a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts index ee333dc07..57c1ac682 100644 --- a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts +++ b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts @@ -245,3 +245,11 @@ 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'); + }); +}; From 5c27d60dd3f463f48e504571180fdb646e76d7fa Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Thu, 26 Jun 2025 14:08:05 +0300 Subject: [PATCH 2/2] fix: fix: handle incorrect response for operations --- src/store/reducers/operations.ts | 18 +++- .../diagnostics/tabs/operations.test.ts | 94 +++++++++++++++++++ .../diagnostics/tabs/operationsMocks.ts | 60 ++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/store/reducers/operations.ts b/src/store/reducers/operations.ts index 2b630f541..2d19b1683 100644 --- a/src/store/reducers/operations.ts +++ b/src/store/reducers/operations.ts @@ -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< @@ -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}; } diff --git a/tests/suites/tenant/diagnostics/tabs/operations.test.ts b/tests/suites/tenant/diagnostics/tabs/operations.test.ts index 551b5f3cb..4928c2dac 100644 --- a/tests/suites/tenant/diagnostics/tabs/operations.test.ts +++ b/tests/suites/tenant/diagnostics/tabs/operations.test.ts @@ -6,9 +6,11 @@ import {Diagnostics, DiagnosticsTab} from '../Diagnostics'; import { setupEmptyOperationsMock, + setupMalformedOperationsMock, setupOperation403Mock, setupOperationNetworkErrorMock, setupOperationsMock, + setupPartialMalformedOperationsMock, } from './operationsMocks'; test.describe('Operations Tab - Infinite Query', () => { @@ -192,6 +194,98 @@ test.describe('Operations Tab - Infinite Query', () => { 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}); diff --git a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts index 57c1ac682..0365f7474 100644 --- a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts +++ b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts @@ -253,3 +253,63 @@ export const setupOperationNetworkErrorMock = async (page: Page) => { 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), + }); + } + }); +};