diff --git a/src/features/inbox/components/chat/__tests__/useChatFilters.identity.test.ts b/src/features/inbox/components/chat/__tests__/useChatFilters.identity.test.ts new file mode 100644 index 0000000000..64222419c2 --- /dev/null +++ b/src/features/inbox/components/chat/__tests__/useChatFilters.identity.test.ts @@ -0,0 +1,152 @@ +/** + * Tests for useChatFilters — referential identity (memoization). + * + * Guard against regressions where derived arrays lose referential stability, + * causing unnecessary re-renders in virtualised message lists. Each useMemo + * derivation must return the same array reference when its inputs haven't changed. + */ + +import { describe, it, expect } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import React from 'react'; +import { useChatFilters } from '../hooks/useChatFilters'; +import type { Message } from '@/types/chat'; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function makeMessage(overrides: Partial = {}): Message { + return { + id: 'msg-1', + content: 'hello', + status: 'sent', + timestamp: new Date().toISOString(), + type: 'text', + fromMe: true, + conversationId: 'conv-1', + ...overrides, + } as Message; +} + +function makeWrapper() { + const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => + React.createElement(MemoryRouter, null, children); + return Wrapper; +} + +const SENT = makeMessage({ id: 'ok-1', status: 'sent' }); +const FAILED = makeMessage({ id: 'fail-1', status: 'failed' }); +const FAILED_AUTH = makeMessage({ id: 'fail-2', status: 'failed_auth' }); +const FAILED_RETRIES = makeMessage({ id: 'fail-3', status: 'failed_retries' }); + +const ALL_MESSAGES = [SENT, FAILED, FAILED_AUTH, FAILED_RETRIES]; + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('useChatFilters — referential identity', () => { + it('failedMessages reference is stable across renders with same input', () => { + const { result, rerender } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + const ref1 = result.current.failedMessages; + rerender(); + const ref2 = result.current.failedMessages; + + expect(ref1).toBe(ref2); + }); + + it('visibleMessages reference is stable across renders with same input', () => { + const { result, rerender } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + const ref1 = result.current.visibleMessages; + rerender(); + const ref2 = result.current.visibleMessages; + + expect(ref1).toBe(ref2); + }); + + it('categoryCounts reference is stable across renders with same input', () => { + const { result, rerender } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + const ref1 = result.current.categoryCounts; + rerender(); + const ref2 = result.current.categoryCounts; + + expect(ref1).toBe(ref2); + }); + + it('categoryFilteredMessages reference is stable across renders with same input', () => { + const { result, rerender } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + const ref1 = result.current.categoryFilteredMessages; + rerender(); + const ref2 = result.current.categoryFilteredMessages; + + expect(ref1).toBe(ref2); + }); +}); + +describe('useChatFilters — correctness', () => { + it('failedMessages contains only failed-status messages', () => { + const { result } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + expect(result.current.failedMessages).toHaveLength(3); + expect(result.current.failedMessages.map((m) => m.id)).toEqual(['fail-1', 'fail-2', 'fail-3']); + }); + + it('categoryCounts matches actual failure distribution', () => { + const { result } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + expect(result.current.categoryCounts).toEqual({ + failed: 1, + failed_auth: 1, + failed_retries: 1, + }); + }); + + it('visibleMessages equals all messages when failuresOnly is false', () => { + const { result } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + expect(result.current.failuresOnly).toBe(false); + expect(result.current.visibleMessages).toHaveLength(ALL_MESSAGES.length); + }); + + it('setFailuresOnly callback is stable across renders', () => { + const { result, rerender } = renderHook(() => useChatFilters(ALL_MESSAGES), { + wrapper: makeWrapper(), + }); + + const cb1 = result.current.setFailuresOnly; + rerender(); + expect(result.current.setFailuresOnly).toBe(cb1); + }); + + it('failedMessages updates when new failed message added', () => { + let messages = [SENT, FAILED]; + const { result, rerender } = renderHook(() => useChatFilters(messages), { + wrapper: makeWrapper(), + }); + + expect(result.current.failedMessages).toHaveLength(1); + + act(() => { + messages = [...messages, FAILED_AUTH]; + }); + rerender(); + + expect(result.current.failedMessages).toHaveLength(2); + }); +}); diff --git a/src/features/inbox/components/chat/__tests__/useChatPanelHandlers.edit.test.ts b/src/features/inbox/components/chat/__tests__/useChatPanelHandlers.edit.test.ts new file mode 100644 index 0000000000..d038e0b06f --- /dev/null +++ b/src/features/inbox/components/chat/__tests__/useChatPanelHandlers.edit.test.ts @@ -0,0 +1,219 @@ +/** + * Regression tests for the edit-message flow in useChatPanelHandlers. + * + * Critical invariant: editMessageApi MUST be awaited before any success toast. + * If the API call fails, no success toast should appear (no false-success). + * If preconditions are missing (no JID, no externalId, no instance), an error + * toast must appear instead of a success toast. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useChatPanelHandlers } from '../useChatPanelHandlers'; +import type { Message } from '@/types/chat'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockToast = vi.fn(); +vi.mock('@/hooks/use-toast', () => ({ toast: (p: unknown) => mockToast(p) })); + +const mockDbUpdate = vi.fn(); +const mockDbEq = vi.fn(() => ({ + eq: vi.fn(() => ({ + select: vi.fn(() => Promise.resolve({ data: [{ id: 'msg-1' }], error: null })), + })), +})); +const mockDbFrom = vi.fn(() => ({ update: mockDbUpdate })); +mockDbUpdate.mockReturnValue({ + eq: vi.fn(() => ({ + select: vi.fn(() => Promise.resolve({ data: [{ id: 'msg-1' }], error: null })), + })), +}); + +vi.mock('@/integrations/datasource/db', () => ({ + dbFrom: (...args: unknown[]) => mockDbFrom(...args), +})); +vi.mock('@/features/auth', () => ({ useAuth: () => ({ user: { id: 'user-1' } }) })); +vi.mock('@/lib/logger', () => ({ + getLogger: () => ({ warn: vi.fn(), error: vi.fn(), info: vi.fn() }), +})); +vi.mock('@/lib/undoToast', () => ({ undoToast: vi.fn() })); +vi.mock('../../hooks/useWhisperMessagesMutation', () => ({ insertWhisperMessage: vi.fn() })); +vi.mock('../useInputHandlers', async () => { + const { useState } = await import('react'); + return { + useInputHandlers: () => { + const [inputValue, setInputValue] = useState(''); + return { + inputValue, + setInputValue, + handleInputChange: vi.fn(), + applyTemplate: vi.fn(), + clearInput: vi.fn(), + }; + }, + }; +}); +vi.mock('../useProductHandlers', () => ({ + useProductHandlers: () => ({ handleSendProduct: vi.fn() }), +})); +vi.mock('../useAudioVoiceChange', () => ({ + useAudioVoiceChange: () => ({ handleAudioVoiceChange: vi.fn() }), +})); +vi.mock('../useMessageReactionHandlers', () => ({ + useMessageReactionHandlers: () => ({ handleReaction: vi.fn() }), +})); + +// ── Test helpers ────────────────────────────────────────────────────────────── + +const EDIT_JID = '5511999887766@s.whatsapp.net'; +const EDIT_MSG: Message = { + id: 'msg-1', + external_id: 'ext-abc123', + content: 'old text', + status: 'sent', + timestamp: new Date().toISOString(), + type: 'text', + fromMe: true, + conversationId: 'conv-1', +} as unknown as Message; + +function makeHandlers(editMessageApi: ReturnType) { + return renderHook(() => + useChatPanelHandlers({ + conversationId: 'conv-1', + contactId: EDIT_JID, + contactPhone: '5511999887766', + instanceName: 'wpp2', + onSendMessage: vi.fn(), + editMessageApi, + applySignature: (t: string) => t, + handleTypingStart: vi.fn(), + handleTypingStop: vi.fn(), + openDialog: vi.fn(), + closeDialog: vi.fn(), + handleSetActiveTool: vi.fn(), + }) + ); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockDbFrom.mockReturnValue({ + update: () => ({ + eq: () => ({ + select: () => Promise.resolve({ data: [{ id: 'msg-1' }], error: null }), + }), + }), + }); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('edit message — API called before success toast', () => { + it('calls editMessageApi before showing success toast on happy path', async () => { + const callOrder: string[] = []; + const editMessageApi = vi.fn(async () => { + callOrder.push('api'); + }); + mockToast.mockImplementation(() => { + callOrder.push('toast'); + }); + + const { result } = makeHandlers(editMessageApi); + + act(() => { + result.current.handleEditStart(EDIT_MSG); + }); + act(() => { + result.current.setInputValue('new text'); + }); + + await act(async () => { + await result.current.handleSend(); + }); + + expect(editMessageApi).toHaveBeenCalledTimes(1); + const apiIdx = callOrder.indexOf('api'); + const toastIdx = callOrder.indexOf('toast'); + expect(apiIdx).toBeGreaterThanOrEqual(0); + expect(toastIdx).toBeGreaterThan(apiIdx); + }); + + it('does NOT show success toast when editMessageApi throws', async () => { + const editMessageApi = vi.fn().mockRejectedValue(new Error('Network error')); + + const { result } = makeHandlers(editMessageApi); + + act(() => { + result.current.handleEditStart(EDIT_MSG); + }); + act(() => { + result.current.setInputValue('new text'); + }); + + await act(async () => { + await result.current.handleSend(); + }); + + const successToasts = mockToast.mock.calls.filter(([p]) => + typeof p === 'object' && p !== null && 'variant' in p + ? (p as { variant: string }).variant !== 'destructive' + : true + ); + // Only destructive toasts (errors) should appear when API throws + const destructiveToasts = mockToast.mock.calls.filter( + ([p]) => + typeof p === 'object' && p !== null && (p as { variant?: string }).variant === 'destructive' + ); + expect(successToasts.length).toBe(0); + expect(destructiveToasts.length).toBeGreaterThan(0); + }); +}); + +describe('edit message — precondition guard', () => { + it('shows error toast and does not call API when message has no external_id', async () => { + const editMessageApi = vi.fn(); + const { result } = makeHandlers(editMessageApi); + + const msgWithoutExternalId: Message = { + ...EDIT_MSG, + external_id: undefined, + } as unknown as Message; + act(() => { + result.current.handleEditStart(msgWithoutExternalId); + }); + act(() => { + result.current.setInputValue('new text'); + }); + + await act(async () => { + await result.current.handleSend(); + }); + + expect(editMessageApi).not.toHaveBeenCalled(); + expect(mockToast).toHaveBeenCalledWith(expect.objectContaining({ variant: 'destructive' })); + }); + + it('calls editMessageApi with correct params when all preconditions are met', async () => { + const editMessageApi = vi.fn().mockResolvedValue(undefined); + const { result } = makeHandlers(editMessageApi); + + act(() => { + result.current.handleEditStart(EDIT_MSG); + }); + act(() => { + result.current.setInputValue('updated text'); + }); + + await act(async () => { + await result.current.handleSend(); + }); + + expect(editMessageApi).toHaveBeenCalledWith('wpp2', { + number: EDIT_JID, + messageId: 'ext-abc123', + text: 'updated text', + }); + }); +}); diff --git a/src/features/inbox/hooks/__tests__/useFallbackContact.test.ts b/src/features/inbox/hooks/__tests__/useFallbackContact.test.ts new file mode 100644 index 0000000000..f4da662a31 --- /dev/null +++ b/src/features/inbox/hooks/__tests__/useFallbackContact.test.ts @@ -0,0 +1,119 @@ +/** + * Tests for useFallbackContact — verifies JID vs UUID routing to correct DB column. + * + * Critical regression guard: passing a JID into the `id` (UUID) column causes + * PostgREST 400 "invalid input syntax for type uuid". This hook must detect + * the format and route to the correct filter column. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useFallbackContact } from '../useFallbackContact'; + +// ── Mock supabase client ────────────────────────────────────────────────────── + +const mockMaybeSingle = vi.fn(); +const mockEq = vi.fn(() => ({ maybeSingle: mockMaybeSingle })); +const mockSelect = vi.fn(() => ({ eq: mockEq })); +const mockFrom = vi.fn(() => ({ select: mockSelect })); + +vi.mock('@/integrations/supabase/client', () => ({ + supabase: { + from: (...args: unknown[]) => mockFrom(...args), + }, +})); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const MOCK_UUID = '550e8400-e29b-41d4-a716-446655440000'; +const MOCK_JID = '5511999887766@s.whatsapp.net'; +const MOCK_PHONE = '5511999887766'; + +const mockContact = { id: MOCK_UUID, phone: MOCK_PHONE, name: 'Test Contact' }; + +beforeEach(() => { + vi.clearAllMocks(); + mockMaybeSingle.mockResolvedValue({ data: mockContact, error: null }); +}); + +// ── Tests ───────────────────────────────────────────────────────────────────── + +describe('useFallbackContact — UUID input', () => { + it('routes to id column when contactId is a UUID', async () => { + renderHook(() => useFallbackContact(MOCK_UUID, null)); + + await waitFor(() => { + expect(mockFrom).toHaveBeenCalledWith('contacts'); + expect(mockEq).toHaveBeenCalledWith('id', MOCK_UUID); + }); + }); + + it('does NOT use phone column for UUID input', async () => { + renderHook(() => useFallbackContact(MOCK_UUID, null)); + + await waitFor(() => { + expect(mockEq).not.toHaveBeenCalledWith('phone', expect.anything()); + }); + }); +}); + +describe('useFallbackContact — JID input', () => { + it('routes to phone column when contactId is a JID', async () => { + renderHook(() => useFallbackContact(MOCK_JID, null)); + + await waitFor(() => { + expect(mockFrom).toHaveBeenCalledWith('contacts'); + expect(mockEq).toHaveBeenCalledWith('phone', MOCK_PHONE); + }); + }); + + it('does NOT route JID to id column (would cause PostgREST 400)', async () => { + renderHook(() => useFallbackContact(MOCK_JID, null)); + + await waitFor(() => { + expect(mockEq).not.toHaveBeenCalledWith('id', MOCK_JID); + }); + }); +}); + +describe('useFallbackContact — bare phone input', () => { + it('routes to phone column when contactId is a bare phone number', async () => { + renderHook(() => useFallbackContact(MOCK_PHONE, null)); + + await waitFor(() => { + expect(mockEq).toHaveBeenCalledWith('phone', MOCK_PHONE); + }); + }); +}); + +describe('useFallbackContact — early returns', () => { + it('skips DB call when selectedConversation is already available', () => { + const existing = { + contact: mockContact as never, + messages: [], + unreadCount: 0, + lastMessage: null, + }; + renderHook(() => useFallbackContact(MOCK_UUID, existing)); + + expect(mockFrom).not.toHaveBeenCalled(); + }); + + it('skips DB call when contactId is null', () => { + renderHook(() => useFallbackContact(null, null)); + + expect(mockFrom).not.toHaveBeenCalled(); + }); + + it('returns selectedConversation directly when provided', () => { + const existing = { + contact: mockContact as never, + messages: [], + unreadCount: 0, + lastMessage: null, + }; + const { result } = renderHook(() => useFallbackContact(MOCK_UUID, existing)); + + expect(result.current).toBe(existing); + }); +});