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() const acceptButton = await screen.findByRole('button', { name: /accept/i }) await userEvent.click(acceptButton) expect(screen.getByRole('heading', { name: 'Accept Proposal' })).toBeInTheDocument() expect(screen.getByText(/milestone to generate story tasks/i)).toBeInTheDocument() expect(screen.getByRole('combobox')).toBeInTheDocument() }) it('disables confirm button without milestone selection', async () => { render() const acceptButton = await screen.findByRole('button', { name: /accept/i }) await userEvent.click(acceptButton) const confirmButton = screen.getByRole('button', { name: 'Confirm Accept' }) expect(confirmButton).toBeDisabled() }) it('enables confirm button after milestone selection', async () => { render() const acceptButton = await screen.findByRole('button', { name: /accept/i }) await userEvent.click(acceptButton) 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() const acceptButton = await screen.findByRole('button', { name: /accept/i }) await userEvent.click(acceptButton) 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() const acceptButton = await screen.findByRole('button', { name: /accept/i }) await userEvent.click(acceptButton) 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 screen.findByText('Feature Essential') 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() }) const deleteButtons = screen.queryAllByRole('button', { name: 'Delete' }) 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 screen.findByRole('button', { name: /reopen/i }) 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() const acceptButton = await screen.findByRole('button', { name: /accept/i }) await userEvent.click(acceptButton) 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() }) }) }) })