datajunction-ui
Version:
DataJunction UI
925 lines (808 loc) • 31.5 kB
JSX
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();
});
});
});