From fb8935051e7576eab24ed3aa0c3e3617dca85e73 Mon Sep 17 00:00:00 2001 From: ClementLagasse Date: Sat, 1 Nov 2025 23:04:07 +0100 Subject: [PATCH] Add tests for hang-up functionality in ActiveCallContext and AudioManager - Implemented comprehensive tests for the hangUpCall method in ActiveCallContext, ensuring proper state updates and error handling during call termination. - Enhanced AudioManager tests to verify the behavior of the hangUp method under various WebSocket states, including sending messages and handling multiple calls gracefully. - Improved test coverage for scenarios involving active calls, error handling, and UI updates related to call status. This commit strengthens the testing framework for call management functionalities, ensuring robustness and reliability. --- .../__tests__/active-call-context.test.tsx | 288 ++++++++++++++++++ src/services/__tests__/audio-manager.test.ts | 60 ++++ 2 files changed, 348 insertions(+) diff --git a/src/contexts/__tests__/active-call-context.test.tsx b/src/contexts/__tests__/active-call-context.test.tsx index a767353..ad4e3cd 100644 --- a/src/contexts/__tests__/active-call-context.test.tsx +++ b/src/contexts/__tests__/active-call-context.test.tsx @@ -1342,4 +1342,292 @@ describe("ActiveCallContext", () => { consoleWarn.mockRestore(); }); + + describe("hangUpCall", () => { + it("should hang up call successfully and update state", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn(), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function HangUpComponent() { + const { takeCall, hangUpCall, isInCall, callStatus } = useActiveCall(); + return ( +
+ + +
{isInCall ? "In Call" : "Not In Call"}
+
{callStatus || "Unknown"}
+
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call first + fireEvent.click(screen.getByTestId("take-call-btn")); + + await waitFor(() => { + expect(mockHandoffApi.takeControl).toHaveBeenCalled(); + expect(screen.getByTestId("in-call")).toHaveTextContent("In Call"); + }); + + // Now hang up + fireEvent.click(screen.getByTestId("hang-up-btn")); + + await waitFor(() => { + expect(mockAudioManagerInstance.hangUp).toHaveBeenCalled(); + expect(screen.getByTestId("in-call")).toHaveTextContent("Not In Call"); + expect(screen.getByTestId("call-status")).toHaveTextContent("completed"); + }); + }); + + it("should show error when hanging up without active call", () => { + mockSearchParams.set("callId", "call-123"); + + function HangUpComponent() { + const { hangUpCall } = useActiveCall(); + return ( + + ); + } + + render( + + + + ); + + // Try to hang up without taking call first + fireEvent.click(screen.getByTestId("hang-up-btn")); + + // Should not crash, error handling is done via toast + expect(screen.getByTestId("hang-up-btn")).toBeInTheDocument(); + }); + + it("should handle hangUp errors gracefully", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn().mockImplementation(() => { + throw new Error("Failed to hang up"); + }), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function HangUpComponent() { + const { takeCall, hangUpCall } = useActiveCall(); + return ( +
+ + +
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call first + fireEvent.click(screen.getByTestId("take-call-btn")); + + await waitFor(() => { + expect(mockHandoffApi.takeControl).toHaveBeenCalled(); + }); + + // Try to hang up (should handle error) + fireEvent.click(screen.getByTestId("hang-up-btn")); + + // Should not crash + expect(screen.getByTestId("hang-up-btn")).toBeInTheDocument(); + }); + + it("should clear audio manager reference after hang up", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockImplementation(async (_handoffId, _onMessage, onConnected) => { + // Simulate successful connection + if (onConnected) { + onConnected(); + } + }), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn(), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function HangUpComponent() { + const { takeCall, hangUpCall, isAudioConnected } = useActiveCall(); + return ( +
+ + +
+ {isAudioConnected ? "Audio Connected" : "Audio Disconnected"} +
+
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call first + await act(async () => { + fireEvent.click(screen.getByTestId("take-call-btn")); + }); + + await waitFor(() => { + expect(screen.getByTestId("audio-connected")).toHaveTextContent("Audio Connected"); + }); + + // Hang up + fireEvent.click(screen.getByTestId("hang-up-btn")); + + await waitFor(() => { + expect(screen.getByTestId("audio-connected")).toHaveTextContent("Audio Disconnected"); + }); + }); + + it("should update call status to completed after hang up", async () => { + mockSearchParams.set("callId", "call-123"); + + mockHandoffApi.takeControl.mockResolvedValue({ + success: true, + handoffId: "handoff-456", + message: "Control taken", + aiTerminated: true, + }); + + const mockAudioManagerInstance = { + connect: jest.fn().mockResolvedValue(undefined), + disconnect: jest.fn(), + requestMicrophonePermission: jest.fn().mockResolvedValue(true), + setMuted: jest.fn(), + setSpeakerOn: jest.fn(), + isConnected: jest.fn().mockReturnValue(true), + hangUp: jest.fn(), + }; + + (mockAudioManager as unknown as jest.Mock).mockImplementation(() => mockAudioManagerInstance); + + function StatusComponent() { + const { takeCall, hangUpCall, callStatus } = useActiveCall(); + return ( +
+ + +
{callStatus || "No Status"}
+
+ ); + } + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByTestId("take-call-btn")).toBeInTheDocument(); + }); + + // Take call + fireEvent.click(screen.getByTestId("take-call-btn")); + + await waitFor(() => { + expect(screen.getByTestId("status")).toHaveTextContent("active"); + }); + + // Hang up + fireEvent.click(screen.getByTestId("hang-up-btn")); + + await waitFor(() => { + expect(screen.getByTestId("status")).toHaveTextContent("completed"); + }); + }); + }); }); diff --git a/src/services/__tests__/audio-manager.test.ts b/src/services/__tests__/audio-manager.test.ts index 82304c2..9eac3ef 100644 --- a/src/services/__tests__/audio-manager.test.ts +++ b/src/services/__tests__/audio-manager.test.ts @@ -554,4 +554,64 @@ describe("AudioManager", () => { expect(result).toBe(false); }); }); + + describe("hangUp", () => { + it("should send end_call message and disconnect when WebSocket is open", async () => { + const connectPromise = audioManager.connect("handoff-123"); + mockWsInstance.simulateOpen(); + await connectPromise; + + expect(audioManager.isConnected()).toBe(true); + + audioManager.hangUp(); + + // Should have sent end_call message + expect(mockWsInstance.send).toHaveBeenCalledWith( + JSON.stringify({ + type: "end_call", + reason: "Operator ended call", + }) + ); + + // Should disconnect after sending message + expect(mockWsInstance.close).toHaveBeenCalled(); + expect(audioManager.isConnected()).toBe(false); + }); + + it("should only disconnect when WebSocket is not open", () => { + // Don't connect, just call hangUp + audioManager.hangUp(); + + // Should not have sent any message + expect(mockWsInstance.send).not.toHaveBeenCalled(); + + // Should still call disconnect (which is safe when not connected) + expect(audioManager.isConnected()).toBe(false); + }); + + it("should handle hangUp when WebSocket is in CONNECTING state", () => { + audioManager.connect("handoff-123"); + // Don't simulate open, leave in CONNECTING state + + audioManager.hangUp(); + + // Should not send message when not OPEN + expect(mockWsInstance.send).not.toHaveBeenCalled(); + + // Should disconnect immediately + expect(mockWsInstance.close).toHaveBeenCalled(); + }); + + it("should handle multiple hangUp calls gracefully", async () => { + const connectPromise = audioManager.connect("handoff-123"); + mockWsInstance.simulateOpen(); + await connectPromise; + + audioManager.hangUp(); + audioManager.hangUp(); // Call again + + // Should only close once + expect(mockWsInstance.close).toHaveBeenCalledTimes(1); + }); + }); });