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