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 ? ( ({ 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/OperationsModel.ts b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts index e1908fe7d..c6bbd1f62 100644 --- a/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts +++ b/tests/suites/tenant/diagnostics/tabs/OperationsModel.ts @@ -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')); @@ -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() { @@ -194,4 +201,22 @@ export class OperationsTable extends BaseModel { // Now get the actual new count return await this.getOperationsCount(); } + + async isPageErrorVisible(): Promise { + 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..4928c2dac 100644 --- a/tests/suites/tenant/diagnostics/tabs/operations.test.ts +++ b/tests/suites/tenant/diagnostics/tabs/operations.test.ts @@ -6,8 +6,11 @@ import {Diagnostics, DiagnosticsTab} from '../Diagnostics'; import { setupEmptyOperationsMock, + setupMalformedOperationsMock, setupOperation403Mock, + setupOperationNetworkErrorMock, setupOperationsMock, + setupPartialMalformedOperationsMock, } from './operationsMocks'; test.describe('Operations Tab - Infinite Query', () => { @@ -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}); diff --git a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts index ee333dc07..0365f7474 100644 --- a/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts +++ b/tests/suites/tenant/diagnostics/tabs/operationsMocks.ts @@ -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), + }); + } + }); +};