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]

Reply via email to