UNPKG

datajunction-ui

Version:
925 lines (808 loc) 31.5 kB
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import DJClientContext from '../../../providers/djclient'; import UserContext from '../../../providers/UserProvider'; import { NamespacePage } from '../index'; import React from 'react'; const mockDjClient = { namespaces: vi.fn(), listNamespacesWithGit: vi.fn(), namespace: vi.fn(), listNodesForLanding: vi.fn(), nodeTypeCounts: vi.fn().mockResolvedValue({}), addNamespace: vi.fn(), whoami: vi.fn(), users: vi.fn(), listTags: vi.fn(), namespaceSources: vi.fn(), namespaceSourcesBulk: vi.fn(), getNamespaceGitConfig: vi.fn(), getNamespaceBranches: vi.fn(), listDeployments: vi.fn(), getPullRequest: vi.fn(), }; const mockCurrentUser = { username: 'dj', email: 'dj@test.com' }; const renderWithProviders = (ui, { route = '/namespaces/default' } = {}) => { return render( <UserContext.Provider value={{ currentUser: mockCurrentUser, loading: false }} > <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}> <MemoryRouter initialEntries={[route]}> <Routes> <Route path="namespaces/:namespace" element={ui} /> <Route path="/" element={ui} /> </Routes> </MemoryRouter> </DJClientContext.Provider> </UserContext.Provider>, ); }; describe('NamespacePage', () => { const original = window.location; const reloadFn = () => { window.location.reload(); }; beforeAll(() => { Object.defineProperty(window, 'location', { configurable: true, value: { reload: vi.fn() }, }); }); afterAll(() => { Object.defineProperty(window, 'location', { configurable: true, value: original, }); }); beforeEach(() => { fetch.resetMocks(); mockDjClient.whoami.mockResolvedValue({ username: 'dj' }); mockDjClient.users.mockResolvedValue([ { username: 'dj' }, { username: 'user1' }, ]); mockDjClient.listTags.mockResolvedValue([ { name: 'tag1' }, { name: 'tag2' }, ]); mockDjClient.namespaceSources.mockResolvedValue({ sources: [] }); mockDjClient.namespaceSourcesBulk.mockResolvedValue({ namespace_sources: {}, }); mockDjClient.getNamespaceGitConfig.mockResolvedValue(null); mockDjClient.getNamespaceBranches.mockResolvedValue([]); mockDjClient.listDeployments.mockResolvedValue([]); mockDjClient.getPullRequest.mockResolvedValue(null); const mockNamespaces = [ { namespace: 'common.one', numNodes: 3, git: null }, { namespace: 'common.one.a', numNodes: 6, git: null }, { namespace: 'common.one.b', numNodes: 17, git: null }, { namespace: 'common.one.c', numNodes: 64, git: null }, { namespace: 'default', numNodes: 41, git: null }, { namespace: 'default.fruits', numNodes: 1, git: null }, { namespace: 'default.fruits.citrus.lemons', numNodes: 1, git: null }, { namespace: 'default.vegetables', numNodes: 2, git: null }, ]; mockDjClient.namespaces.mockResolvedValue(mockNamespaces); mockDjClient.listNamespacesWithGit.mockResolvedValue(mockNamespaces); mockDjClient.namespace.mockResolvedValue([ { name: 'testNode', display_name: 'Test Node', type: 'transform', mode: 'active', updated_at: new Date(), tags: [{ name: 'tag1' }], edited_by: ['dj'], }, ]); mockDjClient.listNodesForLanding.mockResolvedValue({ data: { findNodesPaginated: { pageInfo: { hasNextPage: true, endCursor: 'eyJjcmVhdGVkX2F0IjogIjIwMjQtMDQtMTZUMjM6MjI6MjIuNDQxNjg2KzAwOjAwIiwgImlkIjogNjE0fQ==', hasPrevPage: true, startCursor: 'eyJjcmVhdGVkX2F0IjogIjIwMjQtMTAtMTZUMTY6MDM6MTcuMDgzMjY3KzAwOjAwIiwgImlkIjogMjQwOX0=', }, edges: [ { node: { name: 'default.test_node', type: 'DIMENSION', currentVersion: 'v4.0', tags: [], editedBy: ['dj'], current: { displayName: 'Test Node', status: 'VALID', mode: 'PUBLISHED', updatedAt: '2024-10-18T15:15:33.532949+00:00', }, createdBy: { username: 'dj', }, owners: [ { username: 'customer_service' }, { username: 'finance' }, { username: 'analytics' }, ], }, }, ], }, }, }); }); afterEach(() => { vi.clearAllMocks(); }); it('displays namespaces and renders nodes', async () => { reloadFn(); const element = ( <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}> <NamespacePage /> </DJClientContext.Provider> ); render( <MemoryRouter initialEntries={['/namespaces/default']}> <Routes> <Route path="namespaces/:namespace" element={element} /> </Routes> </MemoryRouter>, ); // Wait for initial nodes to load await waitFor(() => { expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(); }); // The selected-namespace rail renders FolderTree for the current namespace's // children. Route is /namespaces/default; the mock hierarchy has // default.fruits and default.vegetables as immediate children. await waitFor(() => { expect(screen.getByText('Folders')).toBeInTheDocument(); expect(screen.getByText('fruits')).toBeInTheDocument(); expect(screen.getByText('vegetables')).toBeInTheDocument(); }); // Sibling top-level namespace 'common' must NOT appear in the selected rail. expect(screen.queryByText('common')).not.toBeInTheDocument(); // Check that it renders nodes expect(screen.getByText('Test Node')).toBeInTheDocument(); expect(screen.getAllByText('Published').length).toBeGreaterThan(0); expect(screen.getAllByText('Valid').length).toBeGreaterThan(0); expect(screen.getByText('CU')).toBeInTheDocument(); // --- Sorting --- // Track current call count const initialCallCount = mockDjClient.listNodesForLanding.mock.calls.length; // sort by name fireEvent.click(screen.getByText('Name')); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(initialCallCount); }); const afterFirstSort = mockDjClient.listNodesForLanding.mock.calls.length; // flip direction fireEvent.click(screen.getByText('Name')); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(afterFirstSort); }); const afterSecondSort = mockDjClient.listNodesForLanding.mock.calls.length; // sort by display name fireEvent.click(screen.getByText('Display name')); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(afterSecondSort); }); // --- Filters --- // Node type - use react-select properly const selectNodeType = screen.getAllByTestId('select-node-type')[0]; const typeInput = selectNodeType.querySelector('input'); if (typeInput) { fireEvent.focus(typeInput); fireEvent.keyDown(typeInput, { key: 'ArrowDown' }); await waitFor(() => { const sourceOption = screen.queryByText('Source'); if (sourceOption) { fireEvent.click(sourceOption); } }); } // Tag filter fireEvent.click(screen.getByText('Filters')); await waitFor(() => { expect(screen.getByText('Tags')).toBeInTheDocument(); }); const selectTag = screen.getAllByTestId('select-tag')[0]; const tagInput = selectTag.querySelector('input'); if (tagInput) { fireEvent.focus(tagInput); fireEvent.keyDown(tagInput, { key: 'ArrowDown' }); } // User filter const selectUser = screen.getAllByTestId('select-user')[0]; const userInput = selectUser.querySelector('input'); if (userInput) { fireEvent.focus(userInput); fireEvent.keyDown(userInput, { key: 'ArrowDown' }); } // --- Rail still shows folders after sort interactions --- // The FolderTree rail should still be present after sorting. await waitFor(() => { expect(screen.getByText('Folders')).toBeInTheDocument(); }); }); it('rail folder nav: shows child folders to drill into', async () => { // The rail is the folder navigator: it lists the current namespace's child // sub-namespaces (drill in by clicking), NOT its siblings or an // all-namespaces list. Going up a level is handled by the header breadcrumb. const element = ( <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}> <NamespacePage /> </DJClientContext.Provider> ); render( <MemoryRouter initialEntries={['/namespaces/default']}> <Routes> <Route path="namespaces/:namespace" element={element} /> </Routes> </MemoryRouter>, ); // Folder nav loads after the namespace hierarchy is fetched: 'default' has // child folders 'fruits' and 'vegetables'. await waitFor(() => { expect(screen.getByText('Folders')).toBeInTheDocument(); expect(screen.getByText('fruits')).toBeInTheDocument(); expect(screen.getByText('vegetables')).toBeInTheDocument(); }); // Siblings / all-namespaces are NOT shown in the selected view. expect(screen.queryByText('common')).not.toBeInTheDocument(); }); describe('Filter Bar', () => { it('displays quick filter presets', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('Quick')).toBeInTheDocument(); }); // Check that preset buttons are rendered expect(screen.getByText('My Nodes')).toBeInTheDocument(); expect(screen.getByText('Needs Attention')).toBeInTheDocument(); expect(screen.getByText('Drafts')).toBeInTheDocument(); }); it('applies My Nodes preset when clicked', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('My Nodes')).toBeInTheDocument(); }); const initialCalls = mockDjClient.listNodesForLanding.mock.calls.length; fireEvent.click(screen.getByText('My Nodes')); await waitFor(() => { // The API should be called again after clicking preset expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(initialCalls); }); }); it('applies Needs Attention preset when clicked', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('Needs Attention')).toBeInTheDocument(); }); const initialCalls = mockDjClient.listNodesForLanding.mock.calls.length; fireEvent.click(screen.getByText('Needs Attention')); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(initialCalls); }); }); it('applies Drafts preset when clicked', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('Drafts')).toBeInTheDocument(); }); const initialCalls = mockDjClient.listNodesForLanding.mock.calls.length; fireEvent.click(screen.getByText('Drafts')); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(initialCalls); }); }); it('shows Clear all button when filters are active', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('My Nodes')).toBeInTheDocument(); }); // Apply a preset to activate filters fireEvent.click(screen.getByText('My Nodes')); await waitFor(() => { expect(screen.getByText('Clear all ×')).toBeInTheDocument(); }); }); it('clears all filters when Clear all is clicked', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('My Nodes')).toBeInTheDocument(); }); // Apply a preset fireEvent.click(screen.getByText('My Nodes')); await waitFor(() => { expect(screen.getByText('Clear all ×')).toBeInTheDocument(); }); // Clear all filters fireEvent.click(screen.getByText('Clear all ×')); await waitFor(() => { // Clear all button should disappear expect(screen.queryByText('Clear all ×')).not.toBeInTheDocument(); }); }); it('displays filter dropdowns', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { // Check for filter labels expect(screen.getAllByText('Type').length).toBeGreaterThan(0); expect(screen.getByText('Owner')).toBeInTheDocument(); expect(screen.getByText('Tags')).toBeInTheDocument(); expect(screen.getAllByText('Publish state').length).toBeGreaterThan(0); expect(screen.getAllByText('Validation').length).toBeGreaterThan(0); expect(screen.getByText('More filters')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Filters')); await waitFor(() => { expect(screen.getByText('Edited By')).toBeInTheDocument(); expect(screen.getByText('Missing Description')).toBeInTheDocument(); }); }); it('opens More filters dropdown when clicked', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('More filters')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Filters')); await waitFor(() => { expect(screen.getByText('Missing Description')).toBeInTheDocument(); expect(screen.getByText('Orphaned Dimensions')).toBeInTheDocument(); expect(screen.getByText('Has Materialization')).toBeInTheDocument(); }); }); it('toggles quality filters in dropdown', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('More filters')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Filters')); await waitFor(() => { expect(screen.getByText('Missing Description')).toBeInTheDocument(); }); // Toggle the Missing Description checkbox const checkbox = screen.getByLabelText('Missing Description'); const callsBefore = mockDjClient.listNodesForLanding.mock.calls.length; fireEvent.click(checkbox); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(callsBefore); }); }); it('displays no nodes message with clear filter link when no results', async () => { mockDjClient.listNodesForLanding.mockResolvedValue({ data: { findNodesPaginated: { pageInfo: { hasNextPage: false, endCursor: null, hasPrevPage: false, startCursor: null, }, edges: [], }, }, }); renderWithProviders(<NamespacePage />); // Apply a filter first await waitFor(() => { expect(screen.getByText('My Nodes')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('My Nodes')); await waitFor(() => { expect( screen.getByText('No nodes match the current filters.'), ).toBeInTheDocument(); expect(screen.getByText('Clear filters')).toBeInTheDocument(); }); }); }); describe('URL Parameter Sync', () => { it('reads filters from URL parameters on load', async () => { renderWithProviders(<NamespacePage />, { route: '/namespaces/default?type=metric&ownedBy=dj', }); await waitFor(() => { expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(); }); }); it('reads status filter from URL', async () => { renderWithProviders(<NamespacePage />, { route: '/namespaces/default?statuses=INVALID', }); await waitFor(() => { expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(); }); }); it('reads mode filter from URL', async () => { renderWithProviders(<NamespacePage />, { route: '/namespaces/default?mode=draft', }); await waitFor(() => { expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(); }); }); it('reads quality filters from URL', async () => { renderWithProviders(<NamespacePage />, { route: '/namespaces/default?missingDescription=true&orphanedDimension=true', }); await waitFor(() => { expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(); }); }); it('reads hasMaterialization filter from URL', async () => { renderWithProviders(<NamespacePage />, { route: '/namespaces/default?hasMaterialization=true', }); await waitFor(() => { expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(); }); }); }); describe('Git-root namespace (branch landing page)', () => { const gitRootConfig = { github_repo_path: 'org/repo', git_branch: null, default_branch: 'main', parent_namespace: null, git_only: false, git_root_namespace: 'default', }; const mockBranches = [ { namespace: 'default.main', git_branch: 'main', num_nodes: 10, invalid_node_count: 1, last_updated_at: '2024-10-18T12:00:00+00:00', }, { namespace: 'default.feature-xyz', git_branch: 'feature-xyz', num_nodes: 5, invalid_node_count: 0, last_updated_at: null, }, ]; beforeEach(() => { mockDjClient.getNamespaceGitConfig.mockResolvedValue(gitRootConfig); mockDjClient.getNamespaceBranches.mockResolvedValue(mockBranches); }); it('browses the default branch namespace in the node table', async () => { renderWithProviders(<NamespacePage />); // A git root has no nodes of its own — the table queries // <root>.<default_branch> (so a git root shows the same browsable table // as any other namespace, instead of a separate split preview). await waitFor( () => { const calls = mockDjClient.listNodesForLanding.mock.calls; expect(calls.some(args => args[0] === 'default.main')).toBe(true); }, { timeout: 3000 }, ); }); it('redirects to the default branch and keeps New Branch reachable there', async () => { // Regression guard for the orphaned-entry-point bug: a git root redirects // to its default branch, and branch creation must still be reachable on // that (branch) page — via redirect change OR gating change. const branchConfig = { github_repo_path: 'org/repo', git_branch: 'main', git_path: 'nodes/', git_only: false, parent_namespace: 'default', branch_namespace: 'default.main', git_root_namespace: 'default', }; mockDjClient.getNamespaceGitConfig.mockImplementation(ns => Promise.resolve(ns === 'default' ? gitRootConfig : branchConfig), ); const LocationDisplay = () => ( <div data-testid="location">{useLocation().pathname}</div> ); render( <UserContext.Provider value={{ currentUser: mockCurrentUser, loading: false }} > <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}> <MemoryRouter initialEntries={['/namespaces/default']}> <LocationDisplay /> <Routes> <Route path="namespaces/:namespace" element={<NamespacePage />} /> </Routes> </MemoryRouter> </DJClientContext.Provider> </UserContext.Provider>, ); // The git root redirects to its default branch... await waitFor( () => { expect(screen.getByTestId('location').textContent).toBe( '/namespaces/default.main', ); }, { timeout: 3000 }, ); // ...where the branch-creation entry point is still present. The default // branch is read-only, so it renders the read-only menu's inline // "New Branch" rather than the editable Git menu's "+ New Branch". expect(await screen.findByText('New Branch')).toBeInTheDocument(); }); }); describe('Quality filter checkboxes', () => { it('toggles orphanedDimension filter', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('More filters')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Filters')); await waitFor(() => { expect(screen.getByText('Orphaned Dimensions')).toBeInTheDocument(); }); const checkbox = screen.getByLabelText('Orphaned Dimensions'); const callsBefore = mockDjClient.listNodesForLanding.mock.calls.length; fireEvent.click(checkbox); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(callsBefore); }); }); it('toggles hasMaterialization filter', async () => { renderWithProviders(<NamespacePage />); await waitFor(() => { expect(screen.getByText('More filters')).toBeInTheDocument(); }); fireEvent.click(screen.getByText('Filters')); await waitFor(() => { expect(screen.getByText('Has Materialization')).toBeInTheDocument(); }); const checkbox = screen.getByLabelText('Has Materialization'); const callsBefore = mockDjClient.listNodesForLanding.mock.calls.length; fireEvent.click(checkbox); await waitFor(() => { expect( mockDjClient.listNodesForLanding.mock.calls.length, ).toBeGreaterThan(callsBefore); }); }); }); it('node search passes the search term to the fetch', async () => { mockDjClient.getNamespaceGitConfig.mockResolvedValue({ github_repo_path: null, git_branch: null, default_branch: null, parent_namespace: null, git_only: false, git_root_namespace: null, }); mockDjClient.listNamespacesWithGit.mockResolvedValue([ { namespace: 'growth', numNodes: 2, git: null }, { namespace: 'growth.metrics', numNodes: 7, git: null }, ]); renderWithProviders(<NamespacePage />, { route: '/namespaces/growth' }); await waitFor(() => expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(), ); fireEvent.change(screen.getByPlaceholderText(/search nodes/i), { target: { value: 'active' }, }); // Wait for the 300 ms debounce and the subsequent fetch. await waitFor( () => { const opts = mockDjClient.listNodesForLanding.mock.calls.at(-1).at(-1); expect(opts.search).toBe('active'); }, { timeout: 1000 }, ); }); it('shows sub-namespace folders in the rail', async () => { mockDjClient.getNamespaceGitConfig.mockResolvedValue({ github_repo_path: null, git_branch: null, default_branch: null, parent_namespace: null, git_only: false, git_root_namespace: null, }); mockDjClient.listNamespacesWithGit.mockResolvedValue([ { namespace: 'growth', numNodes: 2, git: null }, { namespace: 'growth.experiments', numNodes: 5, git: null }, { namespace: 'growth.metrics', numNodes: 7, git: null }, ]); renderWithProviders(<NamespacePage />, { route: '/namespaces/growth' }); // The rail (FolderTree) lists immediate sub-namespaces of the selected namespace. await waitFor(() => { expect(screen.getByText('Folders')).toBeInTheDocument(); expect(screen.getByText('experiments')).toBeInTheDocument(); expect(screen.getByText('metrics')).toBeInTheDocument(); }); }); it('resets pagination cursors to null when search term changes', async () => { // Limitation: simulating a real Next-click to set a non-null cursor before // typing is impractical in this test harness because the pagination buttons // depend on rendered cursor state that only stabilises after async fetches. // This test therefore directly asserts the regression-guard: after typing a // search term the fetch is called with before=null (index 4) and after=null // (index 5), confirming the reset effect fires on debouncedSearch change. renderWithProviders(<NamespacePage />, { route: '/namespaces/default' }); await waitFor(() => { expect(mockDjClient.listNodesForLanding).toHaveBeenCalled(); }); const searchBox = screen.getByPlaceholderText(/search nodes/i); fireEvent.change(searchBox, { target: { value: 'my_metric' } }); // Wait for the 300 ms debounce to fire and the subsequent fetch to complete. await waitFor( () => { const calls = mockDjClient.listNodesForLanding.mock.calls; const lastCall = calls.at(-1); // before is arg index 4, after is arg index 5. expect(lastCall[4]).toBeNull(); expect(lastCall[5]).toBeNull(); }, { timeout: 1000 }, ); }); describe('Read-only git states', () => { it('shows the node table but no add/edit controls for a flat (read-only) namespace', async () => { // A flat namespace: has a repo path AND its own git_branch, no parent_namespace. // detectShape returns 'flat' → read-only; edit controls must be hidden. const flatConfig = { github_repo_path: 'corp/repo', git_branch: 'main', default_branch: null, parent_namespace: null, git_only: false, git_root_namespace: 'magnesium.tech', }; mockDjClient.getNamespaceGitConfig.mockResolvedValue(flatConfig); mockDjClient.listNamespacesWithGit.mockResolvedValue([ { namespace: 'magnesium.tech', numNodes: 1, git: null }, ]); mockDjClient.listNodesForLanding.mockResolvedValue({ data: { findNodesPaginated: { pageInfo: { hasNextPage: false, endCursor: null, hasPrevPage: false, startCursor: null, }, edges: [ { node: { name: 'magnesium.tech.some_metric', type: 'METRIC', currentVersion: 'v1.0', tags: [], editedBy: [], current: { displayName: 'Some Metric', status: 'VALID', mode: 'PUBLISHED', updatedAt: '2024-10-18T15:15:33.532949+00:00', }, createdBy: { username: 'dj' }, }, }, ], }, }, }); renderWithProviders(<NamespacePage />, { route: '/namespaces/magnesium.tech', }); // The node name should appear in the table. expect(await screen.findByText('some_metric')).toBeInTheDocument(); // No add/edit controls for a read-only (flat) namespace. expect(screen.queryByText(/\+ Add Node/i)).not.toBeInTheDocument(); }); it('never shows Add Node on a git-deployed branch namespace (no flash)', async () => { // Regression guard: a branch namespace (gitShape === 'branch') that is // git-deployed is read-only. NamespaceHeader fires onReadOnlyChange(true) // via a useEffect — one render after showEditControls first becomes true. // The guard must be `headerReadOnly === false` (not `!headerReadOnly`) so // Add Node stays hidden during the intermediate render where headerReadOnly // is still `undefined`. const branchConfig = { github_repo_path: 'org/repo', git_branch: 'main', git_path: 'nodes/', git_only: false, parent_namespace: 'ads', branch_namespace: 'ads.main', git_root_namespace: 'ads', }; // The git root carries default_branch, so `ads.main` is recognized as the // default branch — read-only. (A git-deployed *feature* branch would be // editable; only the default branch / 1:1 / git root are locked.) const adsRootConfig = { github_repo_path: 'org/repo', git_branch: null, default_branch: 'main', parent_namespace: null, git_only: false, git_root_namespace: 'ads', }; mockDjClient.getNamespaceGitConfig.mockImplementation(ns => Promise.resolve(ns === 'ads' ? adsRootConfig : branchConfig), ); mockDjClient.namespaceSources.mockResolvedValue({ total_deployments: 1, primary_source: { type: 'git', repository: 'org/repo', branch: 'main' }, }); mockDjClient.listNamespacesWithGit.mockResolvedValue([ { namespace: 'ads', numNodes: 0, git: null }, { namespace: 'ads.main', numNodes: 3, git: null }, ]); mockDjClient.listNodesForLanding.mockResolvedValue({ data: { findNodesPaginated: { pageInfo: { hasNextPage: false, endCursor: null, hasPrevPage: false, startCursor: null, }, edges: [ { node: { name: 'ads.main.revenue', type: 'METRIC', currentVersion: 'v1.0', tags: [], editedBy: [], current: { displayName: 'Revenue', status: 'VALID', mode: 'PUBLISHED', updatedAt: '2024-10-18T15:15:33.532949+00:00', }, createdBy: { username: 'dj' }, }, }, ], }, }, }); renderWithProviders(<NamespacePage />, { route: '/namespaces/ads.main', }); // Wait for the node table to settle (proves the component rendered fully, // including all effects — onReadOnlyChange has fired by this point). expect(await screen.findByText('revenue')).toBeInTheDocument(); // Add Node must never appear at any point. Because headerReadOnly starts // as `undefined` (fixed) vs `false` (buggy), the old guard `!headerReadOnly` // would render Add Node on the first pass when gitConfig resolves but before // the namespaceSources effect fires onReadOnlyChange. The new guard // `headerReadOnly === false` keeps it hidden until explicitly known editable. // After all effects settle, Add Node must still be absent (git-deployed = read-only). expect(screen.queryByText('+ Add Node')).not.toBeInTheDocument(); }); }); });