diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/heatmap/Heatmap.test.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/heatmap/Heatmap.test.tsx new file mode 100644 index 000000000000..826c52659a34 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/heatmap/Heatmap.test.tsx @@ -0,0 +1,267 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi } from 'vitest'; + +import Heatmap from '@/v2/pages/heatmap/heatmap'; +import * as commonUtils from '@/utils/common'; +import { + heatmapRequests, + heatmapServer, + heatmapServerErrorHandler, + heatmapUnavailableHandler, + resetHeatmapRequestTracking +} from '@tests/mocks/heatmapMocks/heatmapServer'; + +vi.spyOn(commonUtils, 'showDataFetchError'); + +const { collectDrillDownTiles } = vi.hoisted(() => { + type MockHeatmapNode = { + label?: string | null; + path?: string; + color?: number; + children?: MockHeatmapNode[]; + }; + + /** Non-leaf treemap tiles (no color, has path) call onClick — same rule as HeatmapPlot nodeClick. */ + function collectDrillDownTiles(node: MockHeatmapNode, onClick: (path: string) => void): React.ReactNode[] { + const tiles: React.ReactNode[] = []; + node.children?.forEach((child) => { + if (child.path && child.color === undefined) { + tiles.push( +
onClick(child.path!)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + onClick(child.path!); + } + }} + > + {child.label} +
+ ); + } + tiles.push(...collectDrillDownTiles(child, onClick)); + }); + return tiles; + } + + return { collectDrillDownTiles }; +}); + +vi.mock('@/v2/components/plots/heatmapPlot', () => ({ + default: ({ data, onClick, entityType }: { + data: { label?: string | null; path?: string; color?: number; children?: unknown[] }; + onClick: (path: string) => void; + entityType: string; + }) => ( +
+ {collectDrillDownTiles(data, onClick)} +
+ ), +})); + +vi.mock('@/v2/hooks/useAPIData.hook', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useApiData: (url: string, defaultValue: T, options = {}) => { + // The hook's own initial GET does not handle a rejected request, so a 404/500 on mount surfaces as an + // unhandled rejection in tests. Skip it and issue the initial request with a handled promise instead. + const api = actual.useApiData(url, defaultValue, { + ...options, + initialFetch: false, + retryAttempts: 0, + retryDelay: 0, + }); + React.useEffect(() => { + if (url) { + api.execute().catch(() => undefined); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + return api; + }, + }; +}); + +const waitForPlot = (label: string) => waitFor(() => { + expect(screen.getByTestId('heatmap-plot')).toHaveAttribute('data-label', label); +}); + +const searchPath = async (path: string) => { + const pathInput = screen.getByPlaceholderText('/'); + await userEvent.clear(pathInput); + if (path) { + await userEvent.type(pathInput, path); + } + fireEvent.click(screen.getByRole('button', { name: /search/i })); +}; + +// antd Dropdown opens its overlay on hover after a short delay. +const selectFromDropdown = async (trigger: RegExp, item: string) => { + fireEvent.mouseOver(screen.getByRole('button', { name: trigger })); + fireEvent.click(await screen.findByText(item)); +}; + +// Give any request fired on click a chance to reach the mock server before asserting none was sent. +const flushPendingRequests = () => new Promise((resolve) => setTimeout(resolve, 50)); + +describe('Heatmap page', () => { + beforeAll(() => heatmapServer.listen()); + + beforeEach(() => { + resetHeatmapRequestTracking(); + vi.mocked(commonUtils.showDataFetchError).mockClear(); + }); + + afterEach(() => heatmapServer.resetHandlers()); + afterAll(() => heatmapServer.close()); + + test('renders header, filters, legend, and heatmap plot with data', async () => { + render(); + + expect(screen.getByText('Heatmap')).toBeInTheDocument(); + expect(screen.getByText('Path')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('/')).toHaveValue('/'); + expect(screen.getByText(/Entity Type:\s*key/)).toBeInTheDocument(); + expect(screen.getByText(/Last\s+24H/)).toBeInTheDocument(); + expect(screen.getByText('Less Accessed')).toBeInTheDocument(); + expect(screen.getByText('Moderate Accessed')).toBeInTheDocument(); + expect(screen.getByText('Most Accessed')).toBeInTheDocument(); + + await waitForPlot('root'); + expect(screen.getByTestId('heatmap-plot')).toHaveAttribute('data-entity-type', 'key'); + expect(heatmapRequests).toEqual([{ path: '/', entityType: 'key', startDate: '24H' }]); + }); + + test('shows validation error for invalid path characters', async () => { + render(); + await waitForPlot('root'); + + const pathInput = screen.getByPlaceholderText('/'); + await userEvent.clear(pathInput); + await userEvent.type(pathInput, 'bad@path'); + + expect(screen.getByText('Please enter valid path')).toBeInTheDocument(); + }); + + test('fetches heatmap for a new path when the user searches', async () => { + render(); + await waitForPlot('root'); + + await searchPath('/vol1'); + + await waitForPlot('vol1'); + expect(heatmapRequests.map((request) => request.path)).toEqual(['/', '/vol1']); + }); + + test('does not fetch when searching with an empty path', async () => { + render(); + await waitForPlot('root'); + + await searchPath(''); + await flushPendingRequests(); + + expect(heatmapRequests).toHaveLength(1); + expect(screen.getByTestId('heatmap-plot')).toHaveAttribute('data-label', 'root'); + }); + + test('drills down into the clicked entity and updates the path input', async () => { + render(); + await waitForPlot('root'); + + fireEvent.click(screen.getByTestId('heatmap-tile-vol1')); + + await waitForPlot('vol1'); + expect(screen.getByPlaceholderText('/')).toHaveValue('/vol1'); + expect(heatmapRequests.map((request) => request.path)).toContain('/vol1'); + }); + + test('refetches heatmap when the entity type is changed', async () => { + render(); + await waitForPlot('root'); + + await selectFromDropdown(/Entity Type/, 'Bucket'); + + await waitFor(() => { + expect(heatmapRequests).toContainEqual({ path: '/', entityType: 'bucket', startDate: '24H' }); + }); + expect(screen.getByText(/Entity Type:\s*bucket/)).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId('heatmap-plot')).toHaveAttribute('data-entity-type', 'bucket'); + }); + }); + + test('refetches heatmap when the time period is changed', async () => { + render(); + await waitForPlot('root'); + + await selectFromDropdown(/^Last/, '7 Days'); + + await waitFor(() => { + expect(heatmapRequests).toContainEqual({ path: '/', entityType: 'key', startDate: '7D' }); + }); + expect(screen.getByText(/Last\s+7D/)).toBeInTheDocument(); + }); + + test('shows no data warning when the API returns empty labels', async () => { + render(); + await waitForPlot('root'); + + await searchPath('/empty'); + + await waitFor(() => { + expect(screen.getByText('No Data available')).toBeInTheDocument(); + }); + expect(screen.queryByTestId('heatmap-plot')).not.toBeInTheDocument(); + }); + + test('shows disabled state when heatmap API returns 404', async () => { + heatmapServer.use(heatmapUnavailableHandler); + render(); + + await waitFor(() => { + expect(screen.getByText('Heatmap Not Available')).toBeInTheDocument(); + }); + expect( + screen.getByText(/Please ensure Heatmap is enabled in the configs and you have sufficient permissions/i) + ).toBeInTheDocument(); + expect(screen.queryByTestId('heatmap-plot')).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText('/')).not.toBeInTheDocument(); + expect(commonUtils.showDataFetchError).not.toHaveBeenCalled(); + }); + + test('shows fetch error state when heatmap API returns 500', async () => { + heatmapServer.use(heatmapServerErrorHandler); + render(); + + await waitFor(() => { + expect(screen.getByText('Failed to fetch Heatmap')).toBeInTheDocument(); + }); + expect(screen.getByText('Check for any failed requests for more information')).toBeInTheDocument(); + expect(commonUtils.showDataFetchError).toHaveBeenCalledWith('Request failed with status code 500'); + expect(screen.queryByTestId('heatmap-plot')).not.toBeInTheDocument(); + }); +}); diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/heatmap/HeatmapPlot.test.tsx b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/heatmap/HeatmapPlot.test.tsx new file mode 100644 index 000000000000..7bc76fad8047 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/heatmap/HeatmapPlot.test.tsx @@ -0,0 +1,129 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 React from 'react'; +import { render } from '@testing-library/react'; +import { vi } from 'vitest'; + +import HeatmapPlot from '@/v2/components/plots/heatmapPlot'; +import * as CONSTANTS from '@/v2/constants/heatmap.constants'; +import { HeatmapResponse } from '@/v2/types/heatmap.types'; + +type AgChartOptions = { + title?: { text?: string }; + series?: Array<{ + tooltip?: { + renderer: (params: { datum: Record }) => { content: string }; + }; + }>; +}; + +let lastChartOptions: AgChartOptions | undefined; + +vi.mock('ag-charts-react', () => ({ + AgChartsReact: ({ options }: { options: AgChartOptions }) => { + lastChartOptions = options; + return
; + }, +})); + +const rootHeatmapData: HeatmapResponse = { + label: 'root', + path: '/', + size: 512, + minAccessCount: 10, + maxAccessCount: 100, + children: [], +}; + +function renderTooltipContent(datum: Record): string { + if (!lastChartOptions?.series?.[0]?.tooltip?.renderer) { + throw new Error('HeatmapPlot did not configure a treemap tooltip renderer'); + } + return lastChartOptions.series[0].tooltip.renderer({ datum }).content; +} + +describe('HeatmapPlot tooltips', () => { + beforeEach(() => { + lastChartOptions = undefined; + }); + + test('shows size, access count, and entity name for a leaf tile (hover tooltip content)', () => { + render( + + ); + + const content = renderTooltipContent({ + label: 'clusterbuck33', + size: 256, + accessCount: 3832, + color: 1, + }); + + expect(content).toContain('Size:'); + expect(content).toContain('256 Bytes'); + expect(content).toContain('Access count:'); + expect(content).toContain('3832'); + expect(content).toContain('Entity Name:'); + expect(content).toContain('clusterbuck33'); + }); + + test('shows max access count for a non-leaf group tile (hover tooltip content)', () => { + render( + + ); + + const content = renderTooltipContent({ + label: 'vol1', + path: '/vol1', + size: 512, + maxAccessCount: 1220, + }); + + expect(content).not.toContain('Access count:'); + expect(content).toContain('Size:'); + expect(content).toContain('512 Bytes'); + expect(content).toContain('Max Access Count:'); + expect(content).toContain('1220'); + expect(content).toContain('Entity Name:'); + expect(content).toContain('vol1'); + }); + + test('uses entity type in chart title', () => { + render( + + ); + + expect(lastChartOptions?.title?.text).toBe('Bucket Heatmap'); + }); +}); diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/heatmapMocks/heatmapResponseMocks.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/heatmapMocks/heatmapResponseMocks.ts new file mode 100644 index 000000000000..0c4a76022877 --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/heatmapMocks/heatmapResponseMocks.ts @@ -0,0 +1,88 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +/** Root key heatmap (entityType=key, path=/). */ +export const RootKeyHeatmap = { + label: 'root', + path: '/', + size: 512, + minAccessCount: 10, + maxAccessCount: 100, + children: [ + { + label: 'vol1', + path: '/vol1', + size: 512, + minAccessCount: 10, + maxAccessCount: 100, + children: [ + { + label: 'bucket1', + path: '/vol1/bucket1', + size: 256, + minAccessCount: 10, + maxAccessCount: 100, + children: [ + { + label: 'demo-key', + size: 256, + accessCount: 84, + color: 1 + } + ] + } + ] + } + ] +}; + +/** Key heatmap scoped to /vol1 (used when the user searches that path). */ +export const Vol1KeyHeatmap = { + label: 'vol1', + path: '/vol1', + size: 512, + minAccessCount: 20, + maxAccessCount: 200, + children: [ + { + label: 'bucket1', + path: '/vol1/bucket1', + size: 256, + minAccessCount: 20, + maxAccessCount: 200, + children: [ + { + label: 'scoped-key', + size: 128, + accessCount: 42, + color: 0.75 + } + ] + } + ] +}; + +/** Response that drives the "No Data available" warning state. */ +export const EmptyHeatmap = { + label: null, + path: null, + size: 0, + minAccessCount: 0, + maxAccessCount: 0, + children: [] +}; diff --git a/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/heatmapMocks/heatmapServer.ts b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/heatmapMocks/heatmapServer.ts new file mode 100644 index 000000000000..f40eb6bf26db --- /dev/null +++ b/hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/heatmapMocks/heatmapServer.ts @@ -0,0 +1,59 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; + +import * as mockResponses from './heatmapResponseMocks'; + +export type HeatmapRequest = { + path: string; + entityType: string | null; + startDate: string | null; +}; + +/** Query params of each intercepted heatmap request (for tests). */ +export const heatmapRequests: HeatmapRequest[] = []; + +export function resetHeatmapRequestTracking(): void { + heatmapRequests.length = 0; +} + +const heatmapHandler = http.get('/api/v1/heatmap/readaccess', ({ request }) => { + const params = new URL(request.url).searchParams; + const path = params.get('path') ?? '/'; + heatmapRequests.push({ path, entityType: params.get('entityType'), startDate: params.get('startDate') }); + + if (path === '/empty') { + return HttpResponse.json(mockResponses.EmptyHeatmap); + } + if (path === '/vol1') { + return HttpResponse.json(mockResponses.Vol1KeyHeatmap); + } + return HttpResponse.json(mockResponses.RootKeyHeatmap); +}); + +export const heatmapUnavailableHandler = http.get('/api/v1/heatmap/readaccess', () => { + return new HttpResponse(null, { status: 404 }); +}); + +export const heatmapServerErrorHandler = http.get('/api/v1/heatmap/readaccess', () => { + return HttpResponse.json({ message: 'Internal error' }, { status: 500 }); +}); + +export const heatmapServer = setupServer(heatmapHandler);