import { renderHook, waitFor } from '@testing-library/react-native'; import { useWidgetBootstrap } from '../hooks/useWidgetBootstrap'; import { requestWidgetUrl, requestWidgetOptions } from '../services/WidgetBootstrapService'; import type { WidgetStateManager } from '../services/WidgetStateManager'; import type { WidgetValidationService } from '../services/WidgetValidationService'; import type { WidgetData, WidgetOptions, WidgetCallbacks } from '../domain'; // Mock dos serviços jest.mock('../services/WidgetBootstrapService'); const mockRequestWidgetUrl = requestWidgetUrl as jest.MockedFunction; const mockRequestWidgetOptions = requestWidgetOptions as jest.MockedFunction; describe('useWidgetBootstrap', () => { let mockOpen: jest.Mock; let mockHide: jest.Mock; let mockOnError: jest.Mock; let mockOnBlocked: jest.Mock; let mockOnPreOpen: jest.Mock; let mockOnOpened: jest.Mock; let mockValidationService: jest.Mocked; let mockStateManager: jest.Mocked; const defaultParams = { soluCXKey: 'test-key-123', data: { journey: 'test-journey', customer_id: 'user123', } as WidgetData, userId: 'user123', options: { maxAttemptsAfterDismiss: 1, height: 400, } as WidgetOptions, callbacks: {} as WidgetCallbacks, open: jest.fn(), hide: jest.fn(), validationService: {} as any, stateManager: {} as any, }; beforeEach(() => { jest.clearAllMocks(); // Mocks das funções mockOpen = jest.fn().mockResolvedValue(undefined); mockHide = jest.fn(); mockOnError = jest.fn(); mockOnBlocked = jest.fn(); mockOnPreOpen = jest.fn(); mockOnOpened = jest.fn(); // Mock do ValidationService mockValidationService = { shouldDisplayWidget: jest.fn().mockResolvedValue({ canDisplay: true, blockReason: undefined, }), shouldDisplayForTransactionAlreadyAnswered: jest.fn().mockResolvedValue({ canDisplay: true, blockReason: undefined, }), } as any; // Mock do StateManager mockStateManager = { getLogs: jest.fn().mockResolvedValue({ attempts: 0, answeredTransactionIds: [], lastFirstAccess: undefined, }), updateTimestamp: jest.fn().mockResolvedValue(undefined), incrementAttempt: jest.fn().mockResolvedValue(undefined), clearLogs: jest.fn().mockResolvedValue(undefined), } as any; // Mock das funções do serviço de bootstrap mockRequestWidgetOptions.mockResolvedValue({ maxAttemptsAfterDismiss: 2, height: 400, }); mockRequestWidgetUrl.mockResolvedValue({ available: true, url: 'https://widget.test.com/survey?token=abc123', }); }); describe('bootstrap - Happy Path', () => { it('should complete full bootstrap when all validations pass', async () => { const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, open: mockOpen, hide: mockHide, callbacks: { onPreOpen: mockOnPreOpen, onOpened: mockOnOpened, }, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(result.current.widgetUri).toBe('https://widget.test.com/survey?token=abc123'); }); // Verificar ordem das chamadas expect(mockValidationService.shouldDisplayForTransactionAlreadyAnswered).toHaveBeenCalledWith(undefined); expect(mockValidationService.shouldDisplayWidget).toHaveBeenCalled(); expect(mockRequestWidgetUrl).toHaveBeenCalledWith('test-key-123', defaultParams.data, 'user123'); expect(mockOnPreOpen).toHaveBeenCalledWith('user123'); expect(mockOpen).toHaveBeenCalled(); expect(mockStateManager.incrementAttempt).toHaveBeenCalled(); expect(mockStateManager.updateTimestamp).toHaveBeenCalledWith('lastDisplay'); expect(mockOnOpened).toHaveBeenCalledWith('user123'); }); it('should set lastFirstAccess timestamp when widget is displayed for the first time', async () => { mockStateManager.getLogs.mockResolvedValue({ attempts: 0, answeredTransactionIds: [], lastFirstAccess: undefined, // Primeira vez }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockStateManager.updateTimestamp).toHaveBeenCalledWith('lastFirstAccess'); }); }); it('should not update lastFirstAccess when widget is displayed again', async () => { mockStateManager.getLogs.mockResolvedValue({ attempts: 2, answeredTransactionIds: [], lastFirstAccess: 1000, // Já tem valor }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockStateManager.updateTimestamp).not.toHaveBeenCalledWith('lastFirstAccess'); expect(mockStateManager.updateTimestamp).toHaveBeenCalledWith('lastDisplay'); }); }); it('should use provided options when options are passed', async () => { const customOptions: WidgetOptions = { maxAttemptsAfterDismiss: 3, height: 500, }; const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, options: customOptions, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockRequestWidgetOptions).not.toHaveBeenCalled(); expect(result.current.widgetOptions).toEqual(customOptions); }); }); it('should request options from API when options are not provided', async () => { const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, options: undefined, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockRequestWidgetOptions).toHaveBeenCalledWith('test-key-123', 'test-journey'); expect(result.current.widgetOptions).toEqual({ maxAttemptsAfterDismiss: 2, height: 400, }); }); }); }); describe('bootstrap - Validation Blocking', () => { it('should block widget when transaction has already been answered', async () => { mockValidationService.shouldDisplayForTransactionAlreadyAnswered.mockResolvedValue({ canDisplay: false, blockReason: 'BLOCKED_BY_TRANSACTION_ALREADY_ANSWERED', }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, data: { ...defaultParams.data, transaction_id: 'tx123', }, callbacks: { onBlocked: mockOnBlocked, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnBlocked).toHaveBeenCalledWith('BLOCKED_BY_TRANSACTION_ALREADY_ANSWERED'); expect(mockHide).toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); expect(mockRequestWidgetUrl).not.toHaveBeenCalled(); }); }); it('should block widget when validation rules fail', async () => { mockValidationService.shouldDisplayWidget.mockResolvedValue({ canDisplay: false, blockReason: 'BLOCKED_BY_MAX_ATTEMPTS', }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onBlocked: mockOnBlocked, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnBlocked).toHaveBeenCalledWith('BLOCKED_BY_MAX_ATTEMPTS'); expect(mockHide).toHaveBeenCalled(); expect(mockStateManager.updateTimestamp).toHaveBeenCalledWith('lastDisplayAttempt'); expect(mockRequestWidgetUrl).not.toHaveBeenCalled(); }); }); it('should call onError when saving display attempt timestamp fails', async () => { mockValidationService.shouldDisplayWidget.mockResolvedValue({ canDisplay: false, blockReason: 'BLOCKED_BY_DISABLED', }); mockStateManager.updateTimestamp.mockRejectedValue(new Error('Storage error')); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onBlocked: mockOnBlocked, onError: mockOnError, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('Cannot save display attempt timestamp'); expect(mockHide).toHaveBeenCalled(); }); }); }); describe('bootstrap - Preflight Blocking', () => { it('should block widget when preflight indicates unavailability', async () => { mockRequestWidgetUrl.mockResolvedValue({ available: false, reason: 'Survey not active', }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onBlocked: mockOnBlocked, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnBlocked).toHaveBeenCalledWith('Survey not active'); expect(mockHide).toHaveBeenCalled(); expect(mockOpen).not.toHaveBeenCalled(); }); }); it('should use default block reason when preflight returns no reason', async () => { mockRequestWidgetUrl.mockResolvedValue({ available: false, }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onBlocked: mockOnBlocked, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnBlocked).toHaveBeenCalledWith('for the given parameters'); expect(mockHide).toHaveBeenCalled(); }); }); it('should call onError when widget URL is missing in response', async () => { mockRequestWidgetUrl.mockResolvedValue({ available: true, url: undefined as any, }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onError: mockOnError, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('Widget URL is missing in the response'); expect(mockOpen).not.toHaveBeenCalled(); }); }); }); describe('bootstrap - Error Handling', () => { it('should call onError when widget data is missing', async () => { const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, data: undefined as any, callbacks: { onError: mockOnError, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('Widget data is required but was not provided'); expect(mockOpen).not.toHaveBeenCalled(); }); }); it('should call onError when requestWidgetUrl throws error', async () => { mockRequestWidgetUrl.mockRejectedValue(new Error('Network error')); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onError: mockOnError, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('Network error'); expect(mockHide).toHaveBeenCalled(); }); }); it('should call onError when error is a string', async () => { mockRequestWidgetUrl.mockRejectedValue('Connection timeout'); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onError: mockOnError, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('Connection timeout'); }); }); it('should call onError when error is an object', async () => { mockRequestWidgetUrl.mockRejectedValue({ code: 500, message: 'Server error' }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onError: mockOnError, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('{"code":500,"message":"Server error"}'); }); }); it('should call onError when error type is unknown', async () => { mockRequestWidgetUrl.mockRejectedValue(null); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onError: mockOnError, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('Unknown error'); }); }); it('should call onError when saving display timestamps fails', async () => { mockStateManager.incrementAttempt.mockRejectedValue(new Error('Storage full')); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onError: mockOnError, onOpened: mockOnOpened, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockOnError).toHaveBeenCalledWith('Error saving display timestamps'); // O widget ainda deve abrir mesmo com erro no timestamp expect(mockOnOpened).toHaveBeenCalled(); }); }); }); describe('bootstrap - State Management', () => { it('should reset widgetUri to null when bootstrap starts', async () => { const { result, rerender } = renderHook(() => useWidgetBootstrap({ ...defaultParams, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); // Primeiro bootstrap await result.current.bootstrap(); await waitFor(() => { expect(result.current.widgetUri).toBe('https://widget.test.com/survey?token=abc123'); }); // Segundo bootstrap - deve limpar URI primeiro mockRequestWidgetUrl.mockResolvedValue({ available: true, url: 'https://widget.test.com/survey?token=xyz789', }); await result.current.bootstrap(); // Verificar que foi resetado e depois atualizado await waitFor(() => { expect(result.current.widgetUri).toBe('https://widget.test.com/survey?token=xyz789'); }); }); it('should pass undefined as experienceId when transaction_id is not provided', async () => { const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, data: { form_id: 'form123', customer_id: 'user123', }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(mockValidationService.shouldDisplayWidget).toHaveBeenCalledWith( expect.anything(), undefined ); }); }); }); describe('bootstrap - Validation Before Preflight', () => { it('should validate before preflight when bootstrap is called', async () => { const callOrder: string[] = []; mockValidationService.shouldDisplayWidget.mockImplementation(async () => { callOrder.push('validation'); return { canDisplay: false, blockReason: 'BLOCKED_BY_DISABLED' }; }); mockRequestWidgetUrl.mockImplementation(async () => { callOrder.push('preflight'); return { available: true, url: 'test' }; }); const { result } = renderHook(() => useWidgetBootstrap({ ...defaultParams, callbacks: { onBlocked: mockOnBlocked, }, open: mockOpen, hide: mockHide, validationService: mockValidationService, stateManager: mockStateManager, }) ); await result.current.bootstrap(); await waitFor(() => { expect(callOrder).toEqual(['validation']); expect(mockRequestWidgetUrl).not.toHaveBeenCalled(); }); }); }); });