datajunction-ui
Version:
DataJunction UI
1,588 lines (1,423 loc) • 69.3 kB
JSX
import * as React from 'react';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { MemoryRouter, useLocation } from 'react-router-dom';
import NamespaceHeader from '../NamespaceHeader';
import DJClientContext from '../../providers/djclient';
// Helper: render NamespaceHeader with a minimal mock DJ client.
// Options:
// namespace - namespace string (default 'test.namespace')
// gitConfig - value returned by getNamespaceGitConfig (default: rejects)
const renderHeader = ({ namespace = 'test.namespace', gitConfig } = {}) => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig:
gitConfig !== undefined
? vi.fn().mockResolvedValue(gitConfig)
: vi.fn().mockRejectedValue(new Error('no config')),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
deleteNamespaceGitConfig: vi.fn().mockResolvedValue({ success: true }),
updateNamespaceGitConfig: vi.fn().mockResolvedValue(gitConfig || {}),
};
return render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace={namespace} />
</DJClientContext.Provider>
</MemoryRouter>,
);
};
// Render with explicit git config + deployment sources so we can exercise the
// three toolbar states. getPullRequest is mocked because branch namespaces
// fetch an existing PR on mount.
const renderHeaderState = ({ namespace, gitConfig, sources }) => {
const mockDjClient = {
namespaceSources: vi
.fn()
.mockResolvedValue(
sources || { total_deployments: 0, primary_source: null },
),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue(gitConfig),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
deleteNamespaceGitConfig: vi.fn().mockResolvedValue({ success: true }),
updateNamespaceGitConfig: vi.fn().mockResolvedValue(gitConfig || {}),
};
return render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace={namespace} />
</DJClientContext.Provider>
</MemoryRouter>,
);
};
// Render for the read-only verdict (onReadOnlyChange). `configByNamespace` maps
// each namespace NamespaceHeader fetches (the target, its branch, the git root)
// to its git config — namespace-keyed so it's independent of fetch order.
const renderReadOnlyVerdict = ({ namespace, configByNamespace, sources }) => {
const onReadOnlyChange = vi.fn();
const mockDjClient = {
namespaceSources: vi
.fn()
.mockResolvedValue(
sources || { total_deployments: 0, primary_source: null },
),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn(ns => Promise.resolve(configByNamespace[ns])),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader
namespace={namespace}
onReadOnlyChange={onReadOnlyChange}
/>
</DJClientContext.Provider>
</MemoryRouter>,
);
return { onReadOnlyChange };
};
// Git configs for a repo `test` whose default branch is `main`, a feature
// branch `feature`, and their sub-namespaces. Reused across the verdict cases.
const GIT_ROOT = {
github_repo_path: 'test/repo',
git_branch: null,
default_branch: 'main',
git_root_namespace: 'test',
};
const branchCfg = (branch, ns, extra = {}) => ({
github_repo_path: 'test/repo',
git_branch: branch,
git_only: false,
branch_namespace: `test.${branch}`,
git_root_namespace: 'test',
...(ns === `test.${branch}` ? { parent_namespace: 'test' } : {}),
...extra,
});
describe('<NamespaceHeader />', () => {
it('should render and match the snapshot', async () => {
const mockDjClient = {
namespaceSources: vi
.fn()
.mockResolvedValue({ total_deployments: 0, primary_source: null }),
listDeployments: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue({}),
};
const { asFragment } = render(
<MemoryRouter initialEntries={['/namespaces/shared.dimensions.accounts']}>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="shared.dimensions.accounts" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Let git config resolve so the snapshot is the settled state.
await waitFor(() =>
expect(mockDjClient.getNamespaceGitConfig).toHaveBeenCalled(),
);
expect(asFragment()).toMatchSnapshot();
});
it('should render git source badge when source type is git with branch', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 5,
primary_source: {
type: 'git',
repository: 'github.com/test/repo',
branch: 'main',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
'test.namespace',
);
});
// Git-deployed namespaces are read-only; the strip was removed, but the
// Read-only badge in the breadcrumb area still shows for isGitManaged.
expect(screen.getByText('Read-only')).toBeInTheDocument();
// No "Deployed from Git" text (strip removed) and no local-deploy badge
expect(screen.queryByText(/Local Deploy/)).not.toBeInTheDocument();
});
it('should render git source badge when source type is git without branch', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 3,
primary_source: {
type: 'git',
repository: 'github.com/test/repo',
branch: null,
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
'test.namespace',
);
});
// Git-deployed namespaces show the Read-only badge (strip removed).
expect(screen.getByText('Read-only')).toBeInTheDocument();
expect(screen.queryByText(/Local Deploy/)).not.toBeInTheDocument();
});
it('should render local source badge when source type is local', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 2,
primary_source: {
type: 'local',
hostname: 'localhost',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
'test.namespace',
);
});
// Should render Local Deploy badge for local source
expect(screen.getByText(/Local Deploy/)).toBeInTheDocument();
});
it('should not render badge when no deployments', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
'test.namespace',
);
});
// Should not render any source badge
expect(screen.queryByText(/Deployed from Git/)).not.toBeInTheDocument();
expect(screen.queryByText(/Local Deploy/)).not.toBeInTheDocument();
});
it('should handle API error gracefully', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockRejectedValue(new Error('API Error')),
listDeployments: vi.fn().mockResolvedValue([]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
'test.namespace',
);
});
// Should still render breadcrumb without badge
expect(screen.getByText('test')).toBeInTheDocument();
expect(screen.getByText('namespace')).toBeInTheDocument();
expect(screen.queryByText(/Deployed from Git/)).not.toBeInTheDocument();
});
it('should show read-only controls (not a dropdown) for git-deployed namespace', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 5,
primary_source: {
type: 'git',
repository: 'github.com/test/repo',
branch: 'main',
},
}),
listDeployments: vi.fn().mockResolvedValue([
{
uuid: 'deploy-1',
status: 'success',
created_at: '2024-01-15T10:00:00Z',
source: {
type: 'git',
repository: 'github.com/test/repo',
branch: 'main',
commit_sha: 'abc1234567890',
},
},
]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Git-deployed namespace shows the Read-only badge in the breadcrumb area.
await waitFor(() => {
expect(screen.getByText('Read-only')).toBeInTheDocument();
});
// Should NOT show "Edited in DataJunction" or "Connect to Git"
expect(
screen.queryByText(/Edited in DataJunction/),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /connect to git/i }),
).not.toBeInTheDocument();
// Status strip is removed; no .git-status-strip element
expect(document.querySelector('.git-status-strip')).toBeNull();
// Git settings is available via the Git menu
expect(
screen.getAllByRole('button', { name: /git settings/i }).length,
).toBeGreaterThanOrEqual(1);
});
it('should open dropdown when clicking local deploy button', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 2,
primary_source: {
type: 'local',
hostname: 'localhost',
},
}),
listDeployments: vi.fn().mockResolvedValue([
{
uuid: 'deploy-1',
status: 'success',
created_at: '2024-01-15T10:00:00Z',
created_by: 'testuser',
source: {
type: 'local',
hostname: 'localhost',
reason: 'testing',
},
},
]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/Local Deploy/)).toBeInTheDocument();
});
// Click the dropdown button
fireEvent.click(screen.getByText(/Local Deploy/));
// Should show local deploy info in dropdown
await waitFor(() => {
expect(screen.getByText(/Local deploys by testuser/)).toBeInTheDocument();
});
});
it('should show recent deployments list with git source', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 3,
primary_source: {
type: 'git',
repository: 'github.com/test/repo',
branch: 'main',
},
}),
listDeployments: vi.fn().mockResolvedValue([
{
uuid: 'deploy-1',
status: 'success',
created_at: '2024-01-15T10:00:00Z',
source: {
type: 'git',
repository: 'github.com/test/repo',
branch: 'feature-branch',
commit_sha: 'abc1234567890',
},
},
{
uuid: 'deploy-2',
status: 'failed',
created_at: '2024-01-14T10:00:00Z',
source: {
type: 'git',
repository: 'github.com/test/repo',
branch: 'main',
commit_sha: 'def4567890123',
},
},
]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Git-deployed namespaces show the Read-only badge (strip removed).
await waitFor(() => {
expect(screen.getByText('Read-only')).toBeInTheDocument();
});
// Git deployments are not shown in a dropdown; no local-deploy-style list.
expect(screen.queryByText(/feature-branch/)).not.toBeInTheDocument();
});
it('should show local deployments with reason', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 2,
primary_source: {
type: 'local',
},
}),
listDeployments: vi.fn().mockResolvedValue([
{
uuid: 'deploy-1',
status: 'success',
created_at: '2024-01-15T10:00:00Z',
source: {
type: 'local',
reason: 'hotfix deployment',
hostname: 'dev-machine',
},
},
]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/Local Deploy/)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Local Deploy/));
// Should show reason in deployment list
await waitFor(() => {
expect(screen.getByText(/hotfix deployment/)).toBeInTheDocument();
});
});
it('should close local deploy dropdown when clicking outside', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 2,
primary_source: {
type: 'local',
hostname: 'localhost',
},
}),
listDeployments: vi.fn().mockResolvedValue([
{
uuid: 'deploy-1',
status: 'success',
created_at: '2024-01-15T10:00:00Z',
created_by: 'testuser',
source: { type: 'local', hostname: 'localhost', reason: 'testing' },
},
]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/Local Deploy/)).toBeInTheDocument();
});
// Open dropdown
fireEvent.click(screen.getByText(/Local Deploy/));
await waitFor(() => {
expect(screen.getByText(/Local deploys by testuser/)).toBeInTheDocument();
});
// Click outside
fireEvent.mouseDown(document.body);
// Dropdown should close
await waitFor(() => {
expect(
screen.queryByText(/Local deploys by testuser/),
).not.toBeInTheDocument();
});
});
it('should toggle local deploy dropdown arrow indicator', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 2,
primary_source: {
type: 'local',
hostname: 'localhost',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/Local Deploy/)).toBeInTheDocument();
});
// Initially shows down arrow
expect(screen.getByText('▼')).toBeInTheDocument();
// Click to open
fireEvent.click(screen.getByText(/Local Deploy/));
// Should show up arrow when open
await waitFor(() => {
expect(screen.getByText('▲')).toBeInTheDocument();
});
});
it('should show read-only controls even with https-prefixed repo in sources', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'git',
repository: 'https://github.com/test/repo',
branch: 'main',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Git-deployed namespaces show the Read-only badge (strip removed).
await waitFor(() => {
expect(screen.getByText('Read-only')).toBeInTheDocument();
});
// Should NOT show "Connect to Git" or "Edited in DataJunction"
expect(
screen.queryByText(/Edited in DataJunction/),
).not.toBeInTheDocument();
expect(
screen.queryByRole('button', { name: /connect to git/i }),
).not.toBeInTheDocument();
});
it('should render adhoc deployment label when no created_by', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'local',
},
}),
listDeployments: vi.fn().mockResolvedValue([
{
uuid: 'deploy-1',
status: 'success',
created_at: '2024-01-15T10:00:00Z',
created_by: null,
source: {
type: 'local',
},
},
]),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/Local Deploy/)).toBeInTheDocument();
});
fireEvent.click(screen.getByText(/Local Deploy/));
await waitFor(() => {
expect(screen.getByText(/Local\/adhoc deployments/)).toBeInTheDocument();
});
});
it('should show Connect to Git button and open git settings modal', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue(null),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Plain (not-git) state shows a "Connect to Git" button that opens the modal.
await waitFor(() => {
expect(screen.getByText('Connect to Git')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Connect to Git'));
await waitFor(() => {
expect(screen.getByText('Git Configuration')).toBeInTheDocument();
});
});
it('should show git action buttons when git is configured', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'git',
repository: 'test/repo',
branch: 'main',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue({
github_repo_path: 'test/repo',
git_path: 'nodes/',
default_branch: 'main',
// No git_branch or parent_namespace - this is a git root with default_branch
git_root_namespace: 'test.namespace',
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// For git root namespaces with default_branch, button is labeled "New Branch"
await waitFor(() => {
expect(screen.getByText('New Branch')).toBeInTheDocument();
});
});
it('should show Create PR and Delete Branch for branch namespaces', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
// No git deployments so isGitManaged stays false and editable-branch controls show.
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('Create PR')).toBeInTheDocument();
});
expect(
screen.getByRole('button', { name: 'Delete branch' }),
).toBeInTheDocument();
});
describe('read-only verdict (onReadOnlyChange)', () => {
// Regression: a sub-namespace inside a feature branch resolves as `flat`
// (inherited git_branch, no parent_namespace of its own). It must be
// editable (its branch isn't the default), while the default branch — and
// a git-deployed feature branch stays editable too.
const cases = [
{
what: 'a sub-namespace of a feature branch as EDITABLE',
namespace: 'test.feature.metrics',
configByNamespace: {
'test.feature.metrics': branchCfg('feature', 'test.feature.metrics'),
'test.feature': branchCfg('feature', 'test.feature'),
test: GIT_ROOT,
},
expectedReadOnly: false,
},
{
what: 'a sub-namespace of the default branch as READ-ONLY',
namespace: 'test.main.metrics',
configByNamespace: {
'test.main.metrics': branchCfg('main', 'test.main.metrics'),
'test.main': branchCfg('main', 'test.main'),
test: GIT_ROOT,
},
expectedReadOnly: true,
},
{
what: 'a git-deployed feature branch as EDITABLE (deploy status does not lock it)',
namespace: 'test.feature',
configByNamespace: {
'test.feature': branchCfg('feature', 'test.feature'),
test: GIT_ROOT,
},
sources: { total_deployments: 2, primary_source: { type: 'git' } },
expectedReadOnly: false,
},
];
cases.forEach(
({ what, namespace, configByNamespace, sources, expectedReadOnly }) => {
it(`reports ${what}`, async () => {
const { onReadOnlyChange } = renderReadOnlyVerdict({
namespace,
configByNamespace,
sources,
});
await waitFor(() => expect(onReadOnlyChange).toHaveBeenCalled());
expect(onReadOnlyChange).toHaveBeenLastCalledWith(expectedReadOnly);
});
},
);
});
it('should open Create Branch modal when button is clicked', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'git',
repository: 'test/repo',
branch: 'main',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue({
github_repo_path: 'test/repo',
git_path: 'nodes/',
default_branch: 'main',
// No git_branch or parent_namespace - this is a git root with default_branch
git_root_namespace: 'test.namespace',
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('New Branch')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('New Branch'));
await waitFor(() => {
expect(screen.getByLabelText('Branch Name')).toBeInTheDocument();
});
});
it('should open Sync to Git modal when button is clicked', async () => {
// Sync to Git only shows for editable branch namespaces (no git deployments).
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
// No git deployments so isGitManaged stays false and editable-branch controls show.
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('Sync to Git')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Sync to Git'));
await waitFor(() => {
expect(screen.getByText(/Sync all nodes in/)).toBeInTheDocument();
});
});
it('should call updateNamespaceGitConfig when saving git settings', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue(null),
updateNamespaceGitConfig: vi.fn().mockResolvedValue({
github_repo_path: 'myorg/repo',
git_branch: 'main',
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Plain (not-git) state shows Connect to Git which opens the git settings modal.
await waitFor(() => {
expect(screen.getByText('Connect to Git')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Connect to Git'));
await waitFor(() => {
expect(screen.getByText('Git Configuration')).toBeInTheDocument();
});
// Q1: choose "Git repo" (modal opens with 'dj' selected since config is null)
fireEvent.click(screen.getByRole('button', { name: 'Git repo' }));
// Q2: choose "Tracks a branch" (flat model)
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Tracks a branch' }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Tracks a branch' }));
// Fill Repository and Branch fields
fireEvent.change(screen.getByLabelText(/Repository/), {
target: { value: 'myorg/repo' },
});
fireEvent.change(screen.getByLabelText(/Branch/), {
target: { value: 'main' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(mockDjClient.updateNamespaceGitConfig).toHaveBeenCalledWith(
'test.namespace',
expect.objectContaining({
github_repo_path: 'myorg/repo',
}),
);
});
});
it('should call createBranch when creating a branch', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'git',
repository: 'test/repo',
branch: 'main',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue({
github_repo_path: 'test/repo',
git_path: 'nodes/',
default_branch: 'main',
// No git_branch or parent_namespace - this is a git root
git_root_namespace: 'test.namespace',
}),
createBranch: vi.fn().mockResolvedValue({
branch: {
namespace: 'test.namespace.feature_xyz',
git_branch: 'feature-xyz',
parent_namespace: 'test.namespace',
},
deployment_results: [],
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('New Branch')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('New Branch'));
await waitFor(() => {
expect(screen.getByLabelText('Branch Name')).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText('Branch Name'), {
target: { value: 'feature-xyz' },
});
// The button inside the modal is labeled "Create Branch"
fireEvent.click(screen.getByRole('button', { name: 'Create Branch' }));
await waitFor(() => {
expect(mockDjClient.createBranch).toHaveBeenCalledWith(
'test.namespace',
'feature-xyz',
);
});
});
it('should create a branch from the git root when on a branch page', async () => {
// Regression: git roots redirect to their default branch, so the only place
// to start a new branch is the branch switcher on the branch page. Creating
// one must still target the git root (`test`), not the current branch.
const gitConfigByNamespace = {
// The default-branch page (the git root redirects here). It's read-only,
// so it renders the inline "New Branch" toolbar flow this test drives.
'test.main': {
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test',
branch_namespace: 'test.main',
git_root_namespace: 'test',
},
test: {
github_repo_path: 'test/repo',
git_branch: 'main',
default_branch: 'main',
git_path: 'nodes/',
git_root_namespace: 'test',
},
};
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'git',
repository: 'test/repo',
branch: 'feature',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn(ns =>
Promise.resolve(gitConfigByNamespace[ns]),
),
getNamespaceBranches: vi
.fn()
.mockResolvedValue([
{ namespace: 'test.feature', git_branch: 'feature', num_nodes: 0 },
]),
getPullRequest: vi.fn().mockResolvedValue(null),
createBranch: vi.fn().mockResolvedValue({
branch: {
namespace: 'test.feature_xyz',
git_branch: 'feature-xyz',
parent_namespace: 'test',
},
deployment_results: [],
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.main" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Both entry points are present on a branch page: the toolbar button and
// the branch switcher dropdown footer.
await waitFor(() => {
expect(screen.getByText('New Branch')).toBeInTheDocument();
});
// Click the branch-crumb button (not the <code> in the git strip).
fireEvent.click(
screen
.getAllByText('main')
.find(el => el.closest('button') && el.tagName !== 'CODE'),
);
expect(await screen.findByText('New branch')).toBeInTheDocument();
// Drive creation via the toolbar button.
fireEvent.click(screen.getByText('New Branch'));
await waitFor(() => {
expect(screen.getByLabelText('Branch Name')).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText('Branch Name'), {
target: { value: 'feature-xyz' },
});
fireEvent.click(screen.getByRole('button', { name: 'Create Branch' }));
await waitFor(() => {
expect(mockDjClient.createBranch).toHaveBeenCalledWith(
'test',
'feature-xyz',
);
});
});
it('should call syncNamespaceToGit when syncing', async () => {
// Sync to Git only shows for editable branch namespaces (no git deployments).
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
// No git deployments so isGitManaged stays false and editable-branch controls show.
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
syncNamespaceToGit: vi.fn().mockResolvedValue({
files_synced: 5,
commit_sha: 'abc123',
commit_url: 'https://github.com/test/repo/commit/abc123',
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('Sync to Git')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Sync to Git'));
await waitFor(() => {
expect(screen.getByLabelText(/Commit Message/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Commit Message/), {
target: { value: 'Test commit' },
});
fireEvent.click(screen.getByRole('button', { name: 'Sync Now' }));
await waitFor(() => {
expect(mockDjClient.syncNamespaceToGit).toHaveBeenCalledWith(
'test.feature',
'Test commit',
);
});
});
it('should show View PR button when PR exists', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
// No git deployments so isGitManaged stays false and editable-branch controls show.
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue({
pr_number: 42,
pr_url: 'https://github.com/test/repo/pull/42',
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText(/View PR #42/)).toBeInTheDocument();
});
});
it('should call createPullRequest when creating a PR', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
// No git deployments so isGitManaged stays false and editable-branch controls show.
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
syncNamespaceToGit: vi.fn().mockResolvedValue({
files_synced: 3,
commit_sha: 'abc123',
commit_url: 'https://github.com/test/repo/commit/abc123',
}),
createPullRequest: vi.fn().mockResolvedValue({
pr_number: 99,
pr_url: 'https://github.com/test/repo/pull/99',
head_branch: 'feature',
base_branch: 'main',
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('Create PR')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Create PR'));
await waitFor(() => {
expect(screen.getByLabelText(/Title/)).toBeInTheDocument();
});
fireEvent.change(screen.getByLabelText(/Title/), {
target: { value: 'My PR Title' },
});
fireEvent.change(screen.getByLabelText(/Description/), {
target: { value: 'PR description' },
});
// There are two "Create PR" buttons - one in header, one in modal
// Get all and click the last one (modal's submit button)
const createPRButtons = screen.getAllByRole('button', {
name: 'Create PR',
});
fireEvent.click(createPRButtons[createPRButtons.length - 1]);
await waitFor(() => {
expect(mockDjClient.syncNamespaceToGit).toHaveBeenCalledWith(
'test.feature',
'My PR Title',
);
});
await waitFor(() => {
expect(mockDjClient.createPullRequest).toHaveBeenCalledWith(
'test.feature',
'My PR Title',
'PR description',
);
});
});
it('should call deleteBranch when deleting a branch', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
// No git deployments so isGitManaged stays false and editable-branch controls show.
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
deleteBranch: vi.fn().mockResolvedValue({ success: true }),
};
// Mock window.location
delete window.location;
window.location = { href: '' };
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(
screen.getByRole('button', { name: 'Delete branch' }),
).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Delete branch' }));
await waitFor(() => {
expect(screen.getByRole('checkbox')).toBeInTheDocument();
});
// The modal's submit button is named "Delete Branch"; click it.
const deleteBranchButtons = screen.getAllByRole('button', {
name: 'Delete Branch',
});
fireEvent.click(deleteBranchButtons[deleteBranchButtons.length - 1]);
await waitFor(() => {
expect(mockDjClient.deleteBranch).toHaveBeenCalledWith(
'test.main',
'test.feature',
true,
);
});
});
it('should fetch parent git config for branch namespace', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'git',
repository: 'test/repo',
branch: 'feature',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(mockDjClient.getNamespaceGitConfig).toHaveBeenCalledWith(
'test.feature',
);
});
await waitFor(() => {
expect(mockDjClient.getNamespaceGitConfig).toHaveBeenCalledWith(
'test.main',
);
});
await waitFor(() => {
expect(mockDjClient.getPullRequest).toHaveBeenCalledWith('test.feature');
});
});
it('should handle error fetching parent git config gracefully', async () => {
const consoleSpy = vi.spyOn(console, 'error').mockImplementation();
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 1,
primary_source: {
type: 'git',
repository: 'test/repo',
branch: 'feature',
},
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockRejectedValueOnce(new Error('Parent not found')),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockResolvedValue(null),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(consoleSpy).toHaveBeenCalledWith(
'Failed to fetch parent git config:',
expect.any(Error),
);
});
consoleSpy.mockRestore();
});
it('should handle error fetching PR gracefully', async () => {
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
// No git deployments so isGitManaged stays false and editable-branch controls show.
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'feature',
git_path: 'nodes/',
git_only: false,
parent_namespace: 'test.main',
branch_namespace: 'test.feature',
})
.mockResolvedValueOnce({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
}),
getNamespaceBranches: vi.fn().mockResolvedValue([]),
getPullRequest: vi.fn().mockRejectedValue(new Error('API Error')),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.feature" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Should render without crashing and show Create PR button
await waitFor(() => {
expect(screen.getByText('Create PR')).toBeInTheDocument();
});
});
it('should call onGitConfigLoaded callback when config is fetched', async () => {
const onGitConfigLoaded = vi.fn();
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue({
github_repo_path: 'test/repo',
git_branch: 'main',
}),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader
namespace="test.namespace"
onGitConfigLoaded={onGitConfigLoaded}
/>
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(onGitConfigLoaded).toHaveBeenCalledWith({
github_repo_path: 'test/repo',
git_branch: 'main',
});
});
});
it('should call onGitConfigLoaded with null when git config fetch fails', async () => {
const onGitConfigLoaded = vi.fn();
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi
.fn()
.mockRejectedValue(new Error('Config not found')),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader
namespace="test.namespace"
onGitConfigLoaded={onGitConfigLoaded}
/>
</DJClientContext.Provider>
</MemoryRouter>,
);
await waitFor(() => {
expect(onGitConfigLoaded).toHaveBeenCalledWith(null);
});
});
it('should call deleteNamespaceGitConfig when removing git settings', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true);
const mockDjClient = {
namespaceSources: vi.fn().mockResolvedValue({
total_deployments: 0,
primary_source: null,
}),
listDeployments: vi.fn().mockResolvedValue([]),
getNamespaceGitConfig: vi.fn().mockResolvedValue({
github_repo_path: 'test/repo',
git_branch: 'main',
git_path: 'nodes/',
git_only: false,
}),
deleteNamespaceGitConfig: vi.fn().mockResolvedValue({ success: true }),
};
render(
<MemoryRouter>
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
<NamespaceHeader namespace="test.namespace" />
</DJClientContext.Provider>
</MemoryRouter>,
);
// Flat (read-only) state: Git settings is accessible via the Git menu.
await waitFor(() => {