|
| 1 | +import { render, screen, waitFor } from '@testing-library/react'; |
| 2 | +import userEvent from '@testing-library/user-event'; |
| 3 | +import { vi, describe, it, expect, beforeEach } from 'vitest'; |
| 4 | +import ChatHeader from './ChatHeader'; |
| 5 | +import { pipelinesService } from '@/services/pipelines/pipelinesService'; |
| 6 | +import chatService from '@/services/chat/chatService'; |
| 7 | +import { toast } from 'sonner'; |
| 8 | + |
| 9 | +vi.mock('@/services/pipelines/pipelinesService', () => ({ |
| 10 | + pipelinesService: { |
| 11 | + getPipelines: vi.fn(), |
| 12 | + getPipelinesByConversation: vi.fn(), |
| 13 | + addItemToPipeline: vi.fn(), |
| 14 | + moveItem: vi.fn(), |
| 15 | + removeItemFromPipeline: vi.fn(), |
| 16 | + }, |
| 17 | +})); |
| 18 | + |
| 19 | +vi.mock('@/services/chat/chatService', () => ({ |
| 20 | + default: { |
| 21 | + getConversation: vi.fn(), |
| 22 | + }, |
| 23 | +})); |
| 24 | + |
| 25 | +vi.mock('sonner', () => ({ |
| 26 | + toast: { success: vi.fn(), error: vi.fn() }, |
| 27 | +})); |
| 28 | + |
| 29 | +vi.mock('@/hooks/useLanguage', () => ({ |
| 30 | + useLanguage: () => ({ t: (key: string) => key }), |
| 31 | +})); |
| 32 | + |
| 33 | +vi.mock('@/utils/chat/conversationStatus', () => ({ |
| 34 | + getStatusLabel: (s: string) => s, |
| 35 | + isPendingStatus: () => false, |
| 36 | +})); |
| 37 | + |
| 38 | +vi.mock('@/utils/channelUtils', () => ({ |
| 39 | + isPhoneBearingChannel: () => false, |
| 40 | +})); |
| 41 | + |
| 42 | +vi.mock('@/utils/contact/formatContactPhone', () => ({ |
| 43 | + formatContactPhone: (p: string) => p, |
| 44 | +})); |
| 45 | + |
| 46 | +vi.mock('@/components/chat/contact/ContactAvatar', () => ({ |
| 47 | + default: () => <div data-testid="contact-avatar" />, |
| 48 | +})); |
| 49 | + |
| 50 | +const makePipeline = ( |
| 51 | + id: string, |
| 52 | + stages: { id: string; name: string }[], |
| 53 | + items: { id: string; item_id: string; stage_id: string }[] = [], |
| 54 | +) => ({ |
| 55 | + id, |
| 56 | + name: `Pipeline ${id}`, |
| 57 | + pipeline_type: 'custom' as const, |
| 58 | + visibility: 'public' as const, |
| 59 | + is_active: true, |
| 60 | + stages: stages.map(s => ({ ...s, color: '#000', position: 0, created_at: '', updated_at: '' })), |
| 61 | + items, |
| 62 | + created_at: '', |
| 63 | + updated_at: '', |
| 64 | +}); |
| 65 | + |
| 66 | +const makeConversation = (id = '42') => |
| 67 | + ({ |
| 68 | + id, |
| 69 | + status: 'open' as const, |
| 70 | + inbox: { id: '1', name: 'WhatsApp', channel_type: 'Channel::Whatsapp' }, |
| 71 | + contact: { id: '1', name: 'Test Contact' }, |
| 72 | + custom_attributes: {}, |
| 73 | + }) as never; |
| 74 | + |
| 75 | +const defaultProps = { |
| 76 | + conversation: makeConversation(), |
| 77 | + onBackClick: vi.fn(), |
| 78 | + onCloseConversation: vi.fn(), |
| 79 | + onContactSidebarOpen: vi.fn(), |
| 80 | + onMarkAsRead: vi.fn(), |
| 81 | + onMarkAsUnread: vi.fn(), |
| 82 | + onMarkAsOpen: vi.fn(), |
| 83 | + onMarkAsResolved: vi.fn(), |
| 84 | + onPostpone: vi.fn(), |
| 85 | + onMarkAsSnoozed: vi.fn(), |
| 86 | + onSetPriority: vi.fn(), |
| 87 | + onPinConversation: vi.fn(), |
| 88 | + onUnpinConversation: vi.fn(), |
| 89 | + onArchiveConversation: vi.fn(), |
| 90 | + onUnarchiveConversation: vi.fn(), |
| 91 | + onAssignAgent: vi.fn(), |
| 92 | + onAssignTeam: vi.fn(), |
| 93 | + onAssignTag: vi.fn(), |
| 94 | + onDeleteConversation: vi.fn(), |
| 95 | + unreadCount: 0, |
| 96 | +}; |
| 97 | + |
| 98 | +beforeEach(() => { |
| 99 | + vi.clearAllMocks(); |
| 100 | + vi.mocked(chatService.getConversation).mockResolvedValue({ data: makeConversation() } as never); |
| 101 | +}); |
| 102 | + |
| 103 | +const openPipelineAndSelectStage = async ( |
| 104 | + user: ReturnType<typeof userEvent.setup>, |
| 105 | + pipelineName: string, |
| 106 | + stageName: string, |
| 107 | +) => { |
| 108 | + // Open main dropdown |
| 109 | + const menuTrigger = document.querySelector<HTMLElement>('[data-slot="dropdown-menu-trigger"]')!; |
| 110 | + await user.click(menuTrigger); |
| 111 | + |
| 112 | + // Find and focus the pipeline.addTo sub-trigger, then open it with ArrowRight |
| 113 | + const addToTrigger = await screen.findByText('pipeline.addTo'); |
| 114 | + const addToSubTrigger = (addToTrigger.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? addToTrigger) as HTMLElement; |
| 115 | + addToSubTrigger.focus(); |
| 116 | + await user.keyboard('{ArrowRight}'); |
| 117 | + |
| 118 | + // Find and focus the specific pipeline sub-trigger, then open with ArrowRight |
| 119 | + await waitFor(() => screen.getByText(pipelineName), { timeout: 2000 }); |
| 120 | + const pipelineEl = screen.getByText(pipelineName); |
| 121 | + const pipelineSubTrigger = (pipelineEl.closest('[data-slot="dropdown-menu-sub-trigger"]') ?? pipelineEl) as HTMLElement; |
| 122 | + pipelineSubTrigger.focus(); |
| 123 | + await user.keyboard('{ArrowRight}'); |
| 124 | + |
| 125 | + // Find and click the stage |
| 126 | + await waitFor(() => screen.getByText(stageName), { timeout: 2000 }); |
| 127 | + await user.click(screen.getByText(stageName)); |
| 128 | +}; |
| 129 | + |
| 130 | +describe('ChatHeader pipeline', () => { |
| 131 | + it('loads pipelines on mount and calls getPipelinesByConversation when menu opens', async () => { |
| 132 | + const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]); |
| 133 | + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); |
| 134 | + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([]); |
| 135 | + |
| 136 | + render(<ChatHeader {...defaultProps} />); |
| 137 | + |
| 138 | + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalledWith({ is_active: true })); |
| 139 | + |
| 140 | + const user = userEvent.setup(); |
| 141 | + const menuTrigger = document.querySelector<HTMLElement>('[data-slot="dropdown-menu-trigger"]')!; |
| 142 | + await user.click(menuTrigger); |
| 143 | + |
| 144 | + await waitFor(() => |
| 145 | + expect(pipelinesService.getPipelinesByConversation).toHaveBeenCalledWith('42'), |
| 146 | + ); |
| 147 | + }); |
| 148 | + |
| 149 | + it('adds conversation to pipeline using conversation.id not item.id (H1)', async () => { |
| 150 | + const pipeline = makePipeline('p1', [{ id: 'stage-1', name: 'Lead' }]); |
| 151 | + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); |
| 152 | + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([]); |
| 153 | + vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never); |
| 154 | + |
| 155 | + render(<ChatHeader {...defaultProps} />); |
| 156 | + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); |
| 157 | + |
| 158 | + const user = userEvent.setup(); |
| 159 | + await openPipelineAndSelectStage(user, 'Pipeline p1', 'Lead'); |
| 160 | + |
| 161 | + await waitFor(() => { |
| 162 | + expect(pipelinesService.addItemToPipeline).toHaveBeenCalledWith('p1', { |
| 163 | + item_id: '42', |
| 164 | + type: 'conversation', |
| 165 | + pipeline_stage_id: 'stage-1', |
| 166 | + }); |
| 167 | + }); |
| 168 | + }); |
| 169 | + |
| 170 | + it('calls moveItem with item.id when moving within same pipeline (M1)', async () => { |
| 171 | + const existingItem = { |
| 172 | + id: 'item-99', |
| 173 | + item_id: '42', |
| 174 | + stage_id: 'stage-1', |
| 175 | + pipeline_id: 'p1', |
| 176 | + type: 'conversation', |
| 177 | + is_lead: false, |
| 178 | + created_at: '', |
| 179 | + updated_at: '', |
| 180 | + }; |
| 181 | + const pipeline = makePipeline( |
| 182 | + 'p1', |
| 183 | + [ |
| 184 | + { id: 'stage-1', name: 'Lead' }, |
| 185 | + { id: 'stage-2', name: 'Qualified' }, |
| 186 | + ], |
| 187 | + [existingItem], |
| 188 | + ); |
| 189 | + |
| 190 | + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); |
| 191 | + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([pipeline]); |
| 192 | + vi.mocked(pipelinesService.moveItem).mockResolvedValue({ success: true, message: '' }); |
| 193 | + |
| 194 | + render(<ChatHeader {...defaultProps} />); |
| 195 | + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); |
| 196 | + |
| 197 | + const user = userEvent.setup(); |
| 198 | + await openPipelineAndSelectStage(user, 'Pipeline p1', 'Qualified'); |
| 199 | + |
| 200 | + await waitFor(() => { |
| 201 | + expect(pipelinesService.moveItem).toHaveBeenCalledWith({ |
| 202 | + pipeline_id: 'p1', |
| 203 | + item_id: 'item-99', |
| 204 | + from_stage_id: 'stage-1', |
| 205 | + to_stage_id: 'stage-2', |
| 206 | + }); |
| 207 | + expect(pipelinesService.addItemToPipeline).not.toHaveBeenCalled(); |
| 208 | + }); |
| 209 | + }); |
| 210 | + |
| 211 | + it('removes from old pipeline before adding to new pipeline (C1)', async () => { |
| 212 | + const existingItem = { |
| 213 | + id: 'item-old', |
| 214 | + item_id: '42', |
| 215 | + stage_id: 'stage-A', |
| 216 | + pipeline_id: 'p-old', |
| 217 | + type: 'conversation', |
| 218 | + is_lead: false, |
| 219 | + created_at: '', |
| 220 | + updated_at: '', |
| 221 | + }; |
| 222 | + const oldPipeline = makePipeline('p-old', [{ id: 'stage-A', name: 'StageOld' }], [existingItem]); |
| 223 | + const newPipeline = makePipeline('p-new', [{ id: 'stage-B', name: 'StageNew' }]); |
| 224 | + |
| 225 | + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [oldPipeline, newPipeline] } as never); |
| 226 | + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([oldPipeline]); |
| 227 | + vi.mocked(pipelinesService.removeItemFromPipeline).mockResolvedValue({ success: true, message: '' }); |
| 228 | + vi.mocked(pipelinesService.addItemToPipeline).mockResolvedValue({} as never); |
| 229 | + |
| 230 | + render(<ChatHeader {...defaultProps} />); |
| 231 | + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); |
| 232 | + |
| 233 | + const user = userEvent.setup(); |
| 234 | + await openPipelineAndSelectStage(user, 'Pipeline p-new', 'StageNew'); |
| 235 | + |
| 236 | + await waitFor(() => { |
| 237 | + expect(pipelinesService.removeItemFromPipeline).toHaveBeenCalledWith('p-old', 'item-old'); |
| 238 | + expect(pipelinesService.addItemToPipeline).toHaveBeenCalledWith('p-new', { |
| 239 | + item_id: '42', |
| 240 | + type: 'conversation', |
| 241 | + pipeline_stage_id: 'stage-B', |
| 242 | + }); |
| 243 | + }); |
| 244 | + }); |
| 245 | + |
| 246 | + it('shows pipeline.moveError toast when move fails (M2 - distinct from addError)', async () => { |
| 247 | + const existingItem = { |
| 248 | + id: 'item-99', |
| 249 | + item_id: '42', |
| 250 | + stage_id: 'stage-1', |
| 251 | + pipeline_id: 'p1', |
| 252 | + type: 'conversation', |
| 253 | + is_lead: false, |
| 254 | + created_at: '', |
| 255 | + updated_at: '', |
| 256 | + }; |
| 257 | + const pipeline = makePipeline( |
| 258 | + 'p1', |
| 259 | + [ |
| 260 | + { id: 'stage-1', name: 'Lead' }, |
| 261 | + { id: 'stage-2', name: 'Qualified' }, |
| 262 | + ], |
| 263 | + [existingItem], |
| 264 | + ); |
| 265 | + |
| 266 | + vi.mocked(pipelinesService.getPipelines).mockResolvedValue({ data: [pipeline] } as never); |
| 267 | + vi.mocked(pipelinesService.getPipelinesByConversation).mockResolvedValue([pipeline]); |
| 268 | + vi.mocked(pipelinesService.moveItem).mockRejectedValue(new Error('Server error')); |
| 269 | + |
| 270 | + render(<ChatHeader {...defaultProps} />); |
| 271 | + await waitFor(() => expect(pipelinesService.getPipelines).toHaveBeenCalled()); |
| 272 | + |
| 273 | + const user = userEvent.setup(); |
| 274 | + await openPipelineAndSelectStage(user, 'Pipeline p1', 'Qualified'); |
| 275 | + |
| 276 | + await waitFor(() => { |
| 277 | + expect(toast.error).toHaveBeenCalledWith('pipeline.moveError'); |
| 278 | + expect(toast.error).not.toHaveBeenCalledWith('pipeline.addError'); |
| 279 | + }); |
| 280 | + }); |
| 281 | +}); |
0 commit comments