UNPKG

datajunction-ui

Version:
1,327 lines (1,039 loc) 43 kB
import React from 'react'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter } from 'react-router-dom'; import { CreateBranchModal } from '../git/CreateBranchModal'; import { DeleteBranchModal } from '../git/DeleteBranchModal'; import { SyncToGitModal } from '../git/SyncToGitModal'; import { GitSettingsModal } from '../git/GitSettingsModal'; import { CreatePRModal } from '../git/CreatePRModal'; describe('<CreateBranchModal />', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), onCreate: vi.fn(), namespace: 'analytics.prod', gitBranch: 'main', }; beforeEach(() => { vi.clearAllMocks(); }); it('should not render when isOpen is false', () => { render(<CreateBranchModal {...defaultProps} isOpen={false} />); expect(screen.queryByText('Create Branch')).not.toBeInTheDocument(); }); it('should render the modal when open', () => { render(<CreateBranchModal {...defaultProps} />); // Use role to distinguish header from button expect( screen.getByRole('heading', { name: 'Create Branch' }), ).toBeInTheDocument(); expect(screen.getByLabelText('Branch Name')).toBeInTheDocument(); }); it('should show preview when branch name is entered', async () => { render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); expect( screen.getByText(/Create git branch "feature-xyz"/), ).toBeInTheDocument(); expect(screen.getByText(/analytics\.feature_xyz/)).toBeInTheDocument(); }); it('should handle branch name with slashes', async () => { render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature/new-thing'); // Slashes should be converted to underscores in namespace preview expect( screen.getByText(/analytics\.feature_new_thing/), ).toBeInTheDocument(); }); it('should disable submit button when branch name is empty', () => { render(<CreateBranchModal {...defaultProps} />); const submitButton = screen.getByRole('button', { name: 'Create Branch' }); expect(submitButton).toBeDisabled(); }); it('should disable submit button when branch name is whitespace only', async () => { render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, ' '); const submitButton = screen.getByRole('button', { name: 'Create Branch' }); expect(submitButton).toBeDisabled(); }); it('should call onCreate when form is submitted', async () => { defaultProps.onCreate.mockResolvedValue({ branch: { namespace: 'analytics.feature_xyz', git_branch: 'feature-xyz', parent_namespace: 'analytics.prod', }, deployment_results: [], }); render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); const submitButton = screen.getByRole('button', { name: 'Create Branch' }); await userEvent.click(submitButton); await waitFor(() => { expect(defaultProps.onCreate).toHaveBeenCalledWith('feature-xyz'); }); }); it('should show success view after branch creation', async () => { defaultProps.onCreate.mockResolvedValue({ branch: { namespace: 'analytics.feature_xyz', git_branch: 'feature-xyz', parent_namespace: 'analytics.prod', }, deployment_results: [{ node: 'test' }], }); render( <MemoryRouter> <CreateBranchModal {...defaultProps} /> </MemoryRouter>, ); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); await userEvent.click( screen.getByRole('button', { name: 'Create Branch' }), ); await waitFor(() => { expect(screen.getByText('Branch Created!')).toBeInTheDocument(); }); expect(screen.getByText('analytics.feature_xyz')).toBeInTheDocument(); expect(screen.getByText('Nodes copied:')).toBeInTheDocument(); }); it('should show success view without nodes copied section when no deployment results', async () => { defaultProps.onCreate.mockResolvedValue({ branch: { namespace: 'analytics.feature_xyz', git_branch: 'feature-xyz', parent_namespace: 'analytics.prod', }, deployment_results: [], }); render( <MemoryRouter> <CreateBranchModal {...defaultProps} /> </MemoryRouter>, ); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); await userEvent.click( screen.getByRole('button', { name: 'Create Branch' }), ); await waitFor(() => { expect(screen.getByText('Branch Created!')).toBeInTheDocument(); }); expect(screen.queryByText('Nodes copied:')).not.toBeInTheDocument(); }); it('should show error when creation fails', async () => { defaultProps.onCreate.mockResolvedValue({ _error: true, message: 'Branch already exists', }); render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); await userEvent.click( screen.getByRole('button', { name: 'Create Branch' }), ); await waitFor(() => { expect(screen.getByText('Branch already exists')).toBeInTheDocument(); }); }); it('should show error when onCreate throws exception', async () => { defaultProps.onCreate.mockRejectedValue(new Error('Network error')); render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); await userEvent.click( screen.getByRole('button', { name: 'Create Branch' }), ); await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); }); it('should show default error when onCreate throws without message', async () => { defaultProps.onCreate.mockRejectedValue({}); render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); await userEvent.click( screen.getByRole('button', { name: 'Create Branch' }), ); await waitFor(() => { expect(screen.getByText('Failed to create branch')).toBeInTheDocument(); }); }); it('should call onClose when Cancel is clicked', async () => { render(<CreateBranchModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking overlay', async () => { render(<CreateBranchModal {...defaultProps} />); const overlay = document.querySelector('.modal-overlay'); fireEvent.click(overlay); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking close button', async () => { render(<CreateBranchModal {...defaultProps} />); await userEvent.click(screen.getByTitle('Close')); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should not propagate click from modal content to overlay', async () => { render(<CreateBranchModal {...defaultProps} />); const modalContent = document.querySelector('.modal-content'); fireEvent.click(modalContent); expect(defaultProps.onClose).not.toHaveBeenCalled(); }); it('should reset state when closed from success view', async () => { defaultProps.onCreate.mockResolvedValue({ branch: { namespace: 'analytics.feature_xyz', git_branch: 'feature-xyz', parent_namespace: 'analytics.prod', }, deployment_results: [], }); render( <MemoryRouter> <CreateBranchModal {...defaultProps} /> </MemoryRouter>, ); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); await userEvent.click( screen.getByRole('button', { name: 'Create Branch' }), ); await waitFor(() => { expect(screen.getByText('Branch Created!')).toBeInTheDocument(); }); // Click Close button in success view await userEvent.click(screen.getByRole('button', { name: 'Close' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should show Creating... button text while creating', async () => { let resolveCreate; defaultProps.onCreate.mockImplementation( () => new Promise(resolve => { resolveCreate = resolve; }), ); render(<CreateBranchModal {...defaultProps} />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature-xyz'); await userEvent.click( screen.getByRole('button', { name: 'Create Branch' }), ); expect(screen.getByText('Creating...')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Creating...' })).toBeDisabled(); // Resolve to complete the test resolveCreate({ branch: { namespace: 'test', git_branch: 'test', parent_namespace: 'p' }, deployment_results: [], }); }); it('should handle single-part namespace', async () => { render(<CreateBranchModal {...defaultProps} namespace="analytics" />); const input = screen.getByLabelText('Branch Name'); await userEvent.type(input, 'feature'); // For single-part namespace, result should be "analytics.feature" expect(screen.getByText(/analytics\.feature/)).toBeInTheDocument(); }); }); describe('<DeleteBranchModal />', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), onDelete: vi.fn(), namespace: 'analytics.feature_xyz', gitBranch: 'feature-xyz', parentNamespace: 'analytics.prod', }; beforeEach(() => { vi.clearAllMocks(); delete window.location; window.location = { href: '' }; }); it('should not render when isOpen is false', () => { render(<DeleteBranchModal {...defaultProps} isOpen={false} />); expect(screen.queryByText('Delete Branch')).not.toBeInTheDocument(); }); it('should render the modal with branch info', () => { render(<DeleteBranchModal {...defaultProps} />); // Use role to distinguish header from button expect( screen.getByRole('heading', { name: 'Delete Branch' }), ).toBeInTheDocument(); expect(screen.getByText('analytics.feature_xyz')).toBeInTheDocument(); expect(screen.getByText('feature-xyz')).toBeInTheDocument(); expect(screen.getByText('analytics.prod')).toBeInTheDocument(); }); it('should have checkbox for deleting git branch checked by default', () => { render(<DeleteBranchModal {...defaultProps} />); const checkbox = screen.getByRole('checkbox'); expect(checkbox).toBeChecked(); }); it('should toggle checkbox', async () => { render(<DeleteBranchModal {...defaultProps} />); const checkbox = screen.getByRole('checkbox'); await userEvent.click(checkbox); expect(checkbox).not.toBeChecked(); }); it('should call onDelete with checkbox value when submitted', async () => { defaultProps.onDelete.mockResolvedValue({ success: true }); render(<DeleteBranchModal {...defaultProps} />); await userEvent.click( screen.getByRole('button', { name: 'Delete Branch' }), ); await waitFor(() => { expect(defaultProps.onDelete).toHaveBeenCalledWith(true); }); }); it('should call onDelete with false when checkbox is unchecked', async () => { defaultProps.onDelete.mockResolvedValue({ success: true }); render(<DeleteBranchModal {...defaultProps} />); // Uncheck the checkbox await userEvent.click(screen.getByRole('checkbox')); await userEvent.click( screen.getByRole('button', { name: 'Delete Branch' }), ); await waitFor(() => { expect(defaultProps.onDelete).toHaveBeenCalledWith(false); }); }); it('should redirect after successful deletion', async () => { defaultProps.onDelete.mockResolvedValue({ success: true }); render(<DeleteBranchModal {...defaultProps} />); await userEvent.click( screen.getByRole('button', { name: 'Delete Branch' }), ); await waitFor(() => { expect(window.location.href).toBe('/namespaces/analytics.prod'); }); }); it('should show error when deletion fails', async () => { defaultProps.onDelete.mockResolvedValue({ _error: true, message: 'Cannot delete branch with open PR', }); render(<DeleteBranchModal {...defaultProps} />); await userEvent.click( screen.getByRole('button', { name: 'Delete Branch' }), ); await waitFor(() => { expect( screen.getByText('Cannot delete branch with open PR'), ).toBeInTheDocument(); }); }); it('should show error when onDelete throws exception', async () => { defaultProps.onDelete.mockRejectedValue(new Error('Network failure')); render(<DeleteBranchModal {...defaultProps} />); await userEvent.click( screen.getByRole('button', { name: 'Delete Branch' }), ); await waitFor(() => { expect(screen.getByText('Network failure')).toBeInTheDocument(); }); }); it('should show default error when onDelete throws without message', async () => { defaultProps.onDelete.mockRejectedValue({}); render(<DeleteBranchModal {...defaultProps} />); await userEvent.click( screen.getByRole('button', { name: 'Delete Branch' }), ); await waitFor(() => { expect(screen.getByText('Failed to delete branch')).toBeInTheDocument(); }); }); it('should call onClose when Cancel is clicked', async () => { render(<DeleteBranchModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking overlay', async () => { render(<DeleteBranchModal {...defaultProps} />); const overlay = document.querySelector('.modal-overlay'); fireEvent.click(overlay); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking close button', async () => { render(<DeleteBranchModal {...defaultProps} />); await userEvent.click(screen.getByTitle('Close')); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should show Deleting... button text while deleting', async () => { let resolveDelete; defaultProps.onDelete.mockImplementation( () => new Promise(resolve => { resolveDelete = resolve; }), ); render(<DeleteBranchModal {...defaultProps} />); await userEvent.click( screen.getByRole('button', { name: 'Delete Branch' }), ); expect(screen.getByText('Deleting...')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Deleting...' })).toBeDisabled(); // Resolve to complete the test resolveDelete({ success: true }); }); }); describe('<SyncToGitModal />', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), onSync: vi.fn(), namespace: 'analytics.prod', gitBranch: 'main', repoPath: 'myorg/dj-definitions', }; beforeEach(() => { vi.clearAllMocks(); }); it('should not render when isOpen is false', () => { render(<SyncToGitModal {...defaultProps} isOpen={false} />); expect(screen.queryByText('Sync to Git')).not.toBeInTheDocument(); }); it('should render the modal with namespace info', () => { render(<SyncToGitModal {...defaultProps} />); expect(screen.getByText('Sync to Git')).toBeInTheDocument(); expect(screen.getByText('myorg/dj-definitions')).toBeInTheDocument(); expect(screen.getByText('main')).toBeInTheDocument(); expect(screen.getByText(/Sync all nodes in/)).toBeInTheDocument(); expect(screen.getByText('analytics.prod')).toBeInTheDocument(); }); it('should allow entering commit message', async () => { render(<SyncToGitModal {...defaultProps} />); const input = screen.getByLabelText(/Commit Message/); await userEvent.type(input, 'Add new metrics'); expect(input).toHaveValue('Add new metrics'); }); it('should call onSync when form is submitted', async () => { defaultProps.onSync.mockResolvedValue({ files_synced: 5, commit_sha: 'abc123def', commit_url: 'https://github.com/myorg/repo/commit/abc123', }); render(<SyncToGitModal {...defaultProps} />); const input = screen.getByLabelText(/Commit Message/); await userEvent.type(input, 'Update metrics'); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(defaultProps.onSync).toHaveBeenCalledWith('Update metrics'); }); }); it('should call onSync with null for empty commit message', async () => { defaultProps.onSync.mockResolvedValue({ files_synced: 3, commit_sha: 'abc123', commit_url: 'https://github.com/myorg/repo/commit/abc123', }); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(defaultProps.onSync).toHaveBeenCalledWith(null); }); }); it('should call onSync with null for whitespace-only commit message', async () => { defaultProps.onSync.mockResolvedValue({ files_synced: 3, commit_sha: 'abc123', commit_url: 'https://github.com/myorg/repo/commit/abc123', }); render(<SyncToGitModal {...defaultProps} />); const input = screen.getByLabelText(/Commit Message/); await userEvent.type(input, ' '); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(defaultProps.onSync).toHaveBeenCalledWith(null); }); }); it('should show success view after sync', async () => { defaultProps.onSync.mockResolvedValue({ files_synced: 5, commit_sha: 'abc123def456', commit_url: 'https://github.com/myorg/repo/commit/abc123', }); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(screen.getByText('Synced 5 files!')).toBeInTheDocument(); }); expect(screen.getByText('abc123d')).toBeInTheDocument(); expect(screen.getByRole('link', { name: 'View Commit' })).toHaveAttribute( 'href', 'https://github.com/myorg/repo/commit/abc123', ); }); it('should show singular "file" for 1 file synced', async () => { defaultProps.onSync.mockResolvedValue({ files_synced: 1, commit_sha: 'abc123', commit_url: 'https://github.com/myorg/repo/commit/abc123', }); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(screen.getByText('Synced 1 file!')).toBeInTheDocument(); }); }); it('should show error when sync fails', async () => { defaultProps.onSync.mockResolvedValue({ _error: true, message: 'No nodes to sync', }); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(screen.getByText('No nodes to sync')).toBeInTheDocument(); }); }); it('should show error when onSync throws exception', async () => { defaultProps.onSync.mockRejectedValue(new Error('GitHub API error')); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(screen.getByText('GitHub API error')).toBeInTheDocument(); }); }); it('should show default error when onSync throws without message', async () => { defaultProps.onSync.mockRejectedValue({}); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(screen.getByText('Failed to sync to git')).toBeInTheDocument(); }); }); it('should call onClose when Cancel is clicked', async () => { render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking overlay', async () => { render(<SyncToGitModal {...defaultProps} />); const overlay = document.querySelector('.modal-overlay'); fireEvent.click(overlay); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking close button', async () => { render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByTitle('Close')); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should reset state when closed from success view', async () => { defaultProps.onSync.mockResolvedValue({ files_synced: 5, commit_sha: 'abc123', commit_url: 'https://github.com/myorg/repo/commit/abc123', }); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(screen.getByText('Synced 5 files!')).toBeInTheDocument(); }); // Click Close button in success view await userEvent.click(screen.getByRole('button', { name: 'Close' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should show Syncing... button text while syncing', async () => { let resolveSync; defaultProps.onSync.mockImplementation( () => new Promise(resolve => { resolveSync = resolve; }), ); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); expect(screen.getByText('Syncing...')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Syncing...' })).toBeDisabled(); // Resolve to complete the test resolveSync({ files_synced: 1, commit_sha: 'abc', commit_url: 'https://github.com/test', }); }); it('should display branch info in success view', async () => { defaultProps.onSync.mockResolvedValue({ files_synced: 5, commit_sha: 'abc123', commit_url: 'https://github.com/myorg/repo/commit/abc123', }); render(<SyncToGitModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Sync Now' })); await waitFor(() => { expect(screen.getByText('Synced 5 files!')).toBeInTheDocument(); }); // Branch info should be shown expect(screen.getByText('Branch:')).toBeInTheDocument(); expect(screen.getByText('main')).toBeInTheDocument(); }); }); describe('<GitSettingsModal />', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), onSave: vi.fn(), onRemove: vi.fn(), currentConfig: null, namespace: 'analytics.prod', nodeCount: 0, }; beforeEach(() => { vi.clearAllMocks(); }); it('should not render when isOpen is false', () => { render(<GitSettingsModal {...defaultProps} isOpen={false} />); expect(screen.queryByText('Git Configuration')).not.toBeInTheDocument(); }); it('should render the modal heading and source toggle', () => { render(<GitSettingsModal {...defaultProps} />); expect( screen.getByRole('heading', { name: 'Git Configuration' }), ).toBeInTheDocument(); // Source of definitions toggle is always visible expect(screen.getByText('Edited in DataJunction')).toBeInTheDocument(); expect(screen.getByText('Git repo')).toBeInTheDocument(); // By default, source is "dj" so git-specific fields are hidden expect(screen.queryByLabelText(/Repository/)).not.toBeInTheDocument(); }); it('should show Repository and Branch fields when Git repo is selected', async () => { render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); expect(screen.getByLabelText(/Repository/)).toBeInTheDocument(); expect(screen.getByLabelText(/^Branch$/)).toBeInTheDocument(); // No checkbox — intent-based modal has no git_only toggle expect(screen.queryByRole('checkbox')).not.toBeInTheDocument(); }); it('should show Default branch field in root (Feature branches + PRs) mode', async () => { render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.click(screen.getByText('Feature branches + PRs')); expect(screen.getByLabelText(/Default branch/)).toBeInTheDocument(); expect(screen.queryByLabelText(/^Branch$/)).not.toBeInTheDocument(); }); it('should preselect flat mode from an existing flat (branch-tracking) config', () => { render( <GitSettingsModal {...defaultProps} currentConfig={{ github_repo_path: 'myorg/repo', git_branch: 'develop', }} />, ); // Source should default to "git", flat mode active expect(screen.getByDisplayValue('myorg/repo')).toBeInTheDocument(); expect(screen.getByDisplayValue('develop')).toBeInTheDocument(); expect(screen.getByText('Tracks a branch')).toBeInTheDocument(); }); it('should preselect root mode from an existing root config', () => { render( <GitSettingsModal {...defaultProps} currentConfig={{ github_repo_path: 'myorg/repo', default_branch: 'main', }} />, ); expect(screen.getByDisplayValue('myorg/repo')).toBeInTheDocument(); expect(screen.getByDisplayValue('main')).toBeInTheDocument(); expect(screen.getByText('Feature branches + PRs')).toBeInTheDocument(); }); it('should call onSave with flat payload when Tracks a branch form is submitted', async () => { defaultProps.onSave.mockResolvedValue({}); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.type(screen.getByLabelText(/Repository/), 'corp/defs'); await userEvent.clear(screen.getByLabelText(/^Branch$/)); await userEvent.type(screen.getByLabelText(/^Branch$/), 'develop'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect(defaultProps.onSave).toHaveBeenCalledWith({ github_repo_path: 'corp/defs', git_branch: 'develop', }); }); }); it('should call onSave with root payload when Feature branches + PRs is selected', async () => { defaultProps.onSave.mockResolvedValue({}); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.click(screen.getByText('Feature branches + PRs')); await userEvent.type(screen.getByLabelText(/Repository/), 'corp/defs'); await userEvent.clear(screen.getByLabelText(/Default branch/)); await userEvent.type(screen.getByLabelText(/Default branch/), 'trunk'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect(defaultProps.onSave).toHaveBeenCalledWith({ github_repo_path: 'corp/defs', default_branch: 'trunk', }); }); }); it('should call onRemove (not onSave) when Edited in DataJunction is selected', async () => { defaultProps.onSave.mockResolvedValue({}); defaultProps.onRemove.mockResolvedValue({}); render( <GitSettingsModal {...defaultProps} currentConfig={{ github_repo_path: 'myorg/repo', git_branch: 'main' }} />, ); // Source is already "git" from currentConfig; switch back to dj await userEvent.click(screen.getByText('Edited in DataJunction')); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect(defaultProps.onRemove).toHaveBeenCalled(); }); expect(defaultProps.onSave).not.toHaveBeenCalled(); }); it('should show an error when Repository is empty and Git repo is selected', async () => { render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); expect(screen.getByText('Repository is required')).toBeInTheDocument(); expect(defaultProps.onSave).not.toHaveBeenCalled(); }); it('should show success message after save', async () => { defaultProps.onSave.mockResolvedValue({}); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.type(screen.getByLabelText(/Repository/), 'myorg/repo'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect( screen.getByText('Git configuration saved successfully!'), ).toBeInTheDocument(); }); }); it('should show error when onSave returns _error response', async () => { defaultProps.onSave.mockResolvedValue({ _error: true, message: 'Invalid repository', }); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.type(screen.getByLabelText(/Repository/), 'invalid'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect(screen.getByText('Invalid repository')).toBeInTheDocument(); }); }); it('should show error when onSave throws exception', async () => { defaultProps.onSave.mockRejectedValue(new Error('Network error')); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.type(screen.getByLabelText(/Repository/), 'myorg/repo'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect(screen.getByText('Network error')).toBeInTheDocument(); }); }); it('should show default error message when onSave throws without message', async () => { defaultProps.onSave.mockRejectedValue({}); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.type(screen.getByLabelText(/Repository/), 'myorg/repo'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect( screen.getByText('Failed to save git settings'), ).toBeInTheDocument(); }); }); it('should warn when switching a namespace with existing nodes to git', async () => { render( <GitSettingsModal {...defaultProps} currentConfig={null} nodeCount={3} />, ); await userEvent.click(screen.getByText('Git repo')); expect( screen.getByText(/3 nodes here aren't in the repo/), ).toBeInTheDocument(); }); it('should call onClose when Cancel is clicked', async () => { render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking overlay', async () => { render(<GitSettingsModal {...defaultProps} />); const overlay = document.querySelector('.modal-overlay'); fireEvent.click(overlay); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking close button', async () => { render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByTitle('Close')); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should show Saving... and disable the button while saving', async () => { let resolveSave; defaultProps.onSave.mockImplementation( () => new Promise(resolve => { resolveSave = resolve; }), ); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.type(screen.getByLabelText(/Repository/), 'myorg/repo'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); expect(screen.getByText('Saving...')).toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Saving...' })).toBeDisabled(); resolveSave({}); }); it('should show Close button after success and call onClose when clicked', async () => { defaultProps.onSave.mockResolvedValue({}); render(<GitSettingsModal {...defaultProps} />); await userEvent.click(screen.getByText('Git repo')); await userEvent.type(screen.getByLabelText(/Repository/), 'myorg/repo'); await userEvent.click(screen.getByRole('button', { name: /^Save$/ })); await waitFor(() => { expect( screen.getByText('Git configuration saved successfully!'), ).toBeInTheDocument(); }); // Button changes from "Cancel" to "Close" after success const closeBtn = screen.getByRole('button', { name: 'Close' }); await userEvent.click(closeBtn); expect(defaultProps.onClose).toHaveBeenCalled(); }); }); describe('<CreatePRModal />', () => { const defaultProps = { isOpen: true, onClose: vi.fn(), onCreate: vi.fn(), namespace: 'analytics.feature_xyz', gitBranch: 'feature-xyz', parentBranch: 'main', repoPath: 'myorg/dj-definitions', }; beforeEach(() => { vi.clearAllMocks(); }); it('should not render when isOpen is false', () => { render(<CreatePRModal {...defaultProps} isOpen={false} />); expect(screen.queryByText('Create Pull Request')).not.toBeInTheDocument(); }); it('should render the modal with branch flow', () => { render(<CreatePRModal {...defaultProps} />); expect(screen.getByText('Create Pull Request')).toBeInTheDocument(); expect(screen.getByText('feature-xyz')).toBeInTheDocument(); expect(screen.getByText('main')).toBeInTheDocument(); }); it('should disable submit when title is empty', () => { render(<CreatePRModal {...defaultProps} />); const submitButton = screen.getByRole('button', { name: 'Create PR' }); expect(submitButton).toBeDisabled(); }); it('should disable submit when title is whitespace only', async () => { render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), ' '); const submitButton = screen.getByRole('button', { name: 'Create PR' }); expect(submitButton).toBeDisabled(); }); it('should enable submit when title is entered', async () => { render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'My PR title'); const submitButton = screen.getByRole('button', { name: 'Create PR' }); expect(submitButton).not.toBeDisabled(); }); it('should call onCreate with title and body', async () => { defaultProps.onCreate.mockResolvedValue({ pr_number: 42, pr_url: 'https://github.com/myorg/repo/pull/42', head_branch: 'feature-xyz', base_branch: 'main', }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); await userEvent.type( screen.getByLabelText(/Description/), 'This PR adds...', ); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(defaultProps.onCreate).toHaveBeenCalledWith( 'Add new metrics', 'This PR adds...', expect.any(Function), // progress callback ); }); }); it('should call onCreate with null body if description is empty', async () => { defaultProps.onCreate.mockResolvedValue({ pr_number: 42, pr_url: 'https://github.com/myorg/repo/pull/42', head_branch: 'feature-xyz', base_branch: 'main', }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); // Don't fill in description await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(defaultProps.onCreate).toHaveBeenCalledWith( 'Add new metrics', null, expect.any(Function), ); }); }); it('should show success view after PR creation', async () => { defaultProps.onCreate.mockResolvedValue({ pr_number: 42, pr_url: 'https://github.com/myorg/repo/pull/42', head_branch: 'feature-xyz', base_branch: 'main', }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(screen.getByText('Pull Request #42 Created!')).toBeInTheDocument(); }); expect( screen.getByRole('link', { name: 'View on GitHub' }), ).toHaveAttribute('href', 'https://github.com/myorg/repo/pull/42'); }); it('should show error when PR creation fails', async () => { defaultProps.onCreate.mockResolvedValue({ _error: true, message: 'A PR already exists for this branch', }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect( screen.getByText('A PR already exists for this branch'), ).toBeInTheDocument(); }); }); it('should show error when onCreate throws exception', async () => { defaultProps.onCreate.mockRejectedValue(new Error('GitHub API error')); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(screen.getByText('GitHub API error')).toBeInTheDocument(); }); }); it('should show default error when onCreate throws without message', async () => { defaultProps.onCreate.mockRejectedValue({}); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect( screen.getByText('Failed to create pull request'), ).toBeInTheDocument(); }); }); it('should show progress states', async () => { let resolvePromise; defaultProps.onCreate.mockImplementation((title, body, onProgress) => { return new Promise(resolve => { resolvePromise = resolve; // Simulate progress setTimeout(() => onProgress('syncing'), 10); setTimeout(() => onProgress('creating'), 50); }); }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Test'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(screen.getByText('Syncing to git...')).toBeInTheDocument(); }); // Resolve the promise to prevent test timeout resolvePromise({ pr_number: 1, pr_url: 'https://github.com/test', head_branch: 'test', base_branch: 'main', }); }); it('should show Creating PR... progress state', async () => { let resolvePromise; defaultProps.onCreate.mockImplementation((title, body, onProgress) => { return new Promise(resolve => { resolvePromise = resolve; // Skip syncing, go straight to creating onProgress('creating'); }); }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Test'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(screen.getByText('Creating PR...')).toBeInTheDocument(); }); // Resolve the promise to prevent test timeout resolvePromise({ pr_number: 1, pr_url: 'https://github.com/test', head_branch: 'test', base_branch: 'main', }); }); it('should call onClose when Cancel is clicked', async () => { render(<CreatePRModal {...defaultProps} />); await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking overlay', async () => { render(<CreatePRModal {...defaultProps} />); const overlay = document.querySelector('.modal-overlay'); fireEvent.click(overlay); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should call onClose when clicking close button', async () => { render(<CreatePRModal {...defaultProps} />); await userEvent.click(screen.getByTitle('Close')); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should reset state when closed from success view', async () => { defaultProps.onCreate.mockResolvedValue({ pr_number: 42, pr_url: 'https://github.com/myorg/repo/pull/42', head_branch: 'feature-xyz', base_branch: 'main', }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(screen.getByText('Pull Request #42 Created!')).toBeInTheDocument(); }); // Click Close button in success view await userEvent.click(screen.getByRole('button', { name: 'Close' })); expect(defaultProps.onClose).toHaveBeenCalled(); }); it('should display branch flow info in success view', async () => { defaultProps.onCreate.mockResolvedValue({ pr_number: 42, pr_url: 'https://github.com/myorg/repo/pull/42', head_branch: 'feature-xyz', base_branch: 'main', }); render(<CreatePRModal {...defaultProps} />); await userEvent.type(screen.getByLabelText(/Title/), 'Add new metrics'); await userEvent.click(screen.getByRole('button', { name: 'Create PR' })); await waitFor(() => { expect(screen.getByText('Pull Request #42 Created!')).toBeInTheDocument(); }); // Success view should show branch flow (text is combined like "feature-xyz → main") expect(screen.getByText(/feature-xyz.*→.*main/)).toBeInTheDocument(); }); });