test/openagents_web/controllers/voice_call_controller_test.exs

main at 58e6347eeb72 · 5 KB

defmodule OpenAgentsWeb.VoiceCallControllerTest do
  use OpenAgentsWeb.ConnCase, async: false

  setup do
    previous_voice = Application.fetch_env!(:openagents, :voice)
    previous_provider = Application.fetch_env!(:openagents, :voice_call_provider)

    Application.put_env(:openagents, :voice, enabled_voice())
    Application.put_env(:openagents, :voice_call_provider, OpenAgents.Voice.TestCallProvider)
    Application.put_env(:openagents, :voice_call_test_observer, self())
    Application.put_env(:openagents, :voice_sideband_test_observer, self())

    on_exit(fn ->
      Application.put_env(:openagents, :voice, previous_voice)
      Application.put_env(:openagents, :voice_call_provider, previous_provider)
      Application.delete_env(:openagents, :voice_call_test_observer)
      Application.delete_env(:openagents, :voice_sideband_test_observer)
      Application.delete_env(:openagents, :voice_call_test_result)
    end)

    :ok
  end

  test "admits an account-scoped SDP offer without exposing provider credentials", %{conn: conn} do
    conn = post_sdp(conn, "v=0\r\no=browser-offer")

    assert response(conn, 201) == "v=0\r\no=test-answer"
    assert get_resp_header(conn, "content-type") == ["application/sdp; charset=utf-8"]
    assert get_resp_header(conn, "cache-control") == ["no-store"]
    assert get_resp_header(conn, "permissions-policy") == ["microphone=(self)"]

    assert_receive {:voice_call, "v=0\r\no=browser-offer", safety_identifier, config}
    assert_receive {:sideband_started, _sideband, session}
    assert byte_size(safety_identifier) == 64
    assert config.voice == "marin"
    refute inspect(conn.resp_headers) =~ "OPENAI_API_KEY"
    assert {:ok, _ended} = OpenAgents.VoiceSessions.end_session(session)
  end

  test "fails closed when voice is disabled", %{conn: conn} do
    Application.put_env(:openagents, :voice, Keyword.put(enabled_voice(), :enabled, false))

    conn = post_sdp(conn, "v=0\r\no=browser-offer")

    assert json_response(conn, 503) == %{"error" => "voice_unavailable"}
    refute_received {:voice_call, _sdp, _identifier, _config}
  end

  test "ends only the active session resolved from account identity", %{conn: conn} do
    conn = post_sdp(conn, "v=0\r\no=browser-offer")
    assert response(conn, 201)
    assert_receive {:sideband_started, _sideband, session}

    conn = delete_call(recycle(conn))

    assert response(conn, 204) == ""
    assert OpenAgents.Voice.get_session!(session.id).status == "ended"
    assert OpenAgents.VoiceSessions.whereis(session.id) == nil
  end

  test "ending without an active session is idempotent", %{conn: conn} do
    conn = conn |> log_in_github_user("voice-idempotent-user") |> delete_call()

    assert response(conn, 204) == ""
  end

  test "interrupts only a responding session for the current account", %{conn: conn} do
    conn = post_sdp(conn, "v=0\r\no=browser-offer")
    assert response(conn, 201)
    assert_receive {:sideband_started, _sideband, session}

    assert {:ok, responding, _event, :created} =
             OpenAgents.Voice.record_provider_event(
               session,
               session.generation,
               %OpenAgents.Voice.ProviderEvent{
                 kind: :response_started,
                 provider_event_id: "evt-controller-response",
                 payload: %{"response_id" => "response-controller"}
               }
             )

    conn = post_interrupt(recycle(conn))

    assert response(conn, 204) == ""
    assert_receive {:sideband_event_sent, %{"type" => "response.cancel"}}
    assert OpenAgents.Voice.get_session!(responding.id).status == "interrupted"
    assert {:ok, _ended} = OpenAgents.VoiceSessions.end_session(responding)
  end

  test "refuses voice admission while a text turn is active", %{conn: conn} do
    token = "voice-text-conflict-browser-credential-000000000000"
    user = github_user(token)
    {:ok, conversation} = OpenAgents.Conversations.ensure_conversation(user)
    {:ok, records} = OpenAgents.Conversations.create_turn(conversation, "A text turn is active")

    conn =
      conn
      |> post_sdp("v=0\r\no=browser-offer", token)

    assert json_response(conn, 409) == %{"error" => "text_turn_in_progress"}
    refute_received {:voice_call, _sdp, _identifier, _config}
    assert {:ok, _failed} = OpenAgents.Conversations.fail_turn(records.turn, :test_cleanup)
  end

  test "rejects malformed SDP before calling the provider", %{conn: conn} do
    conn = post_sdp(conn, "not-sdp")

    assert json_response(conn, 400) == %{"error" => "invalid_sdp"}
    refute_received {:voice_call, _sdp, _identifier, _config}
  end

  defp post_sdp(conn, sdp, key \\ "voice-controller-user") do
    csrf_token = Plug.CSRFProtection.get_csrf_token()

    conn
    |> log_in_github_user(key)
    |> put_req_header("accept", "text/html")
    |> put_req_header("content-type", "application/sdp")
    |> put_req_header("x-csrf-token", csrf_token)
    |> post(~p"/voice/calls", sdp)
  end

  defp delete_call(conn) do
    csrf_token = Plug.CSRFProtection.get_csrf_token()

    conn
    |> put_req_header("accept", "text/html")
    |> put_req_header("x-csrf-token", csrf_token)
    |> delete(~p"/voice/calls")
  end

  defp post_interrupt(conn) do
    csrf_token = Plug.CSRFProtection.get_csrf_token()

    conn
    |> put_req_header("accept", "text/html")
    |> put_req_header("x-csrf-token", csrf_token)
    |> post(~p"/voice/calls/interrupt")
  end

  defp enabled_voice do
    [
      enabled: true,
      architecture: :openai_realtime,
      provider: "openai",
      model: "gpt-realtime-2.1",
      voice: "marin",
      reasoning_effort: "low",
      maximum_session_seconds: 3_000
    ]
  end
end