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); + }); + }); });