diff --git a/src/hooks/chat/__tests__/useConversationHandlers.spec.ts b/src/hooks/chat/__tests__/useConversationHandlers.spec.ts new file mode 100644 index 00000000..cffb5c08 --- /dev/null +++ b/src/hooks/chat/__tests__/useConversationHandlers.spec.ts @@ -0,0 +1,122 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '@testing-library/react'; +import { useConversationHandlers } from '../useConversationHandlers'; + +const mockConversations = vi.hoisted(() => ({ + updateConversationStatus: vi.fn(), + updateConversationPriority: vi.fn(), + markAsRead: vi.fn(), + markAsUnread: vi.fn(), + pinConversation: vi.fn(), + unpinConversation: vi.fn(), + archiveConversation: vi.fn(), + unarchiveConversation: vi.fn(), +})); + +vi.mock('@/contexts/chat/ChatContext', () => ({ + useChatContext: () => ({ conversations: mockConversations }), +})); + +vi.mock('@/contexts/PermissionsContext', () => ({ + usePermissions: () => ({ can: vi.fn().mockReturnValue(true) }), +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +const baseConversation = { id: 'conv-1', uuid: 'uuid-conv-1', status: 'open' } as any; + +describe('useConversationHandlers', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('handleMarkAsResolved', () => { + it('calls updateConversationStatus with resolved status', async () => { + mockConversations.updateConversationStatus.mockResolvedValueOnce({}); + + const { result } = renderHook(() => useConversationHandlers()); + await result.current.handleMarkAsResolved(baseConversation); + + expect(mockConversations.updateConversationStatus).toHaveBeenCalledWith( + 'conv-1', + 'resolved', + undefined, + ); + }); + + it('re-throws when updateConversationStatus fails', async () => { + const error = new Error('API error'); + mockConversations.updateConversationStatus.mockRejectedValueOnce(error); + + const { result } = renderHook(() => useConversationHandlers()); + + await expect( + result.current.handleMarkAsResolved(baseConversation), + ).rejects.toThrow('API error'); + }); + + it('passes onReload callback to updateConversationStatus', async () => { + mockConversations.updateConversationStatus.mockResolvedValueOnce({}); + const onReload = vi.fn().mockResolvedValue(undefined); + + const { result } = renderHook(() => useConversationHandlers()); + await result.current.handleMarkAsResolved(baseConversation, onReload); + + expect(mockConversations.updateConversationStatus).toHaveBeenCalledWith( + 'conv-1', + 'resolved', + onReload, + ); + }); + + it('does not swallow error β€” caller can gate navigation on success', async () => { + mockConversations.updateConversationStatus.mockRejectedValueOnce(new Error('fail')); + + const { result } = renderHook(() => useConversationHandlers()); + + let threw = false; + try { + await result.current.handleMarkAsResolved(baseConversation); + } catch { + threw = true; + } + + expect(threw).toBe(true); + }); + }); + + describe('handleMarkAsResolved β€” selected vs non-selected (navigation gating)', () => { + it('resolves without throwing when conversation is NOT selected (non-selected path)', async () => { + mockConversations.updateConversationStatus.mockResolvedValueOnce({}); + + const { result } = renderHook(() => useConversationHandlers()); + + await expect( + result.current.handleMarkAsResolved(baseConversation), + ).resolves.not.toThrow(); + }); + + it('propagates error so Chat.tsx can skip URL navigation on failure', async () => { + mockConversations.updateConversationStatus.mockRejectedValueOnce(new Error('backend fail')); + + const { result } = renderHook(() => useConversationHandlers()); + + const navigateMock = vi.fn(); + + let navigationCalled = false; + try { + await result.current.handleMarkAsResolved(baseConversation); + navigateMock('/conversations'); + navigationCalled = true; + } catch { + // expected β€” navigation must NOT run + } + + expect(navigateMock).not.toHaveBeenCalled(); + expect(navigationCalled).toBe(false); + }); + }); +}); diff --git a/src/hooks/chat/useConversationHandlers.ts b/src/hooks/chat/useConversationHandlers.ts index 3d2d7c2d..b0c3418a 100644 --- a/src/hooks/chat/useConversationHandlers.ts +++ b/src/hooks/chat/useConversationHandlers.ts @@ -37,6 +37,7 @@ export const useConversationHandlers = () => { await conversations.updateConversationStatus(conversation.id, 'resolved', onReload); } catch (error) { console.error('❌ Error marking as resolved:', error); + throw error; } }, [conversations], diff --git a/src/pages/Customer/Chat/Chat.tsx b/src/pages/Customer/Chat/Chat.tsx index 813cbed3..ed6a5358 100644 --- a/src/pages/Customer/Chat/Chat.tsx +++ b/src/pages/Customer/Chat/Chat.tsx @@ -377,11 +377,31 @@ const Chat = () => { [conversationHandlers], ); + const clearSelectionAndGoToList = useCallback(async () => { + isManualNavigationRef.current = true; + await conversations.selectConversation(null); + setMobileView('list'); + setIsContactSidebarOpen(false); + navigate('/conversations', { replace: true }); + setTimeout(() => { + isManualNavigationRef.current = false; + }, 100); + }, [conversations, navigate]); + const handleMarkAsResolved = useCallback( async (conversation: Conversation) => { - await conversationHandlers.handleMarkAsResolved(conversation, reloadCurrentFilters); + const resolvedId = String(conversation.uuid || conversation.id); + try { + await conversationHandlers.handleMarkAsResolved(conversation, reloadCurrentFilters); + const liveSelected = conversations.state.selectedConversationId; + if (liveSelected != null && String(liveSelected) === resolvedId) { + await clearSelectionAndGoToList(); + } + } catch { + // error already toasted by updateConversationStatus + } }, - [conversationHandlers, reloadCurrentFilters], + [conversationHandlers, reloadCurrentFilters, conversations, clearSelectionAndGoToList], ); const handlePostpone = useCallback( @@ -496,13 +516,7 @@ const Chat = () => { await conversations.deleteConversation(String(conversationToDelete.id)); setShowDeleteDialog(false); setConversationToDelete(null); - setMobileView('list'); - setIsContactSidebarOpen(false); - isManualNavigationRef.current = true; - navigate('/conversations', { replace: true }); - setTimeout(() => { - isManualNavigationRef.current = false; - }, 100); + await clearSelectionAndGoToList(); } catch (error) { console.error('Error deleting conversation:', error); // Error is already handled in the context with toast @@ -667,21 +681,9 @@ const Chat = () => { }, 100); }; - const handleCloseConversation = () => { - // πŸ”’ MARCAR NAVEGAÇÃO MANUAL para evitar URL sync - isManualNavigationRef.current = true; - - conversations.selectConversation(null); - setMobileView('list'); - setIsContactSidebarOpen(false); // Fechar sidebar se estiver aberto - // Voltar para a lista geral de conversas - navigate('/conversations', { replace: true }); - - // πŸ”’ RESET flag apΓ³s navegaΓ§Γ£o - setTimeout(() => { - isManualNavigationRef.current = false; - }, 100); - }; + const handleCloseConversation = useCallback(async () => { + await clearSelectionAndGoToList(); + }, [clearSelectionAndGoToList]); return ( diff --git a/src/pages/Customer/Chat/__tests__/Chat.spec.tsx b/src/pages/Customer/Chat/__tests__/Chat.spec.tsx new file mode 100644 index 00000000..31abb6d8 --- /dev/null +++ b/src/pages/Customer/Chat/__tests__/Chat.spec.tsx @@ -0,0 +1,248 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, act } from '@testing-library/react'; +import Chat from '../Chat'; +import type { Conversation } from '@/types/chat/api'; + +// ─── Mutable test state ─────────────────────────────────────────────────────── +const mockState = vi.hoisted(() => ({ + selectedConversationId: null as string | null, +})); + +const mockHookResolve = vi.hoisted(() => ({ + fn: vi.fn<[Conversation, (() => Promise)?], Promise>(), +})); + +const capturedProps = vi.hoisted(() => ({ + onMarkAsResolved: null as ((conv: Conversation) => Promise) | null, +})); + +// ─── react-router-dom ───────────────────────────────────────────────────────── +const mockNavigate = vi.fn(); + +vi.mock('react-router-dom', () => ({ + useParams: () => ({ conversationId: undefined }), + useNavigate: () => mockNavigate, +})); + +// ─── i18n ───────────────────────────────────────────────────────────────────── +vi.mock('@/hooks/useLanguage', () => ({ + useLanguage: () => ({ t: (k: string) => k }), +})); + +// ─── Permissions ────────────────────────────────────────────────────────────── +vi.mock('@/contexts/PermissionsContext', () => ({ + usePermissions: () => ({ can: vi.fn().mockReturnValue(true), isReady: true }), +})); + +// ─── Chat context ───────────────────────────────────────────────────────────── +const mockSelectConversation = vi.fn().mockResolvedValue(undefined); + +vi.mock('@/contexts/chat/ChatContext', () => ({ + useChatContext: () => ({ + conversations: { + state: { + selectedConversationId: mockState.selectedConversationId, + conversationsLoading: false, + conversationsError: null, + conversations: [], + }, + selectConversation: mockSelectConversation, + getConversation: vi.fn().mockReturnValue(null), + getUnreadCount: vi.fn().mockReturnValue(0), + loadConversations: vi.fn().mockResolvedValue(undefined), + }, + messages: { + loadMessages: vi.fn(), + loadMoreMessages: vi.fn(), + state: { messages: [], messagesLoading: false }, + }, + selectedConversation: null, + selectedMessages: [], + }), +})); + +// ─── Conversation handlers hook ─────────────────────────────────────────────── +vi.mock('@/hooks/chat/useConversationHandlers', () => ({ + useConversationHandlers: () => ({ + handleMarkAsResolved: mockHookResolve.fn, + handleMarkAsRead: vi.fn().mockResolvedValue(undefined), + handleMarkAsUnread: vi.fn().mockResolvedValue(undefined), + handlePostpone: vi.fn().mockResolvedValue(undefined), + handleMarkAsOpen: vi.fn().mockResolvedValue(undefined), + handleMarkAsSnoozed: vi.fn().mockResolvedValue(undefined), + handleSetPriority: vi.fn().mockResolvedValue(undefined), + handlePinConversation: vi.fn().mockResolvedValue(undefined), + handleUnpinConversation: vi.fn().mockResolvedValue(undefined), + handleArchiveConversation: vi.fn().mockResolvedValue(undefined), + handleUnarchiveConversation: vi.fn().mockResolvedValue(undefined), + handleDeleteConversation: vi.fn(), + }), +})); + +// ─── Assignment handlers hook ───────────────────────────────────────────────── +vi.mock('@/hooks/chat/useAssignmentHandlers', () => ({ + useAssignmentHandlers: () => ({ + handleAssignAgent: vi.fn(), + handleAssignTeam: vi.fn(), + handleAssignTag: vi.fn(), + handleAssignmentConfirm: vi.fn(), + }), +})); + +// ─── Filter handlers hook ───────────────────────────────────────────────────── +vi.mock('@/hooks/chat/useFilterHandlers', () => ({ + useFilterHandlers: () => ({ + handleApplyFilters: vi.fn().mockResolvedValue(undefined), + handleClearFilters: vi.fn().mockResolvedValue(undefined), + reloadCurrentFilters: vi.fn().mockResolvedValue(undefined), + }), +})); + +// ─── Storage utils ──────────────────────────────────────────────────────────── +vi.mock('@/utils/storage/filtersStorage', () => ({ + loadConversationFilters: vi.fn().mockReturnValue([]), + getDefaultFilter: vi.fn().mockReturnValue([]), +})); + +// ─── App store ──────────────────────────────────────────────────────────────── +vi.mock('@/store/appDataStore', () => ({ + useAppDataStore: (selector: (s: any) => any) => selector({ fetchLabels: vi.fn() }), +})); + +// ─── Services ───────────────────────────────────────────────────────────────── +vi.mock('@/services/contacts/labelsService', () => ({ + labelsService: { createLabel: vi.fn() }, +})); + +// ─── Sub-components β€” capture onMarkAsResolved from ChatSidebar ─────────────── +vi.mock('@/components/chat/chat-sidebar/ChatSidebar', () => ({ + default: (props: any) => { + capturedProps.onMarkAsResolved = props.onMarkAsResolved; + return
; + }, +})); + +vi.mock('@/components/chat/chat-header/ChatHeader', () => ({ + default: () =>
, +})); + +vi.mock('@/components/chat/chat-area/ChatArea', () => ({ + default: () =>
, +})); + +vi.mock('@/components/chat/chat-tabs/ChatTabs', () => ({ + default: () =>
, +})); + +vi.mock('../../../components/ErrorBoundary', () => ({ + default: ({ children }: any) => <>{children}, +})); + +vi.mock('@/tours', () => ({ + ChatTour: () => null, +})); + +vi.mock('@evoapi/design-system/alert-dialog', () => ({ + AlertDialog: ({ children }: any) => <>{children}, + AlertDialogContent: ({ children }: any) => <>{children}, + AlertDialogHeader: ({ children }: any) => <>{children}, + AlertDialogTitle: ({ children }: any) => <>{children}, + AlertDialogDescription: ({ children }: any) => <>{children}, + AlertDialogFooter: ({ children }: any) => <>{children}, + AlertDialogAction: ({ children, onClick }: any) => ( + + ), + AlertDialogCancel: ({ children }: any) => , +})); + +vi.mock('sonner', () => ({ + toast: { error: vi.fn(), success: vi.fn() }, +})); + +// ─── Fixtures ───────────────────────────────────────────────────────────────── +const selectedConv: Conversation = { + id: 'conv-selected', + uuid: 'uuid-selected', + status: 'open', +} as any; + +const otherConv: Conversation = { + id: 'conv-other', + uuid: 'uuid-other', + status: 'open', +} as any; + +// ─── Tests ──────────────────────────────────────────────────────────────────── +describe('Chat β€” handleMarkAsResolved navigation behavior', () => { + beforeEach(() => { + vi.clearAllMocks(); + capturedProps.onMarkAsResolved = null; + mockState.selectedConversationId = null; + mockHookResolve.fn.mockResolvedValue(undefined); + mockSelectConversation.mockResolvedValue(undefined); + }); + + it('clears URL and selection when resolving the currently-selected conversation', async () => { + mockState.selectedConversationId = 'uuid-selected'; + + const { unmount } = render(); + + await act(async () => { + await capturedProps.onMarkAsResolved!(selectedConv); + }); + + expect(mockNavigate).toHaveBeenCalledWith('/conversations', { replace: true }); + expect(mockSelectConversation).toHaveBeenCalledWith(null); + + unmount(); + }); + + it('does not navigate when resolving a non-selected conversation', async () => { + mockState.selectedConversationId = 'uuid-selected'; + + const { unmount } = render(); + + await act(async () => { + await capturedProps.onMarkAsResolved!(otherConv); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSelectConversation).not.toHaveBeenCalled(); + + unmount(); + }); + + it('does not navigate when no conversation is selected', async () => { + mockState.selectedConversationId = null; + + const { unmount } = render(); + + await act(async () => { + await capturedProps.onMarkAsResolved!(selectedConv); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + + unmount(); + }); + + it('does not navigate when resolve fails on the selected conversation', async () => { + mockState.selectedConversationId = 'uuid-selected'; + mockHookResolve.fn.mockRejectedValueOnce(new Error('API error')); + + const { unmount } = render(); + + await act(async () => { + await capturedProps.onMarkAsResolved!(selectedConv); + }); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSelectConversation).not.toHaveBeenCalled(); + + unmount(); + }); +});