Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
288 changes: 288 additions & 0 deletions src/contexts/__tests__/active-call-context.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<button onClick={takeCall} data-testid="take-call-btn">
Take Call
</button>
<button onClick={hangUpCall} data-testid="hang-up-btn">
Hang Up
</button>
<div data-testid="in-call">{isInCall ? "In Call" : "Not In Call"}</div>
<div data-testid="call-status">{callStatus || "Unknown"}</div>
</div>
);
}

render(
<ActiveCallProvider>
<HangUpComponent />
</ActiveCallProvider>
);

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 (
<button onClick={hangUpCall} data-testid="hang-up-btn">
Hang Up
</button>
);
}

render(
<ActiveCallProvider>
<HangUpComponent />
</ActiveCallProvider>
);

// 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 (
<div>
<button onClick={takeCall} data-testid="take-call-btn">
Take Call
</button>
<button onClick={hangUpCall} data-testid="hang-up-btn">
Hang Up
</button>
</div>
);
}

render(
<ActiveCallProvider>
<HangUpComponent />
</ActiveCallProvider>
);

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 (
<div>
<button onClick={takeCall} data-testid="take-call-btn">
Take Call
</button>
<button onClick={hangUpCall} data-testid="hang-up-btn">
Hang Up
</button>
<div data-testid="audio-connected">
{isAudioConnected ? "Audio Connected" : "Audio Disconnected"}
</div>
</div>
);
}

render(
<ActiveCallProvider>
<HangUpComponent />
</ActiveCallProvider>
);

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 (
<div>
<button onClick={takeCall} data-testid="take-call-btn">
Take Call
</button>
<button onClick={hangUpCall} data-testid="hang-up-btn">
Hang Up
</button>
<div data-testid="status">{callStatus || "No Status"}</div>
</div>
);
}

render(
<ActiveCallProvider>
<StatusComponent />
</ActiveCallProvider>
);

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");
});
});
});
});
60 changes: 60 additions & 0 deletions src/services/__tests__/audio-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
Loading