diff --git a/src/app/(protected)/active-call/page.tsx b/src/app/(protected)/active-call/page.tsx index b15ff4b..16647e6 100644 --- a/src/app/(protected)/active-call/page.tsx +++ b/src/app/(protected)/active-call/page.tsx @@ -86,6 +86,7 @@ export default function ActiveCallPage() { setIsSpeakerOn, isInCall, takeCall, + hangUpCall, isAudioConnected, } = useActiveCall(); @@ -425,9 +426,7 @@ export default function ActiveCallPage() {
- - Auto-translating from Spanish - + Auto-translating
@@ -465,36 +464,6 @@ export default function ActiveCallPage() { {/* Insights List */}
- {/* WebSocket Connection Status */} -
-
-
- - - {wsConnectionState.isConnected - ? "Real-time Updates Active" - : "Disconnected"} - -
- {wsConnectionState.sessionId && ( - - {wsConnectionState.sessionId.slice(0, 8)} - - )} -
-
- - {aiInsights.map((insight, index) => ( - - ))} - {/* Caller Info Card - Real Data from WebSocket */}
@@ -539,7 +508,9 @@ export default function ActiveCallPage() {
- + {aiInsights.map((insight, index) => ( + + ))} {/* Connection Events Log (for debugging) */} {connectionEvents.length > 0 && (
@@ -600,7 +571,10 @@ export default function ActiveCallPage() { )} - 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/contexts/active-call-context.tsx b/src/contexts/active-call-context.tsx index a00957f..f60139a 100644 --- a/src/contexts/active-call-context.tsx +++ b/src/contexts/active-call-context.tsx @@ -36,6 +36,7 @@ interface ActiveCallContextType { setIsSpeakerOn: (speakerOn: boolean) => void; isInCall: boolean; takeCall: () => Promise; + hangUpCall: () => void; isAudioConnected: boolean; } @@ -210,6 +211,31 @@ export function ActiveCallProvider({ children }: ActiveCallProviderProps) { } }; + const hangUpCall = () => { + if (!audioManagerRef.current) { + toast.error("No active call to hang up"); + return; + } + + try { + // Send hang up message via WebSocket + audioManagerRef.current.hangUp(); + + // Update UI state + setIsInCall(false); + setIsAudioConnected(false); + setCallStatus("completed"); + + toast.success("Call ended"); + + // Clear the audio manager reference + audioManagerRef.current = null; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to hang up call"; + toast.error(errorMessage); + } + }; + // WebSocket connection state monitoring useEffect(() => { console.log("🔌 [ACTIVE-CALL] Setting up connection state monitoring"); @@ -327,6 +353,7 @@ export function ActiveCallProvider({ children }: ActiveCallProviderProps) { setIsSpeakerOn: handleSetSpeakerOn, isInCall, takeCall, + hangUpCall, isAudioConnected, }; 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); + }); + }); }); diff --git a/src/services/audio-manager.ts b/src/services/audio-manager.ts index 976016e..6047974 100644 --- a/src/services/audio-manager.ts +++ b/src/services/audio-manager.ts @@ -314,6 +314,23 @@ export class AudioManager { return this.ws !== null && this.ws.readyState === WebSocket.OPEN; } + /** + * Hang up the call - sends end_call message to backend + */ + hangUp(): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send( + JSON.stringify({ + type: "end_call", + reason: "Operator ended call", + }) + ); + } + + // Disconnect after sending end_call message + this.disconnect(); + } + /** * Disconnect and cleanup */