chihsuan commented on code in PR #11513: URL: https://github.com/apache/ozone/pull/11513#discussion_r4269228294
########## hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/namespaceUsage/NamespaceUsage.test.tsx: ########## @@ -0,0 +1,205 @@ +/* + * 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, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { vi } from 'vitest'; + +import NamespaceUsage from '@/v2/pages/namespaceUsage/namespaceUsage'; +import * as commonUtils from '@/utils/common'; +import { + namespaceUsageServer, + resetNamespaceUsageRequestTracking, + usagePathsRequested +} from '@tests/mocks/namespaceUsageMocks/namespaceUsageServer'; +import * as mockResponses from '@tests/mocks/namespaceUsageMocks/namespaceUsageResponseMocks'; + +vi.spyOn(commonUtils, 'showDataFetchError'); +vi.spyOn(commonUtils, 'showInfoNotification'); + +vi.mock('@/v2/components/eChart/eChart', () => ({ + default: () => <div data-testid="nu-echart" /> +})); + +vi.mock('@/v2/hooks/useAPIData.hook', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/v2/hooks/useAPIData.hook')>(); + return { + ...actual, + useApiData: <T,>(url: string, defaultValue: T, options = {}) => + actual.useApiData(url, defaultValue, { + ...options, + retryAttempts: 0, + retryDelay: 0 + }) + }; +}); + +describe('Namespace Usage page (Disk Usage)', () => { + beforeAll(() => namespaceUsageServer.listen()); + + beforeEach(() => { + resetNamespaceUsageRequestTracking(); + vi.mocked(commonUtils.showDataFetchError).mockClear(); + vi.mocked(commonUtils.showInfoNotification).mockClear(); + }); + + afterEach(() => namespaceUsageServer.resetHandlers()); + afterAll(() => namespaceUsageServer.close()); + + const rootUsageRequestCount = () => + usagePathsRequested.filter((path) => path === '/').length; + + const confirmRootLoad = async () => { + fireEvent.click(screen.getByRole('button', { name: 'Load usage' })); + expect(screen.getByText('Confirm Root Path Usage Load')).toBeInTheDocument(); + const modal = document.querySelector('.ant-modal') as HTMLElement; + await userEvent.click(within(modal).getByRole('button', { name: /^ok$/i })); + }; + + const openBreadcrumbMenu = async () => { + const trigger = document.querySelector('.ant-breadcrumb-overlay-link') as HTMLElement; + await userEvent.click(trigger); + }; + + const waitForMetadataEntityType = async (entityType: string) => { + const metadataTable = screen.getByRole('table'); + await waitFor(() => { + const entityTypeRow = within(metadataTable).getByText('Entity Type').closest('tr'); + expect(entityTypeRow).toHaveTextContent(entityType); + }); + return metadataTable; + }; + + const clickBreadcrumbSubmenuItem = async (label: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + await userEvent.click(within(menu).getByRole('menuitem', { name: label })); + }; + + const searchPathFromBreadcrumb = async (pathSegment: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + const searchInput = within(menu).getByPlaceholderText('Enter Path'); + await userEvent.type(searchInput, pathSegment); + const searchButton = searchInput + .closest('.ant-input-search') + ?.querySelector('button.ant-input-search-button') as HTMLButtonElement; + await userEvent.click(searchButton); + }; + + test('does not fetch root usage until the user confirms load', async () => { + render(<NamespaceUsage />); + + expect(screen.getByText('Namespace Usage')).toBeInTheDocument(); + expect(screen.getByText(/Root path usage is not loaded by default/i)).toBeInTheDocument(); + expect(usagePathsRequested).toEqual([]); Review Comment: This runs before the mock server sees any request, so it still passes if the page loads root on mount. Should we wait a tick first? ########## hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/namespaceUsageMocks/namespaceUsageResponseMocks.ts: ########## @@ -0,0 +1,190 @@ +/* + * 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 { NUResponse } from '@/v2/types/namespaceUsage.types'; + +export const RootNamespaceUsage: NUResponse = { + status: 'OK', + path: '/', + size: 10000, + sizeWithReplica: -1, + subPathCount: 2, + sizeDirectKey: -1, + subPaths: [ + { path: '/vol1', size: 6000, sizeWithReplica: -1, isKey: false }, + { path: '/vol2', size: 4000, sizeWithReplica: -1, isKey: false } + ] +}; + +export const Vol1NamespaceUsage: NUResponse = { + status: 'OK', + path: '/vol1', + size: 6000, + sizeWithReplica: -1, + subPathCount: 1, + sizeDirectKey: 0, + subPaths: [ + { path: '/vol1/bucket1', size: 6000, sizeWithReplica: -1, isKey: false } + ] +}; + +export const Bucket1NamespaceUsage: NUResponse = { + status: 'OK', + path: '/vol1/bucket1', + size: 6000, + sizeWithReplica: -1, + subPathCount: 1, + sizeDirectKey: 3000, + subPaths: [ + { path: '/vol1/bucket1/key1', size: 3000, sizeWithReplica: -1, isKey: true } + ] +}; + +export const Key1NamespaceUsage: NUResponse = { + status: 'OK', + path: '/vol1/bucket1/key1', + size: 3000, + sizeWithReplica: 9000, + subPathCount: 0, + sizeDirectKey: 3000, + subPaths: [] +}; + +export const PathNotFoundUsage: NUResponse = { + status: 'PATH_NOT_FOUND', + path: '/missing', + size: 0, + sizeWithReplica: 0, + subPathCount: 0, + sizeDirectKey: 0, + subPaths: [] +}; + +export const InitializingUsage: NUResponse = { + status: 'INITIALIZING', + path: '/', + size: 0, + sizeWithReplica: 0, + subPathCount: 0, + sizeDirectKey: 0, + subPaths: [] +}; + +export const RootSummaryResponse = { + status: 'OK', + path: '/', + type: 'ROOT', + countStats: { + numVolume: 2, + numBucket: 0, + numDir: 0, + numKey: 0 + }, + objectInfo: {} +}; + +export const Vol1SummaryResponse = { + status: 'OK', + path: '/vol1', + type: 'VOLUME', + countStats: { + numVolume: 0, + numBucket: 1, + numDir: 0, + numKey: 0 + }, + objectInfo: { + volumeName: 'vol1', + name: 'vol1' + } +}; + +export const Bucket1SummaryResponse = { + status: 'OK', + path: '/vol1/bucket1', + type: 'BUCKET', + countStats: { + numVolume: 0, + numBucket: 0, + numDir: 0, + numKey: 1 + }, + objectInfo: { + bucketName: 'bucket1', + volumeName: 'vol1', + name: 'bucket1', + bucketLayout: 'FILE_SYSTEM_OPTIMIZED', + owner: 'hdfs', + storageType: 'DISK', + usedNamespace: 1 + } +}; + +export const Key1SummaryResponse = { + status: 'OK', + path: '/vol1/bucket1/key1', + type: 'KEY', + countStats: { + numVolume: 0, + numBucket: 0, + numDir: 0, + numKey: 0 + }, + objectInfo: { + keyName: 'key1', + fileName: 'key1', + bucketName: 'bucket1', + volumeName: 'vol1', + name: 'key1', + creationTime: 1673415921920, + modificationTime: 1673415921920 + } +}; + +export const RootQuotaResponse = { + status: 'TYPE_NOT_APPLICABLE' +}; Review Comment: Should these follow the backend? Root actually returns quota values, while a key has no quota. ########## hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/namespaceUsage/NamespaceUsage.test.tsx: ########## @@ -0,0 +1,205 @@ +/* + * 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, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { vi } from 'vitest'; + +import NamespaceUsage from '@/v2/pages/namespaceUsage/namespaceUsage'; +import * as commonUtils from '@/utils/common'; +import { + namespaceUsageServer, + resetNamespaceUsageRequestTracking, + usagePathsRequested +} from '@tests/mocks/namespaceUsageMocks/namespaceUsageServer'; +import * as mockResponses from '@tests/mocks/namespaceUsageMocks/namespaceUsageResponseMocks'; + +vi.spyOn(commonUtils, 'showDataFetchError'); +vi.spyOn(commonUtils, 'showInfoNotification'); + +vi.mock('@/v2/components/eChart/eChart', () => ({ + default: () => <div data-testid="nu-echart" /> +})); + +vi.mock('@/v2/hooks/useAPIData.hook', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/v2/hooks/useAPIData.hook')>(); + return { + ...actual, + useApiData: <T,>(url: string, defaultValue: T, options = {}) => + actual.useApiData(url, defaultValue, { + ...options, + retryAttempts: 0, + retryDelay: 0 + }) + }; +}); + +describe('Namespace Usage page (Disk Usage)', () => { + beforeAll(() => namespaceUsageServer.listen()); + + beforeEach(() => { + resetNamespaceUsageRequestTracking(); + vi.mocked(commonUtils.showDataFetchError).mockClear(); + vi.mocked(commonUtils.showInfoNotification).mockClear(); + }); + + afterEach(() => namespaceUsageServer.resetHandlers()); + afterAll(() => namespaceUsageServer.close()); + + const rootUsageRequestCount = () => + usagePathsRequested.filter((path) => path === '/').length; + + const confirmRootLoad = async () => { + fireEvent.click(screen.getByRole('button', { name: 'Load usage' })); + expect(screen.getByText('Confirm Root Path Usage Load')).toBeInTheDocument(); + const modal = document.querySelector('.ant-modal') as HTMLElement; + await userEvent.click(within(modal).getByRole('button', { name: /^ok$/i })); + }; + + const openBreadcrumbMenu = async () => { + const trigger = document.querySelector('.ant-breadcrumb-overlay-link') as HTMLElement; + await userEvent.click(trigger); + }; + + const waitForMetadataEntityType = async (entityType: string) => { + const metadataTable = screen.getByRole('table'); + await waitFor(() => { + const entityTypeRow = within(metadataTable).getByText('Entity Type').closest('tr'); + expect(entityTypeRow).toHaveTextContent(entityType); + }); + return metadataTable; + }; + + const clickBreadcrumbSubmenuItem = async (label: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + await userEvent.click(within(menu).getByRole('menuitem', { name: label })); + }; + + const searchPathFromBreadcrumb = async (pathSegment: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + const searchInput = within(menu).getByPlaceholderText('Enter Path'); + await userEvent.type(searchInput, pathSegment); + const searchButton = searchInput + .closest('.ant-input-search') + ?.querySelector('button.ant-input-search-button') as HTMLButtonElement; + await userEvent.click(searchButton); + }; + + test('does not fetch root usage until the user confirms load', async () => { + render(<NamespaceUsage />); + + expect(screen.getByText('Namespace Usage')).toBeInTheDocument(); + expect(screen.getByText(/Root path usage is not loaded by default/i)).toBeInTheDocument(); + expect(usagePathsRequested).toEqual([]); + + await confirmRootLoad(); + + await waitFor(() => { + expect(rootUsageRequestCount()).toBe(1); + }); + expect(usagePathsRequested).toContain('/'); + await waitFor(() => { + expect(screen.queryByText(/Root path usage is not loaded by default/i)).not.toBeInTheDocument(); + }); + + const metadataTable = await waitForMetadataEntityType('ROOT'); + + const volumesRow = within(metadataTable).getByText('Volumes').closest('tr'); + expect(volumesRow).toHaveTextContent('2'); Review Comment: This matches `2` anywhere in the row, so `22` would pass too. Would it be better to check the value exactly? Same for **Buckets** below. ########## hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/namespaceUsage/NamespaceUsage.test.tsx: ########## @@ -0,0 +1,205 @@ +/* + * 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, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { vi } from 'vitest'; + +import NamespaceUsage from '@/v2/pages/namespaceUsage/namespaceUsage'; +import * as commonUtils from '@/utils/common'; +import { + namespaceUsageServer, + resetNamespaceUsageRequestTracking, + usagePathsRequested +} from '@tests/mocks/namespaceUsageMocks/namespaceUsageServer'; +import * as mockResponses from '@tests/mocks/namespaceUsageMocks/namespaceUsageResponseMocks'; + +vi.spyOn(commonUtils, 'showDataFetchError'); +vi.spyOn(commonUtils, 'showInfoNotification'); + +vi.mock('@/v2/components/eChart/eChart', () => ({ + default: () => <div data-testid="nu-echart" /> +})); + +vi.mock('@/v2/hooks/useAPIData.hook', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/v2/hooks/useAPIData.hook')>(); + return { + ...actual, + useApiData: <T,>(url: string, defaultValue: T, options = {}) => + actual.useApiData(url, defaultValue, { + ...options, + retryAttempts: 0, + retryDelay: 0 + }) + }; +}); + +describe('Namespace Usage page (Disk Usage)', () => { + beforeAll(() => namespaceUsageServer.listen()); + + beforeEach(() => { + resetNamespaceUsageRequestTracking(); + vi.mocked(commonUtils.showDataFetchError).mockClear(); + vi.mocked(commonUtils.showInfoNotification).mockClear(); + }); + + afterEach(() => namespaceUsageServer.resetHandlers()); + afterAll(() => namespaceUsageServer.close()); + + const rootUsageRequestCount = () => + usagePathsRequested.filter((path) => path === '/').length; + + const confirmRootLoad = async () => { + fireEvent.click(screen.getByRole('button', { name: 'Load usage' })); + expect(screen.getByText('Confirm Root Path Usage Load')).toBeInTheDocument(); + const modal = document.querySelector('.ant-modal') as HTMLElement; + await userEvent.click(within(modal).getByRole('button', { name: /^ok$/i })); + }; + + const openBreadcrumbMenu = async () => { + const trigger = document.querySelector('.ant-breadcrumb-overlay-link') as HTMLElement; + await userEvent.click(trigger); + }; + + const waitForMetadataEntityType = async (entityType: string) => { + const metadataTable = screen.getByRole('table'); + await waitFor(() => { + const entityTypeRow = within(metadataTable).getByText('Entity Type').closest('tr'); + expect(entityTypeRow).toHaveTextContent(entityType); + }); + return metadataTable; + }; + + const clickBreadcrumbSubmenuItem = async (label: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + await userEvent.click(within(menu).getByRole('menuitem', { name: label })); + }; + + const searchPathFromBreadcrumb = async (pathSegment: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + const searchInput = within(menu).getByPlaceholderText('Enter Path'); + await userEvent.type(searchInput, pathSegment); + const searchButton = searchInput + .closest('.ant-input-search') + ?.querySelector('button.ant-input-search-button') as HTMLButtonElement; + await userEvent.click(searchButton); + }; + + test('does not fetch root usage until the user confirms load', async () => { + render(<NamespaceUsage />); + + expect(screen.getByText('Namespace Usage')).toBeInTheDocument(); + expect(screen.getByText(/Root path usage is not loaded by default/i)).toBeInTheDocument(); + expect(usagePathsRequested).toEqual([]); + + await confirmRootLoad(); + + await waitFor(() => { + expect(rootUsageRequestCount()).toBe(1); + }); + expect(usagePathsRequested).toContain('/'); + await waitFor(() => { + expect(screen.queryByText(/Root path usage is not loaded by default/i)).not.toBeInTheDocument(); + }); + + const metadataTable = await waitForMetadataEntityType('ROOT'); + + const volumesRow = within(metadataTable).getByText('Volumes').closest('tr'); + expect(volumesRow).toHaveTextContent('2'); + }); + + test('navigates to a child path via breadcrumb submenu', async () => { + render(<NamespaceUsage />); + await confirmRootLoad(); + await waitForMetadataEntityType('ROOT'); + + await clickBreadcrumbSubmenuItem('vol1'); + + await waitFor(() => + expect(screen.queryByText(/Root path usage can be expensive/i)).not.toBeInTheDocument() + ); + const metadataTable = await waitForMetadataEntityType('VOLUME'); + const nameRow = within(metadataTable).getByText('Name').closest('tr'); + expect(nameRow).toHaveTextContent('vol1'); + const volumeNameRow = within(metadataTable).getByText('Volume Name').closest('tr'); + expect(volumeNameRow).toHaveTextContent('vol1'); + const bucketsRow = within(metadataTable).getByText('Buckets').closest('tr'); + expect(bucketsRow).toHaveTextContent('1'); + }); + + test('navigates to a key path via breadcrumb search', async () => { + render(<NamespaceUsage />); + await confirmRootLoad(); + await waitForMetadataEntityType('ROOT'); + + await clickBreadcrumbSubmenuItem('vol1'); + await waitForMetadataEntityType('VOLUME'); + + await clickBreadcrumbSubmenuItem('bucket1'); + await waitForMetadataEntityType('BUCKET'); + + await searchPathFromBreadcrumb('key1'); + + const keyMetadataTable = await waitForMetadataEntityType('KEY'); + const fileSizeRow = within(keyMetadataTable).getByText('File Size').closest('tr'); + expect(fileSizeRow).toHaveTextContent('2.93 KB'); + }); + + test('shows error when API returns PATH_NOT_FOUND', async () => { + render(<NamespaceUsage />); + await confirmRootLoad(); + const metadataTable = await waitForMetadataEntityType('ROOT'); + expect(within(metadataTable).getByText('Volumes')).toBeInTheDocument(); + + await searchPathFromBreadcrumb('missing'); + + await waitFor(() => { + const invalidPathCalls = vi.mocked(commonUtils.showDataFetchError).mock.calls.filter( + (call) => call[0] === 'Invalid Path: /missing' + ); + expect(invalidPathCalls).toHaveLength(2); + }); + + await waitFor(() => { + const table = screen.getByRole('table'); + expect(within(table).queryByText('Entity Type')).not.toBeInTheDocument(); + expect(within(table).queryByText('Volumes')).not.toBeInTheDocument(); + }); + }); + + test('notifies when namespace summary is still initializing', async () => { + namespaceUsageServer.use( + http.get('/api/v1/namespace/usage', () => + HttpResponse.json(mockResponses.InitializingUsage)) + ); Review Comment: Would it be worth returning initializing from summary and quota too? The backend does that for all three. Heads-up: that exposes a small bug in `nuMetadata` (it checks `state` instead of `status`), probably worth a separate Jira. ########## hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/mocks/namespaceUsageMocks/namespaceUsageServer.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. + */ + +import { setupServer } from 'msw/node'; +import { http, HttpResponse } from 'msw'; + +import * as mockResponses from './namespaceUsageResponseMocks'; + +/** Paths from `path` query param on each intercepted usage request (for tests). */ +export const usagePathsRequested: string[] = []; + +export function resetNamespaceUsageRequestTracking(): void { + usagePathsRequested.length = 0; +} + +const usageHandler = http.get('/api/v1/namespace/usage', ({ request }) => { + const url = new URL(request.url); + const path = url.searchParams.get('path') ?? '/'; + usagePathsRequested.push(path); + + if (path === '/missing') { + return HttpResponse.json(mockResponses.PathNotFoundUsage); + } + if (path === '/vol1/bucket1/key1') { + return HttpResponse.json(mockResponses.Key1NamespaceUsage); + } + if (path === '/vol1/bucket1') { + return HttpResponse.json(mockResponses.Bucket1NamespaceUsage); + } + if (path === '/vol1') { + return HttpResponse.json(mockResponses.Vol1NamespaceUsage); + } + return HttpResponse.json(mockResponses.RootNamespaceUsage); Review Comment: nit: Is returning root data for unknown paths intended? A wrong path in the request could go unnoticed. ########## hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/namespaceUsage/NamespaceUsage.test.tsx: ########## @@ -0,0 +1,205 @@ +/* + * 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, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { vi } from 'vitest'; + +import NamespaceUsage from '@/v2/pages/namespaceUsage/namespaceUsage'; +import * as commonUtils from '@/utils/common'; +import { + namespaceUsageServer, + resetNamespaceUsageRequestTracking, + usagePathsRequested +} from '@tests/mocks/namespaceUsageMocks/namespaceUsageServer'; +import * as mockResponses from '@tests/mocks/namespaceUsageMocks/namespaceUsageResponseMocks'; + +vi.spyOn(commonUtils, 'showDataFetchError'); +vi.spyOn(commonUtils, 'showInfoNotification'); + +vi.mock('@/v2/components/eChart/eChart', () => ({ + default: () => <div data-testid="nu-echart" /> +})); + +vi.mock('@/v2/hooks/useAPIData.hook', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/v2/hooks/useAPIData.hook')>(); + return { + ...actual, + useApiData: <T,>(url: string, defaultValue: T, options = {}) => + actual.useApiData(url, defaultValue, { + ...options, + retryAttempts: 0, + retryDelay: 0 + }) + }; +}); + +describe('Namespace Usage page (Disk Usage)', () => { + beforeAll(() => namespaceUsageServer.listen()); + + beforeEach(() => { + resetNamespaceUsageRequestTracking(); + vi.mocked(commonUtils.showDataFetchError).mockClear(); + vi.mocked(commonUtils.showInfoNotification).mockClear(); + }); + + afterEach(() => namespaceUsageServer.resetHandlers()); + afterAll(() => namespaceUsageServer.close()); + + const rootUsageRequestCount = () => + usagePathsRequested.filter((path) => path === '/').length; + + const confirmRootLoad = async () => { + fireEvent.click(screen.getByRole('button', { name: 'Load usage' })); + expect(screen.getByText('Confirm Root Path Usage Load')).toBeInTheDocument(); + const modal = document.querySelector('.ant-modal') as HTMLElement; + await userEvent.click(within(modal).getByRole('button', { name: /^ok$/i })); + }; + + const openBreadcrumbMenu = async () => { + const trigger = document.querySelector('.ant-breadcrumb-overlay-link') as HTMLElement; + await userEvent.click(trigger); + }; + + const waitForMetadataEntityType = async (entityType: string) => { + const metadataTable = screen.getByRole('table'); + await waitFor(() => { + const entityTypeRow = within(metadataTable).getByText('Entity Type').closest('tr'); + expect(entityTypeRow).toHaveTextContent(entityType); + }); + return metadataTable; + }; + + const clickBreadcrumbSubmenuItem = async (label: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + await userEvent.click(within(menu).getByRole('menuitem', { name: label })); + }; + + const searchPathFromBreadcrumb = async (pathSegment: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + const searchInput = within(menu).getByPlaceholderText('Enter Path'); + await userEvent.type(searchInput, pathSegment); + const searchButton = searchInput + .closest('.ant-input-search') + ?.querySelector('button.ant-input-search-button') as HTMLButtonElement; + await userEvent.click(searchButton); + }; + + test('does not fetch root usage until the user confirms load', async () => { + render(<NamespaceUsage />); + + expect(screen.getByText('Namespace Usage')).toBeInTheDocument(); + expect(screen.getByText(/Root path usage is not loaded by default/i)).toBeInTheDocument(); + expect(usagePathsRequested).toEqual([]); + + await confirmRootLoad(); + + await waitFor(() => { + expect(rootUsageRequestCount()).toBe(1); + }); + expect(usagePathsRequested).toContain('/'); + await waitFor(() => { + expect(screen.queryByText(/Root path usage is not loaded by default/i)).not.toBeInTheDocument(); + }); + + const metadataTable = await waitForMetadataEntityType('ROOT'); + + const volumesRow = within(metadataTable).getByText('Volumes').closest('tr'); + expect(volumesRow).toHaveTextContent('2'); + }); + + test('navigates to a child path via breadcrumb submenu', async () => { Review Comment: Could we also test going back to root from the Home breadcrumb? It should ask for confirmation again, and **Cancel** shouldn't send a request. ########## hadoop-ozone/recon/src/main/resources/webapps/recon/ozone-recon-web/src/__tests__/namespaceUsage/NamespaceUsage.test.tsx: ########## @@ -0,0 +1,205 @@ +/* + * 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, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { http, HttpResponse } from 'msw'; +import { vi } from 'vitest'; + +import NamespaceUsage from '@/v2/pages/namespaceUsage/namespaceUsage'; +import * as commonUtils from '@/utils/common'; +import { + namespaceUsageServer, + resetNamespaceUsageRequestTracking, + usagePathsRequested +} from '@tests/mocks/namespaceUsageMocks/namespaceUsageServer'; +import * as mockResponses from '@tests/mocks/namespaceUsageMocks/namespaceUsageResponseMocks'; + +vi.spyOn(commonUtils, 'showDataFetchError'); +vi.spyOn(commonUtils, 'showInfoNotification'); + +vi.mock('@/v2/components/eChart/eChart', () => ({ + default: () => <div data-testid="nu-echart" /> +})); + +vi.mock('@/v2/hooks/useAPIData.hook', async (importOriginal) => { + const actual = await importOriginal<typeof import('@/v2/hooks/useAPIData.hook')>(); + return { + ...actual, + useApiData: <T,>(url: string, defaultValue: T, options = {}) => + actual.useApiData(url, defaultValue, { + ...options, + retryAttempts: 0, + retryDelay: 0 + }) + }; +}); + +describe('Namespace Usage page (Disk Usage)', () => { + beforeAll(() => namespaceUsageServer.listen()); + + beforeEach(() => { + resetNamespaceUsageRequestTracking(); + vi.mocked(commonUtils.showDataFetchError).mockClear(); + vi.mocked(commonUtils.showInfoNotification).mockClear(); + }); + + afterEach(() => namespaceUsageServer.resetHandlers()); + afterAll(() => namespaceUsageServer.close()); + + const rootUsageRequestCount = () => + usagePathsRequested.filter((path) => path === '/').length; + + const confirmRootLoad = async () => { + fireEvent.click(screen.getByRole('button', { name: 'Load usage' })); + expect(screen.getByText('Confirm Root Path Usage Load')).toBeInTheDocument(); + const modal = document.querySelector('.ant-modal') as HTMLElement; + await userEvent.click(within(modal).getByRole('button', { name: /^ok$/i })); + }; + + const openBreadcrumbMenu = async () => { + const trigger = document.querySelector('.ant-breadcrumb-overlay-link') as HTMLElement; + await userEvent.click(trigger); + }; + + const waitForMetadataEntityType = async (entityType: string) => { + const metadataTable = screen.getByRole('table'); + await waitFor(() => { + const entityTypeRow = within(metadataTable).getByText('Entity Type').closest('tr'); + expect(entityTypeRow).toHaveTextContent(entityType); + }); + return metadataTable; + }; + + const clickBreadcrumbSubmenuItem = async (label: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + await userEvent.click(within(menu).getByRole('menuitem', { name: label })); + }; + + const searchPathFromBreadcrumb = async (pathSegment: string) => { + await openBreadcrumbMenu(); + const menu = await screen.findByRole('menu'); + const searchInput = within(menu).getByPlaceholderText('Enter Path'); + await userEvent.type(searchInput, pathSegment); + const searchButton = searchInput + .closest('.ant-input-search') + ?.querySelector('button.ant-input-search-button') as HTMLButtonElement; + await userEvent.click(searchButton); + }; + + test('does not fetch root usage until the user confirms load', async () => { + render(<NamespaceUsage />); + + expect(screen.getByText('Namespace Usage')).toBeInTheDocument(); + expect(screen.getByText(/Root path usage is not loaded by default/i)).toBeInTheDocument(); + expect(usagePathsRequested).toEqual([]); + + await confirmRootLoad(); + + await waitFor(() => { + expect(rootUsageRequestCount()).toBe(1); + }); + expect(usagePathsRequested).toContain('/'); + await waitFor(() => { + expect(screen.queryByText(/Root path usage is not loaded by default/i)).not.toBeInTheDocument(); + }); + + const metadataTable = await waitForMetadataEntityType('ROOT'); + + const volumesRow = within(metadataTable).getByText('Volumes').closest('tr'); + expect(volumesRow).toHaveTextContent('2'); + }); + + test('navigates to a child path via breadcrumb submenu', async () => { + render(<NamespaceUsage />); + await confirmRootLoad(); + await waitForMetadataEntityType('ROOT'); + + await clickBreadcrumbSubmenuItem('vol1'); + + await waitFor(() => + expect(screen.queryByText(/Root path usage can be expensive/i)).not.toBeInTheDocument() + ); + const metadataTable = await waitForMetadataEntityType('VOLUME'); + const nameRow = within(metadataTable).getByText('Name').closest('tr'); + expect(nameRow).toHaveTextContent('vol1'); + const volumeNameRow = within(metadataTable).getByText('Volume Name').closest('tr'); + expect(volumeNameRow).toHaveTextContent('vol1'); + const bucketsRow = within(metadataTable).getByText('Buckets').closest('tr'); + expect(bucketsRow).toHaveTextContent('1'); + }); + + test('navigates to a key path via breadcrumb search', async () => { + render(<NamespaceUsage />); + await confirmRootLoad(); + await waitForMetadataEntityType('ROOT'); + + await clickBreadcrumbSubmenuItem('vol1'); + await waitForMetadataEntityType('VOLUME'); + + await clickBreadcrumbSubmenuItem('bucket1'); + await waitForMetadataEntityType('BUCKET'); + + await searchPathFromBreadcrumb('key1'); + + const keyMetadataTable = await waitForMetadataEntityType('KEY'); + const fileSizeRow = within(keyMetadataTable).getByText('File Size').closest('tr'); + expect(fileSizeRow).toHaveTextContent('2.93 KB'); + }); + + test('shows error when API returns PATH_NOT_FOUND', async () => { + render(<NamespaceUsage />); + await confirmRootLoad(); + const metadataTable = await waitForMetadataEntityType('ROOT'); + expect(within(metadataTable).getByText('Volumes')).toBeInTheDocument(); + + await searchPathFromBreadcrumb('missing'); + + await waitFor(() => { + const invalidPathCalls = vi.mocked(commonUtils.showDataFetchError).mock.calls.filter( + (call) => call[0] === 'Invalid Path: /missing' + ); + expect(invalidPathCalls).toHaveLength(2); Review Comment: nit: Would checking the message work better than the call count? The 2 comes from a duplicate toast. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
