diff --git a/src/test/proposal-essential.test.tsx b/src/test/proposal-essential.test.tsx new file mode 100644 index 0000000..0f25629 --- /dev/null +++ b/src/test/proposal-essential.test.tsx @@ -0,0 +1,553 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, waitFor, fireEvent, cleanup } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import ProposalDetailPage from '@/pages/ProposalDetailPage' +import { MemoryRouter, Routes, Route } from 'react-router-dom' + +const mockGet = vi.fn() +const mockPost = vi.fn() +const mockPatch = vi.fn() +const mockDelete = vi.fn() + +vi.mock('@/services/api', () => ({ + default: { + get: (...args: any[]) => mockGet(...args), + post: (...args: any[]) => mockPost(...args), + patch: (...args: any[]) => mockPatch(...args), + delete: (...args: any[]) => mockDelete(...args), + }, +})) + +const mockNavigate = vi.fn() +vi.mock('react-router-dom', async () => { + const actual = await vi.importActual('react-router-dom') + return { + ...actual, + useNavigate: () => mockNavigate, + useParams: () => ({ id: '1' }), + useSearchParams: () => [new URLSearchParams('?project_id=1'), vi.fn()], + } +}) + +type Essential = { + id: number + essential_code: string + proposal_id: number + type: 'feature' | 'improvement' | 'refactor' + title: string + description: string | null + created_by_id: number | null + created_at: string + updated_at: string | null +} + +type Proposal = { + id: number + proposal_code: string | null + title: string + description: string | null + status: 'open' | 'accepted' | 'rejected' + project_id: number + created_by_id: number | null + created_by_username: string | null + feat_task_id: string | null + essentials: Essential[] | null + generated_tasks: any[] | null + created_at: string + updated_at: string | null +} + +type Milestone = { + id: number + title: string + status: 'open' | 'freeze' | 'undergoing' | 'completed' | 'closed' +} + +const mockProposal: Proposal = { + id: 1, + proposal_code: 'PROP-001', + title: 'Test Proposal', + description: 'Test description', + status: 'open', + project_id: 1, + created_by_id: 1, + created_by_username: 'admin', + feat_task_id: null, + essentials: [], + generated_tasks: null, + created_at: '2026-03-01T00:00:00Z', + updated_at: null, +} + +const mockEssentials: Essential[] = [ + { + id: 1, + essential_code: 'ESS-001', + proposal_id: 1, + type: 'feature', + title: 'Feature Essential', + description: 'A feature essential', + created_by_id: 1, + created_at: '2026-03-01T00:00:00Z', + updated_at: null, + }, + { + id: 2, + essential_code: 'ESS-002', + proposal_id: 1, + type: 'improvement', + title: 'Improvement Essential', + description: null, + created_by_id: 1, + created_at: '2026-03-01T00:00:00Z', + updated_at: null, + }, +] + +const mockMilestones: Milestone[] = [ + { id: 1, title: 'Milestone 1', status: 'open' }, + { id: 2, title: 'Milestone 2', status: 'open' }, +] + +function setupApi(options?: { + proposal?: Proposal + essentials?: Essential[] + milestones?: Milestone[] + error?: string +}) { + const proposal = options?.proposal ?? mockProposal + const essentials = options?.essentials ?? mockEssentials + const milestones = options?.milestones ?? mockMilestones + + mockGet.mockImplementation((url: string) => { + if (url.includes('/proposals/1') && !url.includes('/essentials')) { + if (options?.error) { + return Promise.reject({ response: { data: { detail: options.error } } }) + } + return Promise.resolve({ data: proposal }) + } + if (url.includes('/essentials')) { + return Promise.resolve({ data: essentials }) + } + if (url.includes('/milestones')) { + return Promise.resolve({ data: milestones }) + } + return Promise.reject(new Error(`Unhandled GET ${url}`)) + }) + + mockPost.mockResolvedValue({ data: {} }) + mockPatch.mockResolvedValue({ data: {} }) + mockDelete.mockResolvedValue({ data: {} }) +} + +describe('ProposalDetailPage', () => { + beforeEach(() => { + vi.clearAllMocks() + setupApi() + vi.spyOn(window, 'confirm').mockReturnValue(true) + }) + + afterEach(() => { + cleanup() + vi.restoreAllMocks() + }) + + describe('Essential List Display', () => { + it('renders essentials section with count', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Essentials (2)')).toBeInTheDocument() + }) + }) + + it('displays essential cards with type badges', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Feature Essential')).toBeInTheDocument() + }) + + expect(screen.getByText('Improvement Essential')).toBeInTheDocument() + expect(screen.getByText('feature')).toBeInTheDocument() + expect(screen.getByText('improvement')).toBeInTheDocument() + }) + + it('displays essential codes', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('ESS-001')).toBeInTheDocument() + }) + + expect(screen.getByText('ESS-002')).toBeInTheDocument() + }) + + it('displays essential descriptions when available', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('A feature essential')).toBeInTheDocument() + }) + }) + + it('shows empty state when no essentials', async () => { + setupApi({ essentials: [] }) + render() + + await waitFor(() => { + expect(screen.getByText('Essentials (0)')).toBeInTheDocument() + }) + + expect(screen.getByText(/No essentials yet/)).toBeInTheDocument() + expect(screen.getByText(/Add one to define deliverables/)).toBeInTheDocument() + }) + + it('shows loading state for essentials', async () => { + mockGet.mockImplementation((url: string) => { + if (url.includes('/proposals/1') && !url.includes('/essentials')) { + return Promise.resolve({ data: mockProposal }) + } + if (url.includes('/essentials')) { + return new Promise(() => {}) // Never resolve + } + return Promise.reject(new Error(`Unhandled GET ${url}`)) + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/Loading/i)).toBeInTheDocument() + }) + }) + }) + + describe('Essential Create/Edit Forms', () => { + it('opens create essential modal', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('+ New Essential')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('+ New Essential')) + + expect(screen.getByRole('heading', { name: 'New Essential' })).toBeInTheDocument() + expect(screen.getByPlaceholderText('Essential title')).toBeInTheDocument() + expect(screen.getByText('Feature')).toBeInTheDocument() + expect(screen.getByText('Improvement')).toBeInTheDocument() + expect(screen.getByText('Refactor')).toBeInTheDocument() + }) + + it('creates new essential', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('+ New Essential')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('+ New Essential')) + + await userEvent.type(screen.getByPlaceholderText('Essential title'), 'New Test Essential') + fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: 'refactor' } }) + await userEvent.type(screen.getByPlaceholderText('Description (optional)'), 'Test description') + + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/projects/1/proposals/1/essentials', + expect.objectContaining({ + title: 'New Test Essential', + type: 'refactor', + description: 'Test description', + }) + ) + }) + }) + + it('opens edit essential modal with pre-filled data', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Feature Essential')).toBeInTheDocument() + }) + + const editButtons = screen.getAllByRole('button', { name: 'Edit' }) + await userEvent.click(editButtons[0]) + + expect(screen.getByRole('heading', { name: 'Edit Essential' })).toBeInTheDocument() + + const titleInput = screen.getByDisplayValue('Feature Essential') + expect(titleInput).toBeInTheDocument() + + const descriptionInput = screen.getByDisplayValue('A feature essential') + expect(descriptionInput).toBeInTheDocument() + }) + + it('updates essential', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Feature Essential')).toBeInTheDocument() + }) + + const editButtons = screen.getAllByRole('button', { name: 'Edit' }) + await userEvent.click(editButtons[0]) + + const titleInput = screen.getByDisplayValue('Feature Essential') + await userEvent.clear(titleInput) + await userEvent.type(titleInput, 'Updated Essential Title') + + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(mockPatch).toHaveBeenCalledWith( + '/projects/1/proposals/1/essentials/1', + expect.objectContaining({ + title: 'Updated Essential Title', + type: 'feature', + description: 'A feature essential', + }) + ) + }) + }) + + it('deletes essential after confirmation', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Feature Essential')).toBeInTheDocument() + }) + + const deleteButtons = screen.getAllByRole('button', { name: 'Delete' }) + await userEvent.click(deleteButtons[0]) + + await waitFor(() => { + expect(window.confirm).toHaveBeenCalledWith('Are you sure you want to delete this Essential?') + }) + + await waitFor(() => { + expect(mockDelete).toHaveBeenCalledWith('/projects/1/proposals/1/essentials/1') + }) + }) + + it('disables save button when title is empty', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('+ New Essential')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('+ New Essential')) + + const saveButton = screen.getByRole('button', { name: 'Save' }) + expect(saveButton).toBeDisabled() + + await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Some Title') + expect(saveButton).toBeEnabled() + + await userEvent.clear(screen.getByPlaceholderText('Essential title')) + expect(saveButton).toBeDisabled() + }) + }) + + describe('Accept with Milestone Selection', () => { + it('opens accept modal with milestone selector', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('Accept')) + + expect(screen.getByRole('heading', { name: 'Accept Proposal' })).toBeInTheDocument() + expect(screen.getByText(/Select an.*open.*milestone/i)).toBeInTheDocument() + expect(screen.getByRole('combobox')).toBeInTheDocument() + }) + + it('disables confirm button without milestone selection', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('Accept')) + + const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' }) + expect(confirmButton).toBeDisabled() + }) + + it('enables confirm button after milestone selection', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('Accept')) + + const select = screen.getByRole('combobox') + fireEvent.change(select, { target: { value: '1' } }) + + const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' }) + expect(confirmButton).toBeEnabled() + }) + + it('calls accept API with selected milestone', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('Accept')) + + const select = screen.getByRole('combobox') + fireEvent.change(select, { target: { value: '1' } }) + + await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' })) + + await waitFor(() => { + expect(mockPost).toHaveBeenCalledWith( + '/projects/1/proposals/1/accept', + { milestone_id: 1 } + ) + }) + }) + + it('shows warning when no open milestones available', async () => { + setupApi({ milestones: [] }) + render() + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('Accept')) + + expect(screen.getByText('No open milestones available.')).toBeInTheDocument() + }) + + it('displays generated tasks after accept', async () => { + const acceptedProposal: Proposal = { + ...mockProposal, + status: 'accepted', + generated_tasks: [ + { task_id: 1, task_code: 'TASK-001', title: 'Story Task 1', task_type: 'story', task_subtype: 'feature' }, + { task_id: 2, task_code: 'TASK-002', title: 'Story Task 2', task_type: 'story', task_subtype: 'improvement' }, + ], + } + setupApi({ proposal: acceptedProposal }) + + render() + + await waitFor(() => { + expect(screen.getByText('Generated Tasks')).toBeInTheDocument() + }) + + expect(screen.getByText('Story Task 1')).toBeInTheDocument() + expect(screen.getByText('Story Task 2')).toBeInTheDocument() + expect(screen.getByText('story/feature')).toBeInTheDocument() + expect(screen.getByText('story/improvement')).toBeInTheDocument() + }) + }) + + describe('Story Creation Restriction UI', () => { + it('hides essential controls for accepted proposal', async () => { + const acceptedProposal: Proposal = { + ...mockProposal, + status: 'accepted', + essentials: mockEssentials, + } + setupApi({ proposal: acceptedProposal }) + + render() + + await waitFor(() => { + expect(screen.getByText('accepted')).toBeInTheDocument() + }) + + expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument() + }) + + it('hides edit/delete buttons for essentials when proposal is accepted', async () => { + const acceptedProposal: Proposal = { + ...mockProposal, + status: 'accepted', + essentials: mockEssentials, + } + setupApi({ proposal: acceptedProposal }) + + render() + + await waitFor(() => { + expect(screen.getByText('Feature Essential')).toBeInTheDocument() + }) + + // Edit and Delete buttons should not be visible + const editButtons = screen.queryAllByRole('button', { name: 'Edit' }) + const deleteButtons = screen.queryAllByRole('button', { name: 'Delete' }) + + // Only the main proposal Edit button might exist, but not essential edit buttons + expect(deleteButtons.length).toBe(0) + }) + + it('hides edit/delete buttons for rejected proposal', async () => { + const rejectedProposal: Proposal = { + ...mockProposal, + status: 'rejected', + essentials: mockEssentials, + } + setupApi({ proposal: rejectedProposal }) + + render() + + await waitFor(() => { + expect(screen.getByText('rejected')).toBeInTheDocument() + }) + + expect(screen.queryByText('+ New Essential')).not.toBeInTheDocument() + }) + }) + + describe('Error Handling', () => { + it('displays error message on essential create failure', async () => { + mockPost.mockRejectedValue({ response: { data: { detail: 'Failed to create essential' } } }) + + render() + + await waitFor(() => { + expect(screen.getByText('+ New Essential')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('+ New Essential')) + await userEvent.type(screen.getByPlaceholderText('Essential title'), 'Test') + await userEvent.click(screen.getByRole('button', { name: 'Save' })) + + await waitFor(() => { + expect(screen.getByText('Failed to create essential')).toBeInTheDocument() + }) + }) + + it('displays error message on accept failure', async () => { + mockPost.mockRejectedValue({ response: { data: { detail: 'Accept failed: milestone required' } } }) + + render() + + await waitFor(() => { + expect(screen.getByText('Accept')).toBeInTheDocument() + }) + + await userEvent.click(screen.getByText('Accept')) + fireEvent.change(screen.getByRole('combobox'), { target: { value: '1' } }) + await userEvent.click(screen.getByRole('button', { name: 'Confirm Accept' })) + + await waitFor(() => { + expect(screen.getByText('Accept failed: milestone required')).toBeInTheDocument() + }) + }) + }) +})