UNPKG

datajunction-ui

Version:
1,588 lines (1,423 loc) 69.3 kB
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(() => {